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
@@ -12,16 +12,9 @@ import {
|
||||
rebuildSalesDailyFacts,
|
||||
} from '../../services/finance/sales-facts-service.js'
|
||||
import {
|
||||
approveManualExpense,
|
||||
createManualExpense,
|
||||
createManualOrderCost,
|
||||
getManualExpense,
|
||||
getManualExpenseOverview,
|
||||
listManualExpenseCostItems,
|
||||
listManualExpenses,
|
||||
rejectManualExpense,
|
||||
reimburseManualExpense,
|
||||
} from '../../services/finance/manual-expense-service.js'
|
||||
listManualOrderCostItems,
|
||||
} from '../../services/finance/manual-order-cost-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
@@ -122,66 +115,12 @@ router.post(
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/overview',
|
||||
'/finance/manual-order-cost-items',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => getManualExpenseOverview(req.query as Record<string, unknown>), {
|
||||
createJsonHandler(() => listManualOrderCostItems(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出汇总失败',
|
||||
scope: '[admin/finance/manual-expenses/overview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => listManualExpenses(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/cost-items',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler(() => listManualExpenseCostItems(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出成本项失败',
|
||||
scope: '[admin/finance/manual-expenses/cost-items]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/:expenseId',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => getManualExpense(idOf(req, 'expenseId')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出详情失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses',
|
||||
requireAnyAdminPermission(['fund.expense', 'finance.config']),
|
||||
createJsonHandler((req) => createManualExpense(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '人工支出已登记',
|
||||
errorMessage: '登记人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses]',
|
||||
audit: (req, data) => {
|
||||
const record = data as { id?: number; expenseNo?: string; amountFen?: number }
|
||||
return {
|
||||
action: 'manual_expense_created',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String(record.id || ''),
|
||||
data: {
|
||||
expenseNo: record.expenseNo || '',
|
||||
amountFen: record.amountFen || 0,
|
||||
orderId: req.body?.orderId || '',
|
||||
orderItemId: req.body?.orderItemId || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
errorMessage: '读取人工录单成本项失败',
|
||||
scope: '[admin/finance/manual-order-cost-items]',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -194,76 +133,24 @@ router.post(
|
||||
scope: '[admin/finance/manual-order-costs]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
record?: { id?: number; expenseNo?: string; amountFen?: number; orderId?: number }
|
||||
effective?: boolean
|
||||
expenseNo?: string
|
||||
amountFen?: number
|
||||
orderId?: number
|
||||
costSnapshotId?: number | null
|
||||
}
|
||||
return {
|
||||
action: 'manual_order_cost_created',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String(result.record?.id || ''),
|
||||
targetType: 'fund_expense',
|
||||
targetId: result.expenseNo || '',
|
||||
data: {
|
||||
expenseNo: result.record?.expenseNo || '',
|
||||
amountFen: result.record?.amountFen || 0,
|
||||
orderId: result.record?.orderId || req.body?.orderId || '',
|
||||
effective: result.effective === true,
|
||||
expenseNo: result.expenseNo || '',
|
||||
amountFen: result.amountFen || 0,
|
||||
orderId: result.orderId || req.body?.orderId || '',
|
||||
costSnapshotId: result.costSnapshotId || null,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/approve',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler(
|
||||
(req) => approveManualExpense(idOf(req, 'expenseId'), bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '人工支出已通过',
|
||||
errorMessage: '审核人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/approve]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_approved',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: { note: req.body?.note || '' },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/reject',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler(
|
||||
(req) => rejectManualExpense(idOf(req, 'expenseId'), bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '人工支出已驳回',
|
||||
errorMessage: '驳回人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/reject]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_rejected',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: { reason: req.body?.rejectReason || req.body?.reject_reason || '' },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/reimburse',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler((req) => reimburseManualExpense(idOf(req, 'expenseId'), sessionOf(req)), {
|
||||
successMessage: '人工支出已标记报销',
|
||||
errorMessage: '标记报销失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/reimburse]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_reimbursed',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user