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:
yml2213
2026-10-24 11:31:29 +08:00
co-authored by Claude Opus 4.5
parent 43f402cf72
commit fb679d76f6
25 changed files with 953 additions and 2739 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,547 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { getOwnAgentAccountId, writeFundExpense } from '../admin/admin-fund-service.js'
/**
* 订单页「人工录单」服务。
*
* 口径(2026-10-23 确认):
* * 人工成本逐单、按实际金额,不按工时、不按固定单量/固定单价;
* * 成本从客服备用金账户直接扣款,客服角色只能用本人账户,管理员/财务可代录;
* * **没有登记单、没有审批、没有报销**:录入即写 actual 成本快照;
* * 资金台账唯一真源是 manual_expenses(客服备用金支出明细),本服务不另存支出表。
*
* 成本快照主体:subject_type='manual_case',subject_id=manual_expenses.id。
*/
const PAYEE_TYPES = new Set(['worker', 'upstream', 'customer', 'platform', 'other'])
const PAYMENT_METHODS = new Set(['wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'])
/** 人工录单的费用类型;与备用金分类的映射见 mapFundExpenseCategory()。 */
const EXPENSE_CATEGORIES = new Set([
'goods_purchase',
'compensation',
'refund_advance',
'service_fee',
'freight',
'recharge',
'other',
])
type ManualOrderCostResult = {
expenseNo: string
fundExpenseId: number
fundAccountId: number
fundAccountNo: string
amountFen: number
orderId: number
orderItemId: number
costSnapshotId: number | null
fulfillmentMode: string
warning: string
}
/**
* 录入一笔人工成本:扣客服备用金 + 写 actual 成本快照。
*
* 全流程在同一事务内:余额不足直接拒绝,不允许负余额。
*/
export async function createManualOrderCost(
payload: JsonObject,
session: AdminSession,
): Promise<ManualOrderCostResult> {
const orderId = normalizeId(payload.orderId ?? payload.order_id, '订单号无效')
const amountFen = normalizeAmountFen(payload.amountFen ?? payload.amount_fen)
const note = optionalText(payload.note, 500) || '订单页人工录单'
const expenseCategory = normalizeStatus(
payload.expenseCategory ?? payload.expense_category,
EXPENSE_CATEGORIES,
'service_fee',
'费用类型无效',
)
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) || '内部人工'
const payeeType = normalizeStatus(
payload.payeeType ?? payload.payee_type,
PAYEE_TYPES,
'other',
'收款方类型无效',
)
const paymentMethod = normalizeStatus(
payload.paymentMethod ?? payload.payment_method,
PAYMENT_METHODS,
'other',
'付款方式无效',
)
const evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json)
if (!hasEvidence(evidenceJson)) {
evidenceJson.note = note
}
const orderItemId = await resolveOrderItemId(
orderId,
normalizeOptionalId(payload.orderItemId ?? payload.order_item_id),
)
const fundAccountId = await resolveFundAccountId(
normalizeOptionalId(payload.fundAccountId ?? payload.fund_account_id),
session,
)
const costItemId = await resolveManualCostItemId()
const paidAt = nowIso()
return withTransaction(async (client) => {
const fundWritten = await writeFundExpense(client, {
agentAccountId: fundAccountId,
amountFen,
category: mapFundExpenseCategory(expenseCategory),
payeeType: mapFundPayeeType(payeeType),
payeeName,
payeeAccount: optionalText(payload.payeeAccount ?? payload.payee_account, 120),
orderId,
orderItemId,
paidAt,
evidence: toFundEvidence(evidenceJson),
note: buildFundExpenseNote(note, optionalText(evidenceJson.reference, 200)),
actorUserId: session.userId,
})
const fundExpenseId = Number(fundWritten.expense.id)
const fundAccountNo = await loadFundAccountNo(client, fundAccountId)
const context = await loadOrderItemContext(client, orderItemId)
const versionId = await resolveCostVersionId(client, costItemId, context, paidAt)
let costSnapshotId: number | null = null
let warning = ''
if (!versionId) {
// 匹配不到有效成本版本不允许静默记 0,必须进异常队列。
await client.query(
`
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
reason, status, detail_json, created_at
) VALUES (
'order_item', $1, $1, $2, 'manual_expense_cost_missing', 'open', $3::jsonb, NOW()
)
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
`,
[
orderItemId,
context.workOrderId,
JSON.stringify({
fundExpenseId,
fundExpenseNo: fundWritten.expense.expense_no,
amountFen,
message: '人工录单已扣备用金,但该成本项没有匹配到有效成本版本',
}),
],
)
warning = '人工录单已扣备用金,但未匹配到有效成本版本,已进入异常队列'
} else {
const snapshotResult = await client.query<{ id: number }>(
`
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
) VALUES (
'manual_case', $1, $2, $3, $4,
$5, $6, $7,
1, $8, $8, 'actual', 'manual_input',
$9::jsonb, $10, $11, $11
)
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
RETURNING id
`,
[
fundExpenseId,
orderId,
orderItemId,
context.workOrderId,
costItemId,
versionId,
context.canonicalProductId,
amountFen,
JSON.stringify({
source: 'manual_order_cost',
fundExpenseId,
fundExpenseNo: fundWritten.expense.expense_no,
fundAccountNo,
paidByName: fundWritten.ownerName,
payeeName,
payeeType,
paymentMethod,
expenseCategory,
evidence: evidenceJson,
fulfillmentMode: context.fulfillmentMode,
}),
paidAt,
nowIso(),
],
)
costSnapshotId = snapshotResult.rows[0]?.id ?? null
if (!costSnapshotId) {
// 唯一索引冲突意味着同一主体+成本项已有有效快照。钱已经扣了,不能静默吞掉,
// 否则会出现「钱扣了、成本没记」的账实不符。
throw createHttpError(
'该订单项已有同成本项的人工成本快照,本次未写入成本;备用金扣款已回滚,请改用新的支出单或先处理已有快照',
{ statusCode: 409, errorCode: 'manual_order_cost_snapshot_conflict' },
)
}
}
return {
expenseNo: fundWritten.expense.expense_no,
fundExpenseId,
fundAccountId,
fundAccountNo,
amountFen,
orderId,
orderItemId,
costSnapshotId,
fulfillmentMode: context.fulfillmentMode,
warning,
}
})
}
/** 人工录单可用的成本项:人工客服履约成本。 */
export async function listManualOrderCostItems() {
const result = await query<{
id: number
code: string
name: string
cost_model: string
pricing_mode: string
}>(
`
SELECT id, code, name, cost_model, pricing_mode
FROM cost_items
WHERE status = 'active'
AND cost_model = 'manual_actual'
ORDER BY id
`,
)
return {
items: result.rows.map((row) => ({
id: Number(row.id),
code: row.code,
name: row.name,
costModel: row.cost_model,
pricingMode: row.pricing_mode,
})),
}
}
async function resolveOrderItemId(orderId: number, requestedItemId: number | null) {
const itemsResult = await query<{ id: number }>(
'SELECT id FROM order_items WHERE order_id = $1 ORDER BY id ASC',
[orderId],
)
if (itemsResult.rows.length === 0) {
throw createHttpError('订单没有订单项,无法录入人工成本', {
statusCode: 400,
errorCode: 'manual_order_cost_items_missing',
})
}
if (requestedItemId) {
if (!itemsResult.rows.some((row) => Number(row.id) === requestedItemId)) {
throw createHttpError('订单项不属于该订单', {
statusCode: 400,
errorCode: 'manual_order_cost_item_mismatch',
})
}
return requestedItemId
}
if (itemsResult.rows.length === 1) {
return Number(itemsResult.rows[0]?.id || 0)
}
throw createHttpError('订单有多个订单项,请选择要计入的订单项', {
statusCode: 400,
errorCode: 'manual_order_cost_item_required',
})
}
/**
* 备用金账户归属校验:客服只能用本人账户;管理员/财务可代录到指定客服账户。
*/
async function resolveFundAccountId(requestedFundAccountId: number | null, session: AdminSession) {
if (session.role === 'support') {
const ownAccountId = await getOwnAgentAccountId(session.userId)
if (!ownAccountId) {
throw createHttpError('当前账号还没有备用金账户', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
if (requestedFundAccountId && requestedFundAccountId !== ownAccountId) {
throw createHttpError('只能使用自己的备用金账户', {
statusCode: 403,
errorCode: 'manual_order_cost_fund_account_forbidden',
})
}
return ownAccountId
}
if (!requestedFundAccountId) {
throw createHttpError('请选择备用金账户,人工录单必须从备用金扣款', {
statusCode: 400,
errorCode: 'manual_order_cost_fund_account_required',
})
}
return requestedFundAccountId
}
async function resolveManualCostItemId() {
const result = await query<{ id: number }>(
`SELECT id FROM cost_items
WHERE code = 'sys_manual_service' AND status = 'active'
LIMIT 1`,
)
const costItemId = Number(result.rows[0]?.id || 0)
if (!costItemId) {
throw createHttpError('人工客服履约成本项未启用', {
statusCode: 409,
errorCode: 'manual_order_cost_item_unavailable',
})
}
return costItemId
}
type OrderItemContext = {
orderId: number
canonicalProductId: number | null
provider: string
shopId: string
workOrderId: number | null
workProductRuleId: number | null
fulfillmentMode: string
}
async function loadFundAccountNo(client: PoolClient, fundAccountId: number) {
const result = await client.query<{ account_no: string }>(
'SELECT account_no FROM fund_accounts WHERE id = $1 LIMIT 1',
[fundAccountId],
)
return result.rows[0]?.account_no || ''
}
async function loadOrderItemContext(
client: PoolClient,
orderItemId: number,
): Promise<OrderItemContext> {
const result = await client.query<OrderItemContext>(
`
SELECT
oi.order_id,
oi.canonical_product_id,
o.provider,
o.shop_id,
wo.id AS work_order_id,
wo.product_rule_id AS work_product_rule_id,
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT id, product_rule_id
FROM work_orders
WHERE order_item_id = oi.id
ORDER BY id DESC
LIMIT 1
) wo ON TRUE
WHERE oi.id = $1
LIMIT 1
`,
[orderItemId],
)
const row = result.rows[0]
if (!row) {
throw createHttpError('订单项不存在', {
statusCode: 404,
errorCode: 'manual_order_cost_order_item_not_found',
})
}
return row
}
/**
* 解析成本版本:优先级 rule > product > shop > provider > global,按业务发生时间匹配。
* 人工成本项的 draft 占位版本也接受,避免未启用正式版本时人工单无法入账。
*/
async function resolveCostVersionId(
client: PoolClient,
costItemId: number,
context: OrderItemContext,
basisAt: string,
) {
const result = await client.query<{ id: number }>(
`
SELECT v.id
FROM cost_versions v
WHERE v.cost_item_id = $1
AND (
v.status = 'active'
OR (
v.status = 'draft'
AND EXISTS (
SELECT 1 FROM cost_items ci
WHERE ci.id = v.cost_item_id AND ci.code = 'sys_manual_service'
)
)
)
AND v.effective_from <= $2
AND (v.effective_to IS NULL OR v.effective_to > $2)
AND (
(v.scope_type = 'rule' AND $6::bigint IS NOT NULL AND v.work_product_rule_id = $6)
OR (v.scope_type = 'product' AND $5::bigint IS NOT NULL AND v.canonical_product_id = $5)
OR (v.scope_type = 'shop' AND v.provider = $3 AND v.shop_id = $4)
OR (v.scope_type = 'provider' AND v.provider = $3)
OR (v.scope_type = 'global')
)
ORDER BY
CASE v.scope_type
WHEN 'rule' THEN 1
WHEN 'product' THEN 2
WHEN 'shop' THEN 3
WHEN 'provider' THEN 4
ELSE 5
END,
v.effective_from DESC,
v.id DESC
LIMIT 1
`,
[
costItemId,
basisAt,
context.provider,
context.shopId,
context.canonicalProductId,
context.workProductRuleId,
],
)
return result.rows[0]?.id ?? null
}
/** 人工录单的费用类型映射到备用金支出分类。 */
function mapFundExpenseCategory(category: string): string {
switch (category) {
case 'goods_purchase':
return 'purchase'
case 'compensation':
return 'compensation'
case 'freight':
return 'freight'
case 'recharge':
return 'platform_recharge'
default:
return 'other'
}
}
function mapFundPayeeType(payeeType: string): string {
switch (payeeType) {
case 'worker':
case 'upstream':
return 'supplier'
case 'customer':
return 'customer'
case 'platform':
return 'platform'
default:
return 'other'
}
}
function toFundEvidence(evidence: JsonObject): JsonObject[] {
const items: JsonObject[] = []
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
for (const value of urls) {
const url = String(value || '').trim()
if (url) {
items.push({ url, filename: '人工录单凭证', contentType: 'image/*' })
}
}
const reference = String(evidence.reference || '').trim()
if (/^https?:\/\//i.test(reference) && !items.some((item) => item.url === reference)) {
items.push({ url: reference, filename: '人工录单凭证', contentType: 'image/*' })
}
return items.slice(0, 6)
}
function buildFundExpenseNote(note: string, reference: string): string {
const parts = ['人工录单']
if (reference) parts.push(`凭证:${reference}`)
if (note) parts.push(note)
return parts.join(' · ').slice(0, 500)
}
function normalizeEvidence(value: unknown): JsonObject {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as JsonObject
}
const text = String(value || '').trim()
return text ? { reference: text } : {}
}
function hasEvidence(evidence: JsonObject) {
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
return (
urls.length > 0 ||
Boolean(String(evidence.reference || '').trim()) ||
Boolean(String(evidence.note || '').trim())
)
}
function normalizeAmountFen(value: unknown) {
const amount = Math.trunc(Number(value) || 0)
if (amount <= 0) {
throw createHttpError('支出金额必须大于 0', {
statusCode: 400,
errorCode: 'manual_order_cost_amount_invalid',
})
}
if (amount > 100_000_000_00) {
throw createHttpError('支出金额超出允许范围', {
statusCode: 400,
errorCode: 'manual_order_cost_amount_too_large',
})
}
return amount
}
function normalizeOptionalId(value: unknown) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
function normalizeId(value: unknown, message: string) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw createHttpError(message, {
statusCode: 400,
errorCode: 'manual_order_cost_id_invalid',
})
}
return parsed
}
function normalizeStatus(value: unknown, allowed: Set<string>, fallback: string, message: string) {
const text = String(value || '').trim() || fallback
if (!allowed.has(text)) {
throw createHttpError(message, {
statusCode: 400,
errorCode: 'manual_order_cost_value_invalid',
})
}
return text
}
function optionalText(value: unknown, maxLength: number) {
return String(value || '')
.trim()
.slice(0, maxLength)
}