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>
)
}