公司人工支出登记单与审批报销状态机整链下线,人工成本只留一条链路。
口径调整(2026-10-23 确认):
- 历史人工支出找不回来,直接放弃:不补录、不做样本校准、不做均价估算,
只保证上线后的新单不丢。
- 人工成本逐单按实际金额,从客服备用金扣款,客服只能用本人账户;
没有登记单、没有审批、没有报销,录入即计入毛利。
后端:
- 新增 services/finance/manual-order-cost-service.ts:扣备用金 + 写 actual
成本快照,同一事务,余额不足直接拒绝不允许负余额。
- 成本快照主体从「人工支出登记单 id」改为「客服备用金支出单 id」
(subject_type='manual_case',subject_id=manual_expenses.id)。
- 删除 services/finance/manual-expense-service.ts(1263 行)与
routes/admin/finance.ts 下 /finance/manual-expenses* 全套接口。
前端:
- 删除 AdminManualExpensePage.tsx(778 行)与 /admin/manual-expenses 菜单页。
- ManualOrderCostModal 去掉「提交审核 / 立即计入成本」开关,录入即生效。
- 新增 services/admin/manual-order-cost.ts。
迁移:
- 106_manual_expense_records.sql → 106_fund_account_no_readable.sql,
只保留备用金账户编号可读化。
- 108_drop_manual_expense_records.sql:删除 manual_expense_records 与
manual_expense_events 两张表。
- 109_isolate_legacy_manual_snapshots.sql:把来源为旧登记链的历史孤儿快照
subject_id 改为负数,与 manual_expenses.id 彻底隔离。
踩坑修复(E2E 实测发现):主体 id 换空间后与历史孤儿快照撞唯一索引
uq_cost_snapshots_active_subject_item,INSERT ... ON CONFLICT DO NOTHING
静默跳过,导致「钱从备用金扣了、成本快照却没写成」。修法是迁移 109 隔离
legacy 主体 + 后端快照写失败必须抛错回滚(409),杜绝账实不符。
文档:现行 5 篇 + README 基线校准为 dev @ 43f402cf + 迁移 103–109,
修正已删除的旧迁移号,删除「人工支出登记 + 审批 + 报销」口径,
已作废 DDL 草案移入 docs/archive/2026-10-23/。
校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测两笔同订单项各自独立主体、余额扣减与快照金额一一对应;
本地库迁移 108/109 执行后数据量与改造前完全一致。
Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
272 lines
9.4 KiB
TypeScript
272 lines
9.4 KiB
TypeScript
import { App, Alert, Form, Input, InputNumber, Modal, Select } from 'antd'
|
||
import type { FormInstance } from 'antd'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useEffect, useState } from 'react'
|
||
|
||
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
|
||
import { getAdminRole } from '@/utils/admin-auth'
|
||
|
||
export type ManualOrderCostItem = {
|
||
orderItemId: number
|
||
matchSku?: string
|
||
skuName?: string
|
||
skuCode?: string
|
||
quantity?: number
|
||
}
|
||
|
||
type ManualOrderCostModalProps = {
|
||
open: boolean
|
||
orderId: number
|
||
orderNo: string
|
||
items: ManualOrderCostItem[]
|
||
onClose: () => void
|
||
}
|
||
|
||
type ManualOrderCostFormValues = {
|
||
orderItemId: number
|
||
fundAccountId: number
|
||
amountYuan: number
|
||
expenseCategory: string
|
||
payeeName?: string
|
||
paymentMethod?: string
|
||
evidence?: string
|
||
note?: string
|
||
}
|
||
|
||
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) {
|
||
// 只展示平台判断用的真实 SKU,引流长标题不进录单选项。
|
||
const sku = item.matchSku || item.skuName || item.skuCode || `订单项 #${item.orderItemId}`
|
||
return item.quantity && item.quantity > 1 ? `${sku} ×${item.quantity}` : sku
|
||
}
|
||
|
||
export default function ManualOrderCostModal({
|
||
open,
|
||
orderId,
|
||
orderNo,
|
||
items,
|
||
onClose,
|
||
}: ManualOrderCostModalProps) {
|
||
const { message } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const [form] = Form.useForm<ManualOrderCostFormValues>()
|
||
const fundAccountsQuery = useQuery({
|
||
queryKey: ['admin-fund-accounts', 'agent'],
|
||
queryFn: () =>
|
||
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
|
||
enabled: open,
|
||
})
|
||
const fundAccounts = fundAccountsQuery.data?.data.items || []
|
||
const soleFundAccountId = fundAccounts.length === 1 ? fundAccounts[0]?.id : undefined
|
||
const isSupport = getAdminRole() === 'support'
|
||
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
form.resetFields()
|
||
form.setFieldsValue({
|
||
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
|
||
fundAccountId: soleFundAccountId,
|
||
amountYuan: undefined,
|
||
expenseCategory: 'service_fee',
|
||
paymentMethod: 'other',
|
||
})
|
||
}, [form, items, open, soleFundAccountId])
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: (values: ManualOrderCostFormValues) =>
|
||
createAdminManualOrderCost({
|
||
orderId,
|
||
orderItemId: values.orderItemId,
|
||
fundAccountId: values.fundAccountId,
|
||
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 || '订单页人工录单' },
|
||
}),
|
||
onSuccess: (response) => {
|
||
const result = response.data
|
||
if (result.warning) {
|
||
message.warning(result.warning)
|
||
} else {
|
||
message.success(`已计入订单成本,支出单号 ${result.expenseNo}`)
|
||
}
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-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="确认录入并计入成本"
|
||
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>
|
||
{isSupport ? (
|
||
<>
|
||
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
|
||
<Input
|
||
readOnly
|
||
value={
|
||
ownFundAccount
|
||
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ¥${(
|
||
ownFundAccount.balanceFen / 100
|
||
).toFixed(2)}`
|
||
: '当前账号还没有备用金账户'
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="fundAccountId" hidden>
|
||
<Input />
|
||
</Form.Item>
|
||
</>
|
||
) : (
|
||
<Form.Item
|
||
name="fundAccountId"
|
||
label="备用金账户"
|
||
rules={[{ required: true, message: '请选择备用金账户' }]}
|
||
extra="人工支出会直接从该客服的备用金余额扣减"
|
||
>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
loading={fundAccountsQuery.isLoading}
|
||
placeholder="请选择这笔支出从哪个客服备用金账户出"
|
||
options={fundAccounts.map((account) => ({
|
||
value: account.id,
|
||
label: `${account.ownerName} · ${account.accountNo} · 余额 ¥${(
|
||
account.balanceFen / 100
|
||
).toFixed(2)}`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
style={{ marginBottom: 14 }}
|
||
message="没有可用的客服备用金账户"
|
||
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
|
||
/>
|
||
) : null}
|
||
{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>
|
||
</Form>
|
||
</Modal>
|
||
)
|
||
}
|