refactor(finance): 人工成本收敛为「人工录单 → 扣备用金 → 写快照」
公司人工支出登记单与审批报销状态机整链下线,人工成本只留一条链路。
口径调整(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>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
43f402cf72
commit
fb679d76f6
@@ -1,10 +1,10 @@
|
||||
import { App, Alert, Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
|
||||
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, hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
export type ManualOrderCostItem = {
|
||||
orderItemId: number
|
||||
@@ -31,7 +31,6 @@ type ManualOrderCostFormValues = {
|
||||
paymentMethod?: string
|
||||
evidence?: string
|
||||
note?: string
|
||||
effective?: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
@@ -68,7 +67,6 @@ export default function ManualOrderCostModal({
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ManualOrderCostFormValues>()
|
||||
const [canApprove, setCanApprove] = useState(false)
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-fund-accounts', 'agent'],
|
||||
queryFn: () =>
|
||||
@@ -82,7 +80,6 @@ export default function ManualOrderCostModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCanApprove(hasAdminPermission('finance.review'))
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
|
||||
@@ -90,7 +87,6 @@ export default function ManualOrderCostModal({
|
||||
amountYuan: undefined,
|
||||
expenseCategory: 'service_fee',
|
||||
paymentMethod: 'other',
|
||||
effective: hasAdminPermission('finance.review'),
|
||||
})
|
||||
}, [form, items, open, soleFundAccountId])
|
||||
|
||||
@@ -108,19 +104,16 @@ export default function ManualOrderCostModal({
|
||||
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 || '已提交,但自动生效失败,请到人工支出页审核')
|
||||
if (result.warning) {
|
||||
message.warning(result.warning)
|
||||
} else {
|
||||
message.success(`已提交人工支出 ${result.record.expenseNo},财务审核后计入毛利`)
|
||||
message.success(`已计入订单成本,支出单号 ${result.expenseNo}`)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
|
||||
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)] })
|
||||
@@ -140,7 +133,7 @@ export default function ManualOrderCostModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title="人工录单 / 录入订单成本"
|
||||
okText={canApprove ? '确认录入并计入成本' : '提交审核'}
|
||||
okText="确认录入并计入成本"
|
||||
cancelText="取消"
|
||||
confirmLoading={mutation.isPending}
|
||||
onOk={() => void submit()}
|
||||
@@ -158,7 +151,7 @@ export default function ManualOrderCostModal({
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="每一单单独录实际金额,不按小时、不按单量套模板"
|
||||
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
|
||||
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。录入即写入成本快照并计入销售毛利,没有审批与报销环节。"
|
||||
/>
|
||||
<Form.Item label="订单号">
|
||||
<Input value={orderNo} readOnly />
|
||||
@@ -272,23 +265,6 @@ export default function ManualOrderCostModal({
|
||||
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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user