feat(finance): 订单页人工录单按单实际金额计入成本

This commit is contained in:
yml2213
2026-10-22 19:48:56 +08:00
parent 7c8832078f
commit 74b62f3494
10 changed files with 465 additions and 40 deletions
@@ -0,0 +1,233 @@
import { App, Alert, Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
import type { FormInstance } from 'antd'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { createAdminManualOrderCost } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
export type ManualOrderCostItem = {
orderItemId: number
itemTitle: string
skuNick?: string
quantity?: number
}
type ManualOrderCostModalProps = {
open: boolean
orderId: number
orderNo: string
items: ManualOrderCostItem[]
onClose: () => void
}
type ManualOrderCostFormValues = {
orderItemId: number
amountYuan: number
expenseCategory: string
payeeName?: string
paymentMethod?: string
evidence?: string
note?: string
effective?: boolean
}
const CATEGORY_OPTIONS = [
{ value: 'service_fee', label: '人工服务费' },
{ value: 'goods_purchase', label: '商品采购' },
{ value: 'compensation', label: '赔付 / 补偿' },
{ value: 'refund_advance', label: '垫付退款' },
{ value: 'freight', label: '运费' },
{ value: 'other', label: '其他' },
]
const PAYMENT_METHOD_OPTIONS = [
{ value: 'wechat', label: '微信' },
{ value: 'alipay', label: '支付宝' },
{ value: 'bank', label: '银行卡' },
{ value: 'cash', label: '现金' },
{ value: 'platform_balance', label: '平台余额' },
{ value: 'other', label: '其他' },
]
function itemLabel(item: ManualOrderCostItem) {
const parts = [item.itemTitle || `订单项 #${item.orderItemId}`]
if (item.skuNick) parts.push(item.skuNick)
if (item.quantity && item.quantity > 1) parts.push(`×${item.quantity}`)
return parts.join(' · ')
}
export default function ManualOrderCostModal({
open,
orderId,
orderNo,
items,
onClose,
}: ManualOrderCostModalProps) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ManualOrderCostFormValues>()
const [canApprove, setCanApprove] = useState(false)
useEffect(() => {
if (!open) return
setCanApprove(hasAdminPermission('finance.review'))
form.resetFields()
form.setFieldsValue({
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
amountYuan: undefined,
expenseCategory: 'service_fee',
paymentMethod: 'other',
effective: hasAdminPermission('finance.review'),
})
}, [form, items, open])
const mutation = useMutation({
mutationFn: (values: ManualOrderCostFormValues) =>
createAdminManualOrderCost({
orderId,
orderItemId: values.orderItemId,
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
expenseCategory: values.expenseCategory,
payeeName: values.payeeName || '内部人工',
paymentMethod: values.paymentMethod || 'other',
note: values.note || '',
evidenceJson: values.evidence
? { reference: values.evidence }
: { note: values.note || '订单页人工录单' },
effective: canApprove ? values.effective !== false : false,
}),
onSuccess: (response) => {
const result = response.data
if (result.effective) {
message.success(`已计入订单成本,支出单号 ${result.record.expenseNo}`)
} else if (result.reason === 'approval_failed') {
message.warning(result.message || '已提交,但自动生效失败,请到人工支出页审核')
} else {
message.success(`已提交人工支出 ${result.record.expenseNo},财务审核后计入毛利`)
}
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
onClose()
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '录入人工成本失败')
},
})
async function submit() {
const values = await form.validateFields()
mutation.mutate(values)
}
return (
<Modal
open={open}
title="人工录单 / 录入订单成本"
okText={canApprove ? '确认录入并计入成本' : '提交审核'}
cancelText="取消"
confirmLoading={mutation.isPending}
onOk={() => void submit()}
onCancel={onClose}
destroyOnHidden
width={620}
>
<Form<ManualOrderCostFormValues>
form={form as FormInstance<ManualOrderCostFormValues>}
layout="vertical"
initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="每一单单独录实际金额,不按小时、不按单量套模板"
description="成本会先关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
/>
<Form.Item label="订单号">
<Input value={orderNo} readOnly />
</Form.Item>
{items.length === 1 ? (
<>
<Form.Item label="计入订单项">
<Input value={itemLabel(items[0])} readOnly />
</Form.Item>
<Form.Item name="orderItemId" hidden>
<Input />
</Form.Item>
</>
) : (
<Form.Item
name="orderItemId"
label="计入订单项"
rules={[{ required: true, message: '请选择订单项' }]}
>
<Select
placeholder="订单有多个商品,请选择计入哪一项"
options={items.map((item) => ({
value: item.orderItemId,
label: itemLabel(item),
}))}
/>
</Form.Item>
)}
<Form.Item
name="amountYuan"
label="人工成本金额(元)"
rules={[{ required: true, message: '请输入实际金额' }]}
>
<InputNumber
min={0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
<Form.Item
name="expenseCategory"
label="费用类型"
rules={[{ required: true, message: '请选择费用类型' }]}
>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
<Form.Item name="payeeName" label="处理人 / 收款方">
<Input placeholder="例如:客服小王、上游商家;留空记内部人工" maxLength={120} />
</Form.Item>
<Form.Item name="paymentMethod" label="付款方式">
<Select options={PAYMENT_METHOD_OPTIONS} />
</Form.Item>
<Form.Item name="evidence" label="凭证链接 / 流水号(可选)">
<Input placeholder="转账截图链接、流水号等;留空则用备注作为凭证" maxLength={300} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="这一单实际做了什么、为什么是这个金额"
/>
</Form.Item>
{canApprove ? (
<Form.Item
name="effective"
label="立即计入成本"
valuePropName="checked"
extra="关闭后先提交,等财务在人工支出页审核通过再计入毛利。"
>
<Switch />
</Form.Item>
) : (
<Alert
type="warning"
showIcon
message="提交后需要财务审核"
description="当前账号没有 finance.review 权限,录入后状态为待审核,审核通过才会写入成本快照。"
/>
)}
</Form>
</Modal>
)
}
@@ -22,10 +22,11 @@ import {
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
@@ -38,10 +39,27 @@ export default function AdminOrderDetailPage() {
const queryClient = useQueryClient()
const { message, modal } = App.useApp()
const { orderId = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [manualCostOpen, setManualCostOpen] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
useEffect(() => {
if (searchParams.get('manualCost') === '1') {
setManualCostOpen(true)
}
}, [searchParams])
function closeManualCost() {
setManualCostOpen(false)
if (searchParams.get('manualCost')) {
const next = new URLSearchParams(searchParams)
next.delete('manualCost')
setSearchParams(next, { replace: true })
}
}
const query = useQuery({
queryKey: ['admin-order-detail', orderId],
queryFn: () => fetchAdminOrderDetail(orderId),
@@ -183,11 +201,8 @@ export default function AdminOrderDetailPage() {
title="基础信息"
extra={
<Space>
<Button
icon={<FileTextOutlined />}
onClick={() => navigate(`/admin/manual-expenses?orderId=${detail.order.orderId}`)}
>
登记人工支出
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
人工录单
</Button>
{isAutoClaimOrder ? (
<>
@@ -368,6 +383,19 @@ export default function AdminOrderDetailPage() {
/>
</div>
</Modal>
<ManualOrderCostModal
open={manualCostOpen}
orderId={detail.order.orderId}
orderNo={detail.order.platformOrderId || String(detail.order.orderId)}
items={detail.items.map((item) => ({
orderItemId: item.orderItemId,
itemTitle: item.itemTitle,
skuNick: item.skuNick,
quantity: item.quantity,
}))}
onClose={closeManualCost}
/>
</section>
)
}
@@ -1,5 +1,6 @@
import {
CopyOutlined,
DollarOutlined,
EyeOutlined,
LinkOutlined,
ReloadOutlined,
@@ -333,11 +334,13 @@ export default function AdminOrdersPage() {
},
{
title: '操作',
minWidth: 130,
minWidth: 220,
fixed: 'right',
render: (_, row) => {
const orderNo = row.platformOrderId || String(row.orderId)
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
const isManualOrder =
row.fulfillmentType === 'manual' || (!row.canClaimH5 && !row.workOrderCount)
return (
<Space size={6} wrap>
@@ -350,6 +353,17 @@ export default function AdminOrdersPage() {
>
详情
</Button>
{isManualOrder ? (
<Tooltip title="人工录单:录入这一单的实际人工成本">
<Button
size="small"
icon={<DollarOutlined />}
onClick={() => navigate(`/admin/orders/${row.orderId}?manualCost=1`)}
>
人工录单
</Button>
</Tooltip>
) : null}
{row.canClaimH5 ? (
<Tooltip title="复制自动直充 H5 领取链接">
<Button
@@ -49,6 +49,15 @@ export function createAdminManualExpense(payload: Record<string, unknown>) {
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
}
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
return apiPost<{
record: AdminManualExpense
effective: boolean
reason: 'approved' | 'pending_review' | 'approval_failed'
message?: string
}>('/api/v1/admin/finance/manual-order-costs', payload)
}
export function approveAdminManualExpense(
expenseId: number,
payload: Record<string, unknown> = {},