- 确认履约改为单事务:锁订单项 → 复查快照 → 扣积分/备用金 → 写成本快照; 快照失败整体回滚,不再出现「钱扣了、成本没记」 - 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 库全量执行 + 重跑幂等。
12 KiB
影子总账到底是什么意思
状态:现行说明(未实施) 最后校准:2026-10-23 代码基线:
dev(合并 dev1023 后)+ 迁移103–111(已合并到dev,未推送、未部署生产) 配套方案:财务系统集成方案.md 总口径以 财务与履约口径总纲-2026-10-23.md 为准。 前置:统一履约事实、商品主数据、渠道映射、成本中心与销售事实;实施顺序以总纲第六节为准。 一句话:影子总账是一套“跟着真实业务跑、但暂时不接管余额”的复式会计账。 它先把每一笔业务翻译成借贷分录,和现有钱包/订单/提现数据逐日对账;确认长期对得平之后,再让它成为正式总账。边界:影子总账解决“钱怎么记、怎么对”,不负责“商品是什么、成本是多少、人工谁处理”。商品与成本见 成本中心与履约模型.md,人工支出见 人工支出与备用金.md。
当前进度:客服备用金、商品与成本中心、成本快照、销售事实、人工录单已实施;
finance_*表、分录、对账、转正均未实施。人工成本现在只有一条链路:人工录单 → 扣客服备用金 → 写成本快照,无登记单/审批/报销;历史人工支出找不回来、直接放弃,上线后必须全覆盖。
一、为什么叫“影子”
“影子”不是指数据是假的,而是指它的地位:
- 现有
worker_wallets仍然是运行时余额的权威来源; - 现有
worker_wallet_ledgers仍然继续记录打手资金动作; - 影子总账在后台并行记录同一批业务事件,但暂时不决定用户能提现多少、能接多少单;
- 它像影子一样跟着真实系统走,用来验证“如果我们用会计方式记账,能不能和现有系统对得上”。
真实业务
├─ 现有链路:改订单 / 改工单 / 改钱包 / 改提现单 ← 仍然权威
└─ 影子链路:业务事件 → 复式分录 → 总账余额 ← 只观察、不接管
当影子账连续对平、业务口径确认后,才进入“转正”阶段:总账成为权威,钱包变成总账的余额投影。
二、它和现在的钱包流水有什么区别
| 维度 | 现在的 worker_wallet_ledgers |
影子总账 |
|---|---|---|
| 记账方式 | 单边流水:+50 / -20 |
复式分录:每笔有借方和贷方,必须平衡 |
| 覆盖范围 | 主要是打手资金 | 订单收入、成本、打手报酬、押金、提现、寄卖货款、平台费 |
| 余额 | worker_wallets 直接维护可变余额 |
由分录汇总/投影得出,可独立重算 |
| 目的 | 让打手看到余额与流水 | 让财务能出报表、对账、关账、算毛利 |
| 可修改性 | 余额可更新,流水是业务记录 | 已过账分录不可改,只能反向冲正 |
| 维度 | 主要按 worker / work_order | 同时按 order / work_order / worker / shop / provider / settlement |
| 失败影响 | 直接影响打手余额 | 影子期不影响业务;转正后与业务同事务 |
最关键的区别:单边 vs 复式
假设打手被冻结 20 元押金:
- 现在只记一条:
frozen_deposit +20,available -20; - 复式账要记两条:
- 借:打手可提现余额 20
- 贷:打手押金负债 20
这样财务才能回答:这 20 元不是“消失了”,而是从“可提现负债”重分类成了“押金负债”。押金退回到哪、没收进哪个收入科目,也都有依据。
三、一个具体例子
假设一笔订单:
- 买家支付 100 元;
- 工单验收,打手报酬 50 元;
- 打手抢单时冻结押金 20 元;
- 后来提现 10 元。
1. 买家支付 100 元
借:1002 支付通道待结算 100
贷:2201 预收账款 100
此时钱包不变。这笔钱还不是收入,而是预收/待结算。
2. 工单验收,确认收入与履约成本
借:2201 预收账款 100
贷:4001 商品销售收入 100
借:5101 履约成本-打手报酬 50
贷:2001 应付打手报酬 50
此时钱包仍不一定变,因为报酬可能还在待解冻期。
3. 报酬转为可提现
借:2001 应付打手报酬 50
贷:2004 打手可提现余额 50
此时钱包 available_amount +50。
4. 抢单冻结押金 20 元
借:2004 打手可提现余额 20
贷:2003 打手押金 20
钱包:available -20,frozen_deposit +20。这只是负债内部重分类,不是费用。
5. 提现 10 元
借:2004 打手可提现余额 10
贷:1001 银行存款 10
钱包:available -10。
6. 最终核对
钱包 available = 50 - 20 - 10 = 20
钱包 frozen_deposit = 20
总账中对应的负债也应分别是 20 和 20;如果对不上,就说明有事件漏记、重复记或金额映射错了。
四、影子总账在本项目怎么跑
flowchart LR
A["业务事务<br/>订单/工单/提现/寄卖/售后"] --> B["finance_business_events<br/>幂等事件"]
B --> C["finance-posting-service<br/>借贷规则"]
C --> D["finance_journal_entries"]
D --> E["finance_journal_lines"]
E --> F["科目余额 / 钱包投影"]
G["现有 worker_wallets / ledgers"] --> H["每日对账"]
F --> H
I["平台账单 / 支付流水"] --> H
H --> J["差异清单<br/>finance_reconciliation_items"]
1. 业务事件先落“收件箱”
每个业务动作生成唯一事件:
order:123:paid
work_order:456:settled
work_order:456:deposit_freeze
withdrawal:789:approved
consignment:abc:payout
写入 finance_business_events,business_event_id 唯一。重复投递直接忽略,保证幂等。
2. 按规则生成借贷分录
finance-posting-service 只做一件事:把事件翻译成分录。
事件:work_order:456:settled
分录:
借 5101 履约成本 50
贷 2001 应付打手报酬 50
维度:orderId / workOrderId / workerId / shopId / provider
规则必须集中管理,不能散落在各个业务 service 里。
3. 写凭证与分录
finance_journal_entries:凭证头(事件、时间、期间、状态)。finance_journal_lines:借贷行(科目、金额、维度)。- 已过账凭证不可 UPDATE / DELETE;冲正用反向凭证。
4. 生成余额投影
有两种方式:
- 实时投影:分录写入后同步更新
finance_account_balances/worker_wallets; - 定时重算:按期间从分录汇总,生成余额快照。
影子期建议两者都做:实时投影用于展示,定时重算用于校验。
5. 每日对账
至少做四组对账:
| 对账 | 左 | 右 |
|---|---|---|
| 钱包余额 | worker_wallets.available_amount |
总账中打手可提现余额 |
| 押金余额 | worker_wallets.frozen_deposit_amount |
总账中打手押金负债 |
| 待解冻 | worker_wallets.pending_unfreeze_amount |
总账中待解冻明细 |
| 平台应收 | 平台账单/结算单 | 总账中应收账款 |
差异写入 finance_reconciliation_items,必须能定位到具体订单、工单、提现单或事件。
五、影子期和转正期的区别
这是最容易混淆的地方。
5.1 影子期(观察)
- 现有钱包仍是权威;
- 影子总账写入失败,不应阻断业务;
- 失败事件进入重试/死信队列;
- 每日对账发现漏记、重复、金额不一致;
- 目标是把“事件覆盖率”和“对账差异”压到 0。
因此影子期可以做成:
业务事务提交
→ 尝试写影子总账(best-effort)
→ 失败只记录 finance_business_events.status=failed
→ 定时任务重试
5.2 转正期(接管)
- 总账成为权威;
- 分录写入与业务状态变更必须在同一个数据库事务;
- 分录写失败 → 业务回滚;
- 钱包余额从总账投影读取;
- 旧钱包字段保留一个版本用于回滚。
业务事务开始
→ 改业务状态
→ 写总账事件 + 分录
→ 更新钱包投影
→ 提交
影子期允许“账慢一点”,转正期不允许“账少一笔”。
六、历史数据怎么处理
不建议一上来把 32 万+ 历史订单全部重放。更稳妥的做法是:
方案 A:期初余额 + 未来流水(推荐)
- 选一个切换日(例如 2026-11-01)。
- 把切换日各科目余额作为“期初分录”导入:
- 银行存款
- 平台应收
- 应付打手
- 打手押金
- 打手可提现余额
- 应付卖家货款
- 切换日之后的所有业务走影子总账。
- 历史报表继续由旧系统/旧查询提供,或后续再做历史回放。
优点:风险低、上线快、不会因为历史脏数据卡住。 缺点:历史订单级损益需要另行回放。
方案 B:有限回放(用于验证)
选最近 1–3 个月做回放:
- 从
orders、work_orders、worker_wallet_ledgers、worker_finance_requests、consignment_*重建事件; - 与现有钱包/报表逐日比对;
- 验证 posting 规则,不要求覆盖全部历史。
方案 C:全量回放(不推荐先行)
历史数据可能存在:
- 已删除/改写的业务记录;
- 旧口径押金;
- 多渠道补单;
- 手工调账没有事件来源。
全量回放很容易得到“永远对不平”的结果,反而拖慢项目。
七、转正标准
建议至少满足:
- 所有核心事件类型已接入:订单支付/退款、工单结算、押金冻结/释放/没收、提现、充值、寄卖押金/货款、售后扣款/欠款。
- 连续 30 天:
- 借贷不平凭证数 = 0;
- 钱包余额与总账重算差异 = 0;
- 押金/待解冻/提现冻结差异 = 0;
- 平台应收差异可解释;
- 事件漏记率低于约定阈值(例如 0.1%)。
- 财务/运营完成抽样核对与签字。
- 有明确回滚开关:关闭总账投影读,回到旧钱包字段。
- 关账、冲正、手工分录权限与审计已就绪。
八、常见误区
| 误区 | 实际 |
|---|---|
| 影子总账 = 数据库备份 | 不是。备份是数据副本,影子总账是另一套会计模型 |
有 worker_wallet_ledgers 就是总账 |
不是。它是单边业务流水,不能出借贷平衡报表 |
| 先把钱包表改成借贷 | 风险极高。应先并行建账,再切换读模型 |
| 影子账可以先随便改 | 不行。已过账分录必须不可变,否则无法审计 |
| 对账就是总账 | 不是。对账是校验手段,总账是记账体系 |
| 历史数据必须全部重算 | 不一定。期初余额 + 未来流水通常更稳 |
| 影子期 Ledger 写失败要回滚业务 | 影子期不应阻断业务;转正期才必须同事务 |
九、在本项目落地的最小步骤
表结构、API 与分期细节见 财务系统集成方案.md §三–§六;全局顺序以 README.md §三 为准。
- 先完成前置:商品主数据、渠道 SKU 映射、成本中心、销售事实。
- 建总账表:
finance_accounts、finance_business_events、finance_journal_entries、finance_journal_lines、finance_periods、finance_reconciliations。 - 先接 4 类高频事件:订单支付、工单结算、押金冻结/释放/没收、提现;影子期只观察,不阻断业务。
- 日对账 + 差异清单:钱包余额 vs 总账余额;先只报差异,不自动改账。
- 连续对平后转正:总账成为权威,钱包改为投影;保留回滚开关。
十、一句话总结
影子总账就是“先用会计的方式把业务再记一遍,但不让它管钱;等它和现有钱包、平台账单长期对得上,再让它转正”。 它的价值不是多一张表,而是提前暴露“钱从哪里来、到哪里去、哪一笔没记、哪一笔记错了”,避免财务系统上线后才发现历史资金链路对不平。