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
@@ -0,0 +1,51 @@
|
||||
-- 106_manual_expense_records.sql —— 备用金账户编号可读化。
|
||||
--
|
||||
-- 2026-10-23 口径调整:公司人工支出登记(manual_expense_records)与审批报销
|
||||
-- 状态机(manual_expense_events)整链下线。人工成本改为「订单页人工录单 →
|
||||
-- 直接从客服备用金扣款 → 直接写成本快照」,不再有登记单、审批与报销。
|
||||
--
|
||||
-- 因此本迁移不再创建这两张表,只保留备用金账户编号可读化:
|
||||
-- manual_expenses(迁移 100)= 客服备用金支出明细,唯一的资金台账
|
||||
-- cost_snapshots(迁移 104)= 人工成本的唯一落点,subject_type='manual_case'
|
||||
--
|
||||
-- 口径(2026-10-23 确认):
|
||||
-- * 人工成本逐单、按实际金额录入,不按工时、不按固定单量/固定单价;
|
||||
-- * 人工支出从客服备用金账户扣款,客服角色只能用本人账户;
|
||||
-- * 历史人工支出找不回来、也不补录,只保证上线后的新单不丢;
|
||||
-- * 没有审批与报销环节,录入即计入成本快照。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 备用金账户编号改为可读的 FA-<用户名>
|
||||
--
|
||||
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
|
||||
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
fa.id,
|
||||
'FA-' || COALESCE(
|
||||
NULLIF(
|
||||
trim(
|
||||
both '_' FROM upper(
|
||||
regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g')
|
||||
)
|
||||
),
|
||||
''
|
||||
),
|
||||
'ACCOUNT-' || fa.id
|
||||
) AS base_no
|
||||
FROM fund_accounts fa
|
||||
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
base_no,
|
||||
row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn
|
||||
FROM base
|
||||
)
|
||||
UPDATE fund_accounts fa
|
||||
SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END
|
||||
FROM numbered n
|
||||
WHERE fa.id = n.id;
|
||||
@@ -1,201 +0,0 @@
|
||||
-- 106_manual_expense_records.sql —— 公司人工支出登记 + 备用金账户编号可读化。
|
||||
--
|
||||
-- 与迁移 100 的 manual_expenses 严格区分:
|
||||
-- manual_expenses = 客服备用金支出明细(减少客服余额)
|
||||
-- manual_expense_records = 公司口径人工支出登记(谁为哪个订单付了多少钱)
|
||||
-- 两者通过 fund_expense_id 关联,不合并、不改名、不共用索引名。
|
||||
--
|
||||
-- 口径(2026-10-23 确认):
|
||||
-- * 人工成本逐单、按实际金额录入,不按工时、不按固定单量/固定单价;
|
||||
-- * 人工支出从客服备用金账户扣款,客服角色只能用本人账户;
|
||||
-- * 历史人工支出不补录,只取少量样本做校准;
|
||||
-- * 客服必须在上线后 24 小时内为人工单录入成本。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expense_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_no TEXT NOT NULL,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
|
||||
fund_expense_id BIGINT REFERENCES manual_expenses(id) ON DELETE SET NULL,
|
||||
cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT,
|
||||
expense_category TEXT NOT NULL DEFAULT 'other',
|
||||
amount_fen BIGINT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
paid_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
paid_by_name TEXT NOT NULL DEFAULT '',
|
||||
payee_type TEXT NOT NULL DEFAULT 'other',
|
||||
payee_name TEXT NOT NULL DEFAULT '',
|
||||
payee_account TEXT NOT NULL DEFAULT '',
|
||||
payment_method TEXT NOT NULL DEFAULT 'other',
|
||||
paid_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'submitted',
|
||||
approved_by TEXT NOT NULL DEFAULT '',
|
||||
approved_at TIMESTAMPTZ,
|
||||
rejected_by TEXT NOT NULL DEFAULT '',
|
||||
rejected_at TIMESTAMPTZ,
|
||||
reject_reason TEXT NOT NULL DEFAULT '',
|
||||
reimbursed_by TEXT NOT NULL DEFAULT '',
|
||||
reimbursed_at TIMESTAMPTZ,
|
||||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
cost_snapshot_id BIGINT REFERENCES cost_snapshots(id) ON DELETE RESTRICT,
|
||||
reversal_of_id BIGINT REFERENCES manual_expense_records(id) ON DELETE RESTRICT,
|
||||
expense_batch_no TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_expense_no
|
||||
ON manual_expense_records (expense_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_order
|
||||
ON manual_expense_records (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_item
|
||||
ON manual_expense_records (order_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_work_order
|
||||
ON manual_expense_records (work_order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_paid_at
|
||||
ON manual_expense_records (paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_status
|
||||
ON manual_expense_records (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_cost_item
|
||||
ON manual_expense_records (cost_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_payer
|
||||
ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_fund_expense
|
||||
ON manual_expense_records (fund_expense_id)
|
||||
WHERE fund_expense_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_batch
|
||||
ON manual_expense_records (expense_batch_no)
|
||||
WHERE expense_batch_no <> '';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_amount'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_amount CHECK (amount_fen > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_status'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_status
|
||||
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'reversed'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_payee_type'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_payee_type
|
||||
CHECK (payee_type IN ('worker', 'upstream', 'customer', 'platform', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_method'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_method
|
||||
CHECK (payment_method IN ('wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_category'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_category
|
||||
CHECK (
|
||||
expense_category IN (
|
||||
'goods_purchase', 'compensation', 'refund_advance', 'service_fee',
|
||||
'freight', 'recharge', 'other'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_approved'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_approved
|
||||
CHECK (
|
||||
status NOT IN ('approved', 'reimbursed')
|
||||
OR (approved_at IS NOT NULL AND approved_by <> '')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_rejected'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_rejected
|
||||
CHECK (
|
||||
status <> 'rejected'
|
||||
OR (rejected_at IS NOT NULL AND rejected_by <> '' AND reject_reason <> '')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE manual_expense_records IS
|
||||
'公司人工支出登记:逐单实际金额;付款项目/收款方/凭证/审批;与客服备用金 manual_expenses 通过 fund_expense_id 关联';
|
||||
COMMENT ON COLUMN manual_expense_records.fund_expense_id IS
|
||||
'来源客服备用金支出(manual_expenses.id);人工支出必须从备用金扣款,因此正常必有值';
|
||||
COMMENT ON COLUMN manual_expense_records.cost_item_id IS
|
||||
'付款项目 -> cost_items;为空表示未归类,报表必须暴露未归类占比';
|
||||
COMMENT ON COLUMN manual_expense_records.evidence_json IS
|
||||
'凭证:支付截图对象 key、流水号、聊天记录 key 等';
|
||||
COMMENT ON COLUMN manual_expense_records.reversal_of_id IS
|
||||
'预留:退款/仲裁接入后按反向登记冲正,禁止 UPDATE 已通过记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expense_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_id BIGINT NOT NULL REFERENCES manual_expense_records(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
from_status TEXT NOT NULL DEFAULT '',
|
||||
to_status TEXT NOT NULL DEFAULT '',
|
||||
actor_type TEXT NOT NULL DEFAULT 'admin',
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
actor_name TEXT NOT NULL DEFAULT '',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_expense
|
||||
ON manual_expense_events (expense_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_type
|
||||
ON manual_expense_events (event_type, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE manual_expense_events IS
|
||||
'人工支出状态变更审计:只追加,不更新、不删除';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 备用金账户编号改为可读的 FA-<用户名>
|
||||
--
|
||||
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
|
||||
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
fa.id,
|
||||
'FA-' || COALESCE(
|
||||
NULLIF(
|
||||
trim(
|
||||
both '_' FROM upper(
|
||||
regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g')
|
||||
)
|
||||
),
|
||||
''
|
||||
),
|
||||
'ACCOUNT-' || fa.id
|
||||
) AS base_no
|
||||
FROM fund_accounts fa
|
||||
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
base_no,
|
||||
row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn
|
||||
FROM base
|
||||
)
|
||||
UPDATE fund_accounts fa
|
||||
SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END
|
||||
FROM numbered n
|
||||
WHERE fa.id = n.id;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 108_drop_manual_expense_records.sql —— 下线公司人工支出登记与审批报销状态机。
|
||||
--
|
||||
-- 背景(2026-10-23 确认):
|
||||
-- 人工成本不再走「登记单 → 审批 → 报销」三段式。订单页人工录单直接扣客服备用金、
|
||||
-- 直接写成本快照,备用金支出明细(manual_expenses,迁移 100)是唯一资金台账。
|
||||
-- 因此 manual_expense_records(登记单)与 manual_expense_events(状态机审计)
|
||||
-- 整链下线。
|
||||
--
|
||||
-- 处理顺序:
|
||||
-- 1. 先把挂在人工登记单上的成本快照改挂到备用金支出单上(若存在);
|
||||
-- 2. 再删表与索引;
|
||||
-- 3. 成本项 sys_manual_service 的备注改为「人工录单直写」口径。
|
||||
--
|
||||
-- 成本快照主体约定(改后唯一):
|
||||
-- cost_snapshots.subject_type = 'manual_case'
|
||||
-- cost_snapshots.subject_id = manual_expenses.id(客服备用金支出单 id)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 修正历史成本快照的主体指向
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 旧登记单的 subject_id 指向 manual_expense_records.id。旧数据里人工登记单没有
|
||||
-- 关联备用金支出单(fund_expense_id 为空)的,保留原主体 id 不动,避免误删成本;
|
||||
-- 有 fund_expense_id 的改挂到备用金支出单。
|
||||
|
||||
UPDATE cost_snapshots cs
|
||||
SET subject_id = mer.fund_expense_id
|
||||
FROM manual_expense_records mer
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND mer.fund_expense_id IS NOT NULL
|
||||
AND cs.subject_id = mer.id
|
||||
AND cs.subject_id <> mer.fund_expense_id;
|
||||
|
||||
-- 若同一备用金支出单已存在有效快照,保留先写的,把重复的作废(superseded_at)。
|
||||
UPDATE cost_snapshots dup
|
||||
SET superseded_at = NOW()
|
||||
WHERE dup.subject_type = 'manual_case'
|
||||
AND dup.superseded_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_snapshots keep
|
||||
WHERE keep.subject_type = 'manual_case'
|
||||
AND keep.subject_id = dup.subject_id
|
||||
AND keep.cost_item_id = dup.cost_item_id
|
||||
AND keep.superseded_at IS NULL
|
||||
AND keep.id < dup.id
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 删除人工支出登记与状态机
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
DROP TABLE IF EXISTS manual_expense_events;
|
||||
|
||||
DROP TABLE IF EXISTS manual_expense_records;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 人工成本项备注按新口径更新
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
UPDATE cost_items
|
||||
SET note = '系统内置:订单页人工录单直接从客服备用金扣款并写入 actual 快照;逐单实际金额,不按工时或固定单价',
|
||||
updated_at = NOW()
|
||||
WHERE code = 'sys_manual_service';
|
||||
|
||||
COMMENT ON TABLE cost_snapshots IS
|
||||
'成本快照:订单项/工单/人工单实际使用的成本版本与金额;履约方式看 order_items.fulfillment_mode;人工单 subject_id 指向 manual_expenses.id';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 109_isolate_legacy_manual_snapshots.sql —— 隔离历史人工成本快照的主体 id 空间。
|
||||
--
|
||||
-- 背景(2026-10-23):
|
||||
-- 人工成本的快照主体从「人工支出登记单 id」改成「客服备用金支出单 id」
|
||||
-- (manual_expenses.id),两套 id 空间各自从 1 开始,必然撞车。
|
||||
-- 迁移 108 把有 fund_expense_id 的旧快照改挂到备用金支出单,但**没有**关联
|
||||
-- 备用金支出单的孤儿快照(fund_expense_id 为空)保留了原 subject_id,
|
||||
-- 于是会与新建的 manual_expenses.id 撞唯一索引
|
||||
-- uq_cost_snapshots_active_subject_item。
|
||||
--
|
||||
-- 实测后果:人工录单第二笔时 INSERT ... ON CONFLICT DO NOTHING 静默跳过,
|
||||
-- 钱从备用金扣了、成本快照却没写成 —— 账实不符。
|
||||
--
|
||||
-- 处理:把「来源是旧人工支出登记单」的历史快照主体 id 改成负数,
|
||||
-- 与 manual_expenses.id(正数)彻底隔离。这些是历史人工成本,按口径不再补录,
|
||||
-- 保留金额供毛利参考,只是不再与新链路争主体 id。
|
||||
--
|
||||
-- 判定依据:metadata_json.source = 'manual_expense_record'(旧登记链写入时打的标),
|
||||
-- 且该主体 id 没有对应的有效 manual_expenses 关联标记。
|
||||
--
|
||||
-- 幂等:可重复执行。
|
||||
|
||||
UPDATE cost_snapshots cs
|
||||
SET
|
||||
subject_id = -cs.subject_id,
|
||||
metadata_json = cs.metadata_json
|
||||
|| jsonb_build_object(
|
||||
'source', 'legacy_manual_expense_record',
|
||||
'legacySubjectId', cs.subject_id,
|
||||
'isolatedAt', NOW()
|
||||
)
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.subject_id > 0
|
||||
AND cs.metadata_json->>'source' = 'manual_expense_record'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots other
|
||||
WHERE other.subject_type = 'manual_case'
|
||||
AND other.subject_id = -cs.subject_id
|
||||
AND other.cost_item_id = cs.cost_item_id
|
||||
AND other.superseded_at IS NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN cost_snapshots.subject_id IS
|
||||
'主体 id:order_item→order_items.id;work_order→work_orders.id;manual_case→manual_expenses.id;负数表示历史孤儿快照(已与新链路隔离)';
|
||||
Reference in New Issue
Block a user