feat(finance): 人工成本单事务/幂等 + 配置自动匹配 + 缺成本监控

- 确认履约改为单事务:锁订单项 → 复查快照 → 扣积分/备用金 → 写成本快照;
  快照失败整体回滚,不再出现「钱扣了、成本没记」
- manual_case 的 subject_id 统一为 order_item_id,新增订单项级唯一索引(迁移 111),
  消除 manual_expenses.id / platform_credit_entries.id 跨表撞车
- 人工 SKU 匹配增加 sku_code / sku_name 兜底;保存配置立即回填最近 30 天,
  定时任务按窗口 rematch,确认时再按 SKU 兜底匹配
- 新增 manual_cost_missing 异常队列:从首次人工配置起算,确认履约立即收口
- 补单元测试与真实 PostgreSQL 集成测试(并发、重复、强制快照失败回滚、双资金链路)
- 校准财务文档并新增上线初始化/验收手册(历史人工不补录,只保证配置后的新单)

验证:backend typecheck/lint/format/check:sql/test 843(839 pass / 4 skip);
frontend typecheck/lint/test 172;迁移 111 在 scratch 库全量执行 + 重跑幂等。
This commit is contained in:
yml2213
2026-10-26 13:04:51 +08:00
parent 73908119dc
commit a3e2bdb7c4
22 changed files with 1575 additions and 390 deletions
@@ -0,0 +1,85 @@
-- 111_manual_cost_integrity.sql
-- 人工成本完整性:一个订单项只允许一条有效的人工成本快照。
--
-- 背景:
-- 1. 旧实现把 cost_snapshots.subject_id 写成 manual_expenses.id 或
-- platform_credit_entries.id(两张表各自 BIGSERIAL),与唯一索引
-- (subject_type, subject_id, cost_item_id) 叠加后,两条链路会互相撞 id:
-- 第二次合法确认被当成「已有人工成本快照」拒绝,而钱已经扣了。
-- 2. 人工确认原先是「先扣款、后写快照」两个事务,快照失败会留下已扣款未记成本。
-- 现在改为同一事务,并由本迁移提供数据库级兜底:
-- 一个 order_item 只允许一条有效 (superseded_at IS NULL) 的 manual_case 快照。
--
-- 语义调整:
-- * subject_type = 'manual_case' 的 subject_id 统一为 order_item_id;
-- 资金来源(manual_expenses.id / platform_credit_entries.id)只写 metadata_json,
-- 不再作为 subject_id。
-- * 正数 subject_id 的历史行(旧实现写入的支出单/积分流水 id)修正为 order_item_id;
-- 负数 subject_id 的历史孤儿快照按迁移 104 的约定保留,不参与新链路。
-- * 重复正数行保留最新一条,其余标记 superseded,避免唯一索引创建失败。
--
-- 全部语句幂等,可安全重跑。
-- 1. 历史人工快照的 subject_id 归一为 order_item_id。
UPDATE cost_snapshots
SET subject_id = order_item_id
WHERE subject_type = 'manual_case'
AND order_item_id IS NOT NULL
AND subject_id > 0
AND subject_id <> order_item_id;
-- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。
UPDATE cost_snapshots c
SET superseded_at = NOW()
WHERE c.subject_type = 'manual_case'
AND c.superseded_at IS NULL
AND c.subject_id > 0
AND EXISTS (
SELECT 1
FROM cost_snapshots newer
WHERE newer.subject_type = 'manual_case'
AND newer.order_item_id = c.order_item_id
AND newer.superseded_at IS NULL
AND newer.id > c.id
);
-- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item
ON cost_snapshots (order_item_id)
WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0;
-- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。
-- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject'
) THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_manual_subject
CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id)
NOT VALID;
END IF;
END $$;
-- 5. 人工成本监控 / 对账按订单项反查备用金支出。
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item
ON manual_expenses (order_item_id)
WHERE order_item_id IS NOT NULL;
-- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。
-- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。
DROP INDEX IF EXISTS idx_order_items_manual_internal_sku;
CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key
ON order_items (
COALESCE(
NULLIF(spec_json->>'skuNick', ''),
NULLIF(spec_json->>'outSkuSn', ''),
NULLIF(sku_code, ''),
NULLIF(sku_name, '')
)
)
WHERE fulfillment_mode = 'manual';
COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS
'人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)';