Files
order_site/docs/财务系统深度分析-2026-10-27.md
yml2213 9d5af359fa feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛
- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度
- 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment
- 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单
- 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径
- 文档:同步迁移基线、合并评估、代码复核与上线说明
2026-10-27 16:08:16 +08:00

918 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# order_site 财务系统深度分析(2026-10-27)
> 分析对象:订单履约、成本中心、客服备用金/积分、销售事实、影子总账、期间关账与平台对账。
> 代码基线:`dev`,HEAD `0de0fd7b`(迁移收敛到 `101–111`)。
> 文档基线:`docs/README.md` 标注的现行口径(2026-10-26)。
> 验证环境:本机 Docker PostgreSQL(开发库,**不是生产库**);生产尚未部署,按仓库文档口径仍以 `main` 为生产基线。
>
> 一句话结论:**业务闭环已经能做;作为“财务系统”还不能直接关账、对外出报表或承担审计。**
> 核心问题不是“少一张表”,而是**事实层、总账层、资金层三套数据没有形成强一致与可校验的勾稽关系**。
>
> **2026-10-27 更新:P0 代码改进已落地,见 §十一;本报告前文的“问题/建议”保留为审计基线。**
---
## 一、结论先行
| 维度 | 判断 | 依据 |
| --- | --- | --- |
| 订单 → 履约 → 成本 | 基本闭环 | `fulfillment_mode` + `cost_snapshots` + `cost_exceptions` 已打通 |
| 人工履约资金闭环 | 做得较好 | 单事务、订单项行锁、唯一索引、失败回滚均有实现与集成测试 |
| 客服备用金 | 可用,但内控不足 | 余额/流水/支出/对账齐全;对账差额存在过期风险;缺少幂等键 |
| 退款 | 手工路径可用,平台路径有硬缺口 | 平台退款先调用平台、后落本地;全额退款撤销不恢复收入 |
| 销售事实 | 可算毛利,但可信度依赖成本覆盖 | 成本缺失时毛利被高估;关账期间仍可重算 |
| 影子总账 | **只是“销售/成本影子账”** | 11 个科目中仅 4 个有发生额;钱包、押金、提现、寄卖、平台费均未入账 |
| 期间关账 | **不建议生产使用** | 无门槛、无锁、可重复关账;已关账期间事实仍可变 |
| 权限/审计 | 粒度过粗 | `finance.config` 一权多用;无 maker-checker;审计失败被吞 |
| 测试 | 覆盖面尚可 | 后端 866 项(862 pass / 4 skip),前端 181 pass;财务集成测试默认跳过 |
### 上生产前必须先解决的 7 件事
1. **关账期间销售事实仍可被重算**,但总账会跳过已关账期间 → 报表与总账必然分叉。
2. **平台退款先调用平台、后写本地**,且本地没有 `external_ref` 唯一约束 → 平台已退款、本地没记账,或重复记账。
3. **全额退款撤销后 `orders.pay_status` 不恢复** → 撤销了退款,收入仍然是 0。
4. **关账不校验门槛、不加锁、可重复关账** → 带着缺失成本/未对账数据关账;并发写入可穿透。
5. **备用金对账使用创建时的旧差额** → 对账确认后余额可能仍然不等于实际盘点。
6. **总账“已过账不可改、冲正用反向分录”没有实现** → 按日 `DELETE + 重建`,审计链断裂。
7. **权限矩阵与文档不一致**:`finance` 被人工履约路由排除,`operator` 反而可以跨客服代录;`finance.config` 同时管成本、凭证、退款、关账、积分。
---
## 二、系统全景
### 2.1 技术栈与部署
| 层 | 技术 |
| --- | --- |
| 前端 | React 19、Vite 8、Ant Design 6、React Router 7、TanStack Query、TypeScript |
| 后端 | Node.js、Express 5、TypeScript、PostgreSQL、node-pg-migrate |
| 网关 | Caddy |
| 运行 | Docker Compose(开发 / 生产分离) |
财务相关代码主要集中在:
- `apps/backend/src/services/finance/`:总账、销售事实、退款、人工履约
- `apps/backend/src/services/cost-center/`:商品目录、成本版本、成本快照、成本异常、积分钱包
- `apps/backend/src/services/admin/admin-fund-service.ts`:客服备用金
- `apps/backend/src/services/admin/admin-finance-service.ts`:旧版“收支明细”报表
- `apps/backend/src/routes/admin/finance*.ts`、`fund.ts`、`cost-center*.ts`:后台 API
- `apps/backend/src/db/migrations/100–108`:资金、成本、销售事实、退款、人工履约、总账
- `apps/frontend/src/pages/admin/AdminFinancePage.tsx`、`AdminFinanceLedgerPage.tsx`、`AdminSalesProfitPage.tsx`:财务前端
### 2.2 资金/账务全景
```text
买家支付
└─ orders / order_items
├─ fulfillment_mode = work_order ──► work_orders ──► reward_amount
├─ fulfillment_mode = affiliate_dash ──► affiliate 采购成本
├─ fulfillment_mode = legacy_platform ──► 历史成本缺口
└─ fulfillment_mode = manual ──► 客服确认履约
├─ 备用金 fund_accounts / fund_entries / manual_expenses
└─ 积分 platform_credit_accounts / entries
│
▼
cost_snapshots(estimated / actual)
│
▼
sales_daily_facts(按日、按商品、按履约方式)
│
▼
finance_journal_entries / lines(影子总账)
│
▼
finance_periods(关账) + finance_reconciliations(平台对账)
另一条并行链路:
orders / order_refunds / worker_wallet_ledgers / worker_finance_requests
└─ admin-finance-service.ts(旧版 UNION ALL 收支明细)
```
**关键事实:上面两条链路不是同一套口径。** 旧版“收支明细”按业务事件拼装,新版“销售毛利”按销售事实,总账又只读销售事实。三套数字在页面上并存,但没有强制对账。
### 2.3 三套“财务视图”的定位
| 视图 | 入口 | 口径 | 现状 |
| --- | --- | --- | --- |
| 收支明细 | `/admin/finance` | 订单收款、退款、打手报酬、提现、充值、售后追缴的现金流/事件汇总 | 旧版 `UNION ALL`,不是复式账 |
| 销售毛利 | `/admin/finance/sales-profit` | 权责发生制:订单项收入 − 退款 − 成本快照 | 依赖成本覆盖率;成本缺失时毛利不可用 |
| 总账与对账 | `/admin/finance/ledger` | 销售事实按日生成收入/退款/成本凭证 | 仅覆盖销售/成本;不接管钱包/备用金余额 |
---
## 三、数据模型与不变量
### 3.1 核心表
| 域 | 表 | 作用 |
| --- | --- | --- |
| 交易 | `orders` / `order_items` | 订单骨架、订单项、`revenue_amount_fen`、`fulfillment_mode` |
| 退款 | `order_refunds` | 一条 = 一个订单项的一次退款;整单退款用 `batch_no` 分组 |
| 商品 | `product_catalog` / `channel_product_mappings` | 标准商品、渠道 SKU 映射 |
| 成本定义 | `cost_items` / `cost_versions` | 成本模型、计价方式、按生效时间的版本 |
| 成本事实 | `cost_snapshots` | 订单项/工单/人工单实际使用的成本快照 |
| 成本异常 | `cost_exceptions` / `cost_exceptions_archive` | 缺成本、缺映射队列与归档 |
| 销售事实 | `sales_daily_facts` | 日期 × 来源 × 店铺 × 商品 × 履约方式的收入/退款/成本/毛利 |
| 人工配置 | `manual_sku_registry` / `manual_fulfillment_configs` | 内部 SKU → 渠道 + 消耗积分 + 成本模式 |
| 客服资金 | `fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations` | 备用金账户、流水、支出、对账 |
| 客服积分 | `platform_credit_accounts` / `platform_credit_entries` | 南航/乐玩/ROCK 三平台积分钱包 |
| 总账 | `finance_accounts` / `finance_journal_entries` / `finance_journal_lines` | 科目、凭证头、借贷行 |
| 期间/对账 | `finance_periods` / `finance_reconciliations` / `finance_reconciliation_items` | 关账、平台账单导入与差异明细 |
### 3.2 关键不变量:哪些是数据库强约束,哪些只是应用层约定
| 不变量 | 实现 | 强度 |
| --- | --- | --- |
| 单张凭证借贷平衡 | `normalizeLines` 应用层校验 | **弱**:无数据库触发器/延迟约束,直接 SQL 可写入不平衡凭证 |
| 已过账凭证不可修改/删除 | 文档要求;代码按日删除重建 | **未实现**:没有触发器,也没有反向凭证 API |
| 一个订单项最多一条有效人工成本快照 | `uq_cost_snapshots_manual_order_item` 部分唯一索引 | **强** |
| 一个订单项同一成本项最多一条有效快照 | `uq_cost_snapshots_active_subject_item` 部分唯一索引 | **强** |
| 备用金余额 = 期初 + Σ 流水 | 应用层 `fund_accounts.balance_fen` 维护 | **弱**:无触发器/对账约束 |
| 备用金支出必须对应一条流水 | `manual_expenses.fund_entry_id` 唯一 + 同事务写入 | **较强** |
| 一个订单项最多一条人工支出 | 仅普通索引 `idx_manual_expenses_order_item` | **弱**:可插入多条 |
| 退款金额 ≤ 订单项可退金额 | `createManualRefund` 应用层校验 | **弱**:并发下依赖订单行锁,平台路径存在绕过 |
| 平台退款外部单号唯一 | 无唯一索引 | **未实现** |
| 销售事实毛利 = 净收入 − 成本 | `chk_sales_daily_facts_profit` | **强** |
| 净收入 ≥ 0 | `chk_sales_daily_facts_amounts` | **强**(但会掩盖超额退款,见 5.9) |
| 会计期间关账后禁止新增凭证 | `assertPeriodOpen` 普通 SELECT | **弱**:无锁,存在并发穿透 |
这张表是后面所有问题的总根源:**系统把大量“财务不变量”放在了应用层,而应用层又没有全部事务化/加锁。**
---
## 四、关键链路拆解
### 4.1 订单收入固化
- `order_items.revenue_amount_fen` 在迁移 `104` 增加,回填脚本 `revenue:backfill` 分批执行。
- 当前规则:一单一品归整单;多品按等额拆分,`revenue_basis` 标记 `order_total / equal_split / platform_item`。
- `sales_daily_facts` 的销售日取 `COALESCE(orders.paid_at, orders.created_at)` 的 `Asia/Shanghai` 日期;退款会回写到**原销售日**,不是退款发生日。
**评价:**
- 金额用 `BIGINT` 分,方向正确。
- 多品等额拆分不是实际成交价,商品级毛利会失真;当前本地库恰好全部是一单一品,所以暂时没有暴露,但这是未来必须补的口径。
- 退款回写原销售日,是后面“关账期间事实仍可变”的根源。
### 4.2 成本解析与快照
- 成本项 `cost_items.cost_model`:`affiliate_purchase` / `work_order_reward` / `manual_actual` / `platform_fee` / `payment_fee` / `after_sales_loss` / `other`。
- 成本版本按 `effective_from/effective_to` 和范围优先级 `rule > product > shop > provider > global` 解析。
- 预估快照由 `syncEstimatedOrderItemCostSnapshots` 生成;工单验收后由 `syncActualWorkOrderCostSnapshots` 升级为 `actual`;人工确认时直接写 `actual`。
- 匹配不到成本不默认 0,进入 `cost_exceptions`;销售事实记 `cost_missing_count`。
**评价:**
- “不默认 0”是正确的财务设计。
- `cost_snapshots` 冻结金额,历史订单不会因为改价而变化,这是对的。
- 但 `cost_versions` 的“追溯改价”会**就地 UPDATE 当前 active 版本**(见 5.11),版本历史并不真正不可变。
- 预估快照一旦写入不会因版本变化刷新;对“支付时点成本”来说是合理的,但对“当前成本估算”来说会过期,需要在报表上明确口径。
### 4.3 人工履约资金闭环(实现最好的一段)
`confirmManualFulfillment` 的关键顺序:
1. 先按 SKU 兜底匹配 `manual_fulfillment_configs`;
2. `SELECT ... FOR UPDATE OF oi` 锁订单项;
3. 复查是否已有有效 `manual_case` 快照;
4. 解析成本项/成本版本;
5. 现金路径:`writeFundExpense` 扣备用金并写 `manual_expenses`;积分路径:`consumePlatformCreditWithClient` 扣积分并写流水;
6. 写 `cost_snapshots`(`actual`,`subject_id = order_item_id`);
7. 收口 `manual_cost_missing` 异常;
8. 全部在同一个 `withTransaction` 中。
**优点:**
- 钱与成本同事务,失败整体回滚;
- 订单项行锁 + 部分唯一索引,双击/并发只能成功一次;
- 集成测试覆盖了重复确认、并发确认、快照失败回滚。
**待改进:**
- `cost_mode` 字段在确认时没有被读取,实际只按 `credit_quantity > 0` 判断固定/可变(见 5.7);
- 快照 metadata 记录的是请求里的 `creditOwnerAdminUserId`,不是解析后的实际扣款人(见 5.8);
- `finance` 角色被路由排除,无法执行文档所说的“财务代录”(见 5.6)。
### 4.4 退款
- 手工退款:`createManualRefund` 锁订单行、按订单项计算剩余可退、插入 `order_refunds`、全额退完置 `orders.pay_status='refunded'`、关闭成本异常;提交后重算销售事实并同步总账。
- 平台退款(快手 91):`approveAndRecordKuaishouRefund` 先调用平台“同意退款”,成功后再调用 `createManualRefund` 落本地。
- 撤销:`voidOrderRefund` 把退款行置 `voided`,重算销售事实与总账。
**问题集中在平台路径和撤销路径,见 5.2、5.3。**
### 4.5 销售事实 → 总账
`syncFinanceLedgerFromFacts` 按日聚合 `sales_daily_facts`:
- 收入:借 `1101` 应收平台 / 贷 `4001` 商品销售收入
- 退款:借 `4101` 退款冲减 / 贷 `1101` 应收平台
- 成本:借 `5001` 履约成本 / 贷 `2001` 应付履约款
幂等键:`event_type + ref_type + ref_id`,其中 `ref_id = 日期`。
**评价:**
- 能生成一张“借贷平衡”的日汇总账;
- 但只使用 `1101/2001/4001/4101/5001` 五个科目,`1001/1002/1201/2005/3001/5101` 从未使用;
- 没有现金收款、没有应付结算、没有备用金/积分资产、没有钱包/押金/提现;
- 因此它不是完整复式账,而是**收入/成本权责发生制的影子账**。
### 4.6 备用金
- `fund_accounts`:主管池 / 客服账户;`balance_fen` 由应用层维护。
- `fund_entries`:`opening/funding/issue/expense/return/adjustment`,只追加。
- `manual_expenses`:客服支出明细,必须关联一条 `fund_entries`。
- 发放/缴回会写主管与客服两条相反流水,`transfer_no` 关联,设计正确。
- 对账:`fund_reconciliations` 记录系统余额、实际余额、差额。
**优点:**
- 写路径统一收口到 `writeFundExpense`;
- 发放/缴回/支出都先 `SELECT ... FOR UPDATE` 锁账户;
- `balance_fen >= 0`、`amount_fen <> 0` 等约束齐全。
**问题:**
- 对账差额在创建时计算,确认时直接拿来调整当前余额(见 5.5);
- 发放/缴回/拨付/积分充值没有幂等键(见 5.9);
- 账户余额没有数据库级“余额 = Σ流水”约束。
### 4.7 平台对账
- 手工粘贴 JSON 账单,按 `provider + platform_order_id` 匹配订单/退款。
- 明细状态:`matched / amount_mismatch / missing_order / missing_refund / duplicate`。
- 批次可标记 `resolved`。
**问题:**
- 销售行按 `orders.total_amount` 匹配,不按订单项;
- 退款行没有 `external_ref` 时取该订单“最新一条退款”,多笔退款会重复匹配同一条;
- 批次没有唯一约束,重复导入同一账单不会被拦截(见 5.10)。
---
## 五、正确性审计:按严重度排列
> 说明:以下“P0”指上生产前必须修,否则可能造成账实不符、资金损失或无法审计;“P1”指会显著降低报表/内控可信度;“P2”指体验、性能或边界问题。
### 5.1 P0:关账期间销售事实仍可重算,总账却冻结
**证据:**
- `rebuildSalesDailyFacts` 直接 `DELETE FROM sales_daily_facts WHERE stat_date BETWEEN ...`,没有任何 `finance_periods` 检查:[sales-facts-service.ts:47–57](../apps/backend/src/services/finance/sales-facts-service.ts#L47-L57)
- 定时任务每 5 分钟对最近 30 天重算:[cost-finance-sync-job.ts:47–55](../apps/backend/src/services/scheduler/cost-finance-sync-job.ts#L47-L55)
- 退款提交后立即重算原销售日:[order-refund-service.ts:301–305](../apps/backend/src/services/finance/order-refund-service.ts#L301-L305)
- 总账同步遇到已关账期间直接 `skipped`:[finance-ledger-service.ts:197–205](../apps/backend/src/services/finance/finance-ledger-service.ts#L197-L205)
**触发路径:**
1. 9 月关账;
2. 10 月发生一笔 9 月订单的退款;
3. `createManualRefund` 重算 9 月某天的 `sales_daily_facts`;
4. 总账同步看到 9 月已关账,跳过;
5. 9 月销售毛利报表已经变化,9 月总账仍是关账时点数字。
**影响:**
- 报表与总账永久分叉;
- 关账失去意义;
- 无法向审计解释“关账后收入为什么变了”。
**修复建议:**
- 方案 A(推荐):已关账期间的退款/成本变化不直接改原期间事实,改为在**当前开放期间**生成“以前年度/以前期间调整”事实或总账调整凭证;
- 方案 B:关账时把 `sales_daily_facts` 快照化(按期间冻结),后续变更只写调整表;
- 无论哪种,`rebuildSalesDailyFacts` 必须检查 `finance_periods.status`,不能静默重写已关账期间。
### 5.2 P0:平台退款先调用平台,后落本地,且没有幂等键
**证据:**
- 平台调用在第 522 行,本地 `createManualRefund` 在第 529 行:[order-refund-service.ts:522–541](../apps/backend/src/services/finance/order-refund-service.ts#L522-L541)
- 可退金额校验在 `createManualRefund` 内部(第 188 行),也就是平台调用之后:[order-refund-service.ts:181–193](../apps/backend/src/services/finance/order-refund-service.ts#L181-L193)
- `order_refunds` 只有 `refund_no` 唯一,没有 `(source, external_ref)` 唯一约束:[103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql)
**触发路径:**
- 平台退款金额大于本地剩余可退金额 → 平台已同意退款,本地 `createManualRefund` 抛 409,账上没记;
- 平台调用成功、本地插入成功,但接口超时/响应丢失 → 重试可能再插一条;
- 平台退款单号重复提交 → 没有数据库层拦截。
**影响:**
- 平台已退款、系统未退款:应收/收入虚高;
- 重复退款:收入被多冲减;
- 对账时平台有退款、系统无记录或金额不一致。
**修复建议:**
- 引入退款状态机:`requested → platform_approved → recorded → reconciled`(或 outbox);
- 在调用平台前先落“退款意图”,并做剩余可退金额预校验;
- 对 `(source, external_ref)` 建唯一索引(`external_ref <> ''` 的部分唯一索引);
- 平台调用使用平台退款单号做幂等键;本地失败要有补偿/重试任务,不能只抛错。
### 5.3 P0:全额退款撤销后,收入仍然是 0
**证据:**
- 全额退款时把订单置为 `refunded`:[order-refund-service.ts:240–263](../apps/backend/src/services/finance/order-refund-service.ts#L240-L263)
- 撤销只更新 `order_refunds.status='voided'`,没有恢复 `orders.pay_status`:[order-refund-service.ts:354–366](../apps/backend/src/services/finance/order-refund-service.ts#L354-L366)
- 销售事实在没有 confirmed 退款时,仍会把 `pay_status='refunded'` 当作全额退款:[sales-facts-service.ts:70–79](../apps/backend/src/services/finance/sales-facts-service.ts#L70-L79)
**影响:**
- 撤销全额退款后,订单仍是 `refunded`,销售事实仍然按全额退款冲减;
- 成本异常在退款时被置为 `ignored`,撤销时没有重新打开;
- 订单领取/核销等下游逻辑也可能继续把订单当作已退款。
**修复建议:**
- `voidOrderRefund` 在同一事务内根据“剩余 confirmed 退款”重算 `orders.pay_status`(0 → `paid`,否则保持 `refunded`);
- 撤销后重新打开被自动关闭的 `cost_exceptions`(`status='open'`,清空 `resolved_at`);
- 补一条集成测试:全额退款 → 撤销 → 销售事实恢复、总账恢复、异常恢复。
### 5.4 P0:关账没有门槛、没有锁,且可以重复关账
**证据:**
- `closeFinancePeriod` 只是先同步、再算试算平衡,然后直接 upsert 为 `closed`;没有 `if (!trial.balanced) throw`,也没有检查 `cost_missing_count`、人工成本、资金流水、对账差异:[finance-ledger-service.ts:413–448](../apps/backend/src/services/finance/finance-ledger-service.ts#L413-L448)
- `assertPeriodOpen` 是普通 `SELECT`,没有 `FOR UPDATE` 或 advisory lock:[finance-ledger-service.ts:537–548](../apps/backend/src/services/finance/finance-ledger-service.ts#L537-L548)
- 文档明确要求“人工单无成本/未扣备用金/金额不一致不允许关账”:[财务与履约口径总纲:126–130](./财务与履约口径总纲-2026-10-23.md#L126-L130)
- 文档说“关账前会先补齐该期间凭证,并校验试算平衡”:[财务上线初始化与验收:283](./财务上线初始化与验收-2026-10-26.md#L283)
**影响:**
- 可以带着大量 `cost_missing` 关账,毛利失真;
- 可以带着未对账的平台差异关账;
- 并发请求可以在关账检查与提交之间写入凭证;
- 重复关账会覆盖 `closed_at` 与 `summary_json`,关账审计被改写。
**修复建议:**
- 关账放在一个事务里,先对 `finance_periods` 行加锁(或 `pg_advisory_xact_lock`),再检查门槛,再写 `closed`;
- 门槛至少包括:试算平衡、`cost_missing_count=0`、人工单无成本=0、人工支出与流水差异=0、未解决对账差异=0;
- 已关账期间再次关账应拒绝或幂等返回,不得覆盖原 `closed_at`;
- 关账/反关账必须要求填写原因,并写审计。
### 5.5 P0:备用金对账使用“创建时的旧差额”调整“当前余额”
**证据:**
- 创建对账时保存 `system_balance_fen` 与 `difference_fen`:[admin-fund-service.ts:2001–2031](../apps/backend/src/services/admin/admin-fund-service.ts#L2001-L2031)
- 确认时读的是存储的 `difference_fen`,加到**当前**余额:[admin-fund-service.ts:2087](../apps/backend/src/services/admin/admin-fund-service.ts#L2087)、[admin-fund-service.ts:2115–2126](../apps/backend/src/services/admin/admin-fund-service.ts#L2115-L2126)
**触发路径:**
1. 创建对账:系统余额 100,实际 90,差额 -10;
2. 确认前发生一笔 20 元支出,余额变成 80;
3. 确认时用 -10 调整当前余额 80 → 70;
4. 实际盘点 90,账实仍然差 20。
**影响:**
- 对账确认后余额仍不等于实际;
- 差异被“调整”进了 adjustment 流水,但金额不对,后续更难查;
- 如果创建与确认间隔很久,问题更严重。
**修复建议:**
- 确认时锁账户并重新计算 `actualBalanceFen - currentSystemBalanceFen`;
- 或者创建对账时就锁账户/冻结期间,禁止期间内再发生流水;
- `difference_fen` 应作为“确认时点的差额”,而不是创建时的快照。
### 5.6 P1:权限矩阵与文档/实现不一致,且缺少职责分离
**证据:**
- 人工履约路由只允许 `admin / operator / support`,**不包含 finance**:[finance.ts:107–126](../apps/backend/src/routes/admin/finance.ts#L107-L126)
- 但服务层 `PRIVILEGED_ROLES` 明确包含 finance,说明代码作者预期 finance 可以代录:[manual-fulfillment-service.ts:493–500](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L493-L500)
- 文档说“管理员/财务可代录到指定客服账户”:[人工支出与备用金.md:32](./人工支出与备用金.md#L32)
- 同一篇文档又说“管理员/财务/运营可代录”:[人工支出与备用金.md:104](./人工支出与备用金.md#L104)
- `finance.config` 被用于成本配置、手工凭证、退款、关账、积分调整等所有写操作;`finance.review`、`finance.export` 基本未使用:[072_admin_finance_role.sql:11–14](../apps/backend/src/db/migrations/072_admin_finance_role.sql#L11-L14)、[103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql)
**影响:**
- 文档承诺的“财务代录”实际不可用;
- `operator` 可以跨客服代录备用金/积分(`resolveFundAccountId` / `resolveCreditOwnerAdminUserId` 只校验目标账户存在,不校验团队归属),是否符合内控需要明确;
- 同一个 `finance` 用户可以改成本、做手工凭证、退款、关账、调积分,没有 maker-checker;
- 无法把“制单/审核/关账”拆给不同人。
**修复建议:**
- 明确能力矩阵,拆出 `finance.post` / `finance.close` / `finance.reconcile` / `finance.cost_config`;
- 财务角色要么允许人工履约,要么在文档中删掉“财务代录”;
- 关账、手工凭证、退款、积分调整至少两人复核(制单人与审核人不能同一人);
- 代录接口增加目标客服的责任主管/团队范围校验(如果业务要求团队隔离)。
### 5.7 P1:`cost_mode` 字段在确认时没有生效
**证据:**
- 配置表有 `cost_mode`(`fixed/variable`):[104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql)
- 确认时读取了 `cfg.cost_mode`,但 `resolveManualFulfillmentCost` 只接收 `creditQuantity/unitPriceFen/providedCostFen`,没有接收 `costMode`:[manual-fulfillment-service.ts:285–298](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L285-L298)、[manual-fulfillment-service.ts:670–697](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L670-L697)
**影响:**
- 保存逻辑目前按 `creditQuantity > 0` 自动派生 `cost_mode`,所以正常 UI 路径一致;
- 但 DB 导入、历史数据、手工改库可以造出 `cost_mode='variable' + credit_quantity>0`,系统仍按固定成本扣款;
- 字段语义是“装饰性”的,容易误导配置人员。
**修复建议:**
- 要么删除 `cost_mode`,只保留 `credit_quantity`(0 = 可变);
- 要么在确认时真正读取 `cost_mode`,并校验“fixed 必须有 credit_quantity>0,variable 必须为 0”;
- 增加一条配置一致性约束/检查 SQL。
### 5.8 P1:人工快照的扣款人 metadata 不准确
**证据:**
- 快照 metadata 的 `creditOwnerAdminUserId` 取的是请求参数,而不是 `resolveCreditOwnerAdminUserId` 的返回值:[manual-fulfillment-service.ts:398–408](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L398-L408)
**影响:**
- 客服自助确认时,请求里没有 `creditOwnerAdminUserId`,metadata 为 `null`,但积分实际从该客服账户扣除;
- 审计/对账按 metadata 找人会对不上,只能再查 `platform_credit_entries`。
**修复建议:**
- 把解析后的 `creditOwnerAdminUserId` 传进快照写入;
- 同时记录积分账户 ID/流水号,保证能从快照直接追溯到资金流水。
### 5.9 P1:幂等与审计缺口
| 问题 | 证据 | 影响 |
| --- | --- | --- |
| 手工凭证重复 `reference` 静默复用旧凭证 | `ON CONFLICT DO NOTHING` 后返回 existing:[finance-ledger-service.ts:80–110](../apps/backend/src/services/finance/finance-ledger-service.ts#L80-L110) | 用户以为新建成功,实际未入账;审计 targetId 指向旧凭证 |
| 凭证金额小数被 `Math.trunc` 截断 | [finance-ledger-service.ts:503–504](../apps/backend/src/services/finance/finance-ledger-service.ts#L503-L504) | 1.9 分变 1 分,金额被静默修改 |
| 备用金拨付/发放/缴回无幂等键 | [admin-fund-service.ts:1248](../apps/backend/src/services/admin/admin-fund-service.ts#L1248)、[1305](../apps/backend/src/services/admin/admin-fund-service.ts#L1305)、[1431](../apps/backend/src/services/admin/admin-fund-service.ts#L1431) | 双击/重试可能重复转账 |
| 积分充值/调整无幂等键 | [platform-credit-service.ts:165](../apps/backend/src/services/cost-center/platform-credit-service.ts#L165)、[219](../apps/backend/src/services/cost-center/platform-credit-service.ts#L219) | 重复充值 |
| 审计日志写入失败被吞 | [session.ts:156–165](../apps/backend/src/routes/admin/session.ts#L156-L165) | 财务操作成功但没有审计记录 |
**修复建议:**
- 所有资金写命令接收 `requestId/idempotencyKey`,在 DB 建唯一键;
- 审计写入与业务同事务,或写 outbox 后异步落库;财务操作不允许“审计失败但业务成功”;
- 金额入参必须是整数分,拒绝小数;`reference` 重复应返回 409,而不是静默复用。
### 5.10 P1:平台对账的匹配粒度与批次幂等不足
**证据:**
- 销售行用 `orders.total_amount` 作为 expected,而不是订单项/账单行:[finance-reconciliation-service.ts:292–298](../apps/backend/src/services/finance/finance-reconciliation-service.ts#L292-L298)
- 退款行没有 `external_ref` 时取该订单最新一条 confirmed 退款:[finance-reconciliation-service.ts:301–318](../apps/backend/src/services/finance/finance-reconciliation-service.ts#L301-L318)
- `finance_reconciliations` 没有 `(provider, period_from, period_to, source)` 唯一约束:[104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql)
**影响:**
- 一单多品、平台按明细行出账时,销售行金额对不上;
- 同一订单多笔退款且账单不带退款单号时,多行可能匹配到同一条退款,误报 matched;
- 同一账单可以重复导入,形成多个批次,差异统计重复;
- `expected_fen` 对 missing 行记 0,汇总差额不是真正的“全部行差额”。
**修复建议:**
- 账单行增加订单项/平台明细号维度,按明细匹配;
- 退款匹配必须要求平台退款单号,或至少要求金额+时间+订单项;
- 对导入文件做 hash/批次唯一约束,重复导入拒绝或幂等;
- missing 行也纳入 expected/actual 的差异计算,并区分“系统缺失”和“金额不符”。
### 5.11 P1:成本版本“追溯改价”就地 UPDATE,破坏版本历史
**证据:**
- 追溯改价时直接 `UPDATE cost_versions SET amount_fen=..., effective_from=..., created_by=..., approved_by=...`:[product-catalog-service.ts:1397–1415](../apps/backend/src/services/cost-center/product-catalog-service.ts#L1397-L1415)
- 自动发货价格同样有就地更新:[affiliate-product-service.ts:348–362](../apps/backend/src/services/cost-center/affiliate-product-service.ts#L348-L362)
- 文档明确“改价只新增版本;不覆盖历史”:[成本中心与履约模型.md:232](./成本中心与履约模型.md#L232)、[:265](./成本中心与履约模型.md#L265)
- `approved_by_admin_user_id` 被写成当前操作人,等于自批:[product-catalog-service.ts:1404](../apps/backend/src/services/cost-center/product-catalog-service.ts#L1404)
**影响:**
- `cost_snapshots` 的历史金额仍保留,但 `cost_version_id` 指向的版本内容已经变了;
- 想从版本表还原“当时生效的价格”会得到错误结果;
- 审计无法区分“当时定价”与“后来改价”;
- 自批使 `approved_by` 字段失去意义。
**修复建议:**
- 追溯改价也插入新版本(更早的 `effective_from`),旧版本保留并设置 `effective_to`;
- 如果必须修正错误版本,保留原行并新增“更正版本”,不要 UPDATE 金额;
- `approved_by` 必须与 `created_by` 不同,或至少记录审批时间/原因。
### 5.12 P1:总账只是“销售/成本影子账”,不能当完整复式账
**证据:**
- 总账只生成三类凭证:收入、退款、成本:[finance-ledger-service.ts:212–249](../apps/backend/src/services/finance/finance-ledger-service.ts#L212-L249)
- 所有成本都进 `5001`,没有区分人工 `5101`:[finance-ledger-service.ts:244–248](../apps/backend/src/services/finance/finance-ledger-service.ts#L244-L248)
- 科目表里的 `1001/1002/1201/2005/3001/5101` 从未被写入(本机库查询确认)
- `finance_journal_lines.dimensions_json` 在销售凭证中始终是 `{}`,没有 provider/shop/product/order 维度
**影响:**
- 试算平衡只能证明“每张凭证自身借贷相等”,不能证明账务完整;
- 资产负债表科目(银行、备用金、积分预付、应付)没有发生额,无法与钱包/资金对账;
- 无法输出店铺/商品/订单级损益;
- 不能把“总账”作为正式账套使用。
**修复建议:**
- 明确命名:当前是“销售/成本影子账”,不要对外称完整总账;
- 若要转正,补齐钱包、押金、提现、充值、寄卖、售后、平台费、备用金/积分的完整事件与结算分录;
- 给销售凭证写入 `provider/shopId/productId/orderId/fulfillmentMode` 维度;
- 增加“总账 vs 销售事实 vs 钱包/备用金”的日对账。
### 5.13 P1:三套报表口径并存,缺少对账
| 口径 | 位置 | 差异点 |
| --- | --- | --- |
| 旧收支明细 | `admin-finance-service.ts` | 订单收款、退款、打手报酬、提现、充值、售后追缴的 `UNION ALL`;按事件日期 |
| 销售毛利 | `sales-facts-service.ts` | 权责发生制;按 `paid_at` 归属;退款回写原销售日 |
| 影子总账 | `finance-ledger-service.ts` | 只读销售事实;按日总额 |
**影响:**
- 同一时间段,“收款/退款/打手报酬”在三个页面可能不同;
- 财务/运营无法判断哪个是权威口径;
- 对外报表没有唯一来源。
**修复建议:**
- 明确唯一权威口径(建议:销售事实 + 总账,旧报表只做现金流辅助);
- 在页面上标注口径与“不可直接比较”;
- 增加日对账:旧报表事件 vs 销售事实 vs 总账。
### 5.14 P2:默认日期使用 UTC,月初边界会取错月份
**证据:**
- 试算平衡和总账同步路由用 `new Date().toISOString().slice(0,10)` 取“今天”:[finance-ledger.ts:48](../apps/backend/src/routes/admin/finance-ledger.ts#L48)、[:78](../apps/backend/src/routes/admin/finance-ledger.ts#L78)
**影响:**
- 上海时间每月 1 日 00:00–07:59,UTC 还是上个月最后一天;
- 默认期间会变成上个月,用户看到的试算平衡/同步范围不对。
**修复建议:**
- 统一用 `formatShanghaiDate(new Date())`,不要用 UTC 日期。
### 5.15 P2:金额精度与 JS Number
- 数据库用 `BIGINT` 分,正确;但服务层大量 `Number(bigint)` 再回写。
- 当前规模(十亿分级别)远低于 `Number.MAX_SAFE_INTEGER`,暂时安全。
- 但 `Math.trunc` 接受小数、`Number` 转换没有上限校验;未来接入更大金额/多币种时需要改成字符串/整数运算。
---
## 六、数据质量现状(本机开发库,非生产)
> 以下数字来自本机 Docker `order_site-postgres-1` 的只读查询(2026-10-27),**不是生产库**。列在这里是为了说明:在“未按上线手册初始化”的默认状态下,财务页面会呈现什么,以及为什么关账前必须做数据门禁。
| 指标 | 数值 | 解读 |
| --- | ---: | --- |
| 订单 / 订单项 | 375,459 / 375,447 | 与文档基线量级一致 |
| 销售事实行 / 日期范围 | 5,381 / 2026-09-25–2026-10-22 | 只有 28 天,不是全历史 |
| 未归一订单项 | 340,225 / 375,447 = **90.6%** | 商品级毛利基本不可用 |
| `work_order` 未归一 | 153,148 / 153,148 = **100%** | 接单平台商品维度全缺 |
| `affiliate_dash` 未归一 | 53,204 / 53,204 = **100%** | 自动发货商品维度全缺 |
| `legacy_platform` 未归一 | 53,901 / 53,901 = **100%** | 历史平台商品维度全缺 |
| `manual` 未归一 | 79,972 / 115,194 = **69.4%** | 人工也只有三成挂了商品 |
| 标准商品 / 渠道映射 | 542 / **0** | 本机库只有 `man:` 商品,没有渠道映射 |
| 成本快照 | 72,297,全部 `work_order_reward` | 没有人工/自动发货成本快照 |
| 缺成本订单项 | 63,764 / 146,338 = **43.6%** | 这些项成本按 0 计 |
| 缺成本对应收入 | ¥8,730,818.99 / ¥13,230,754.79 = **66.0%** | 毛利被严重高估 |
| 成本异常 | 146,338(111,116 open) | 主要是 `product_mapping_missing` |
| 人工 SKU 配置 / 登记 | 0 / 0 | 人工链路未初始化 |
| 备用金账户 / 支出 | 0 / 0 | 资金链路未初始化 |
| 总账凭证 / 分录 | 56 / 112 | 只有 2026-09、2026-10 两个月 |
| 总账不平衡凭证 | 0 | 应用层校验有效 |
| 已使用科目 | 4 个(1101/2001/4001/5001) | 1001/1002/1201/2005/3001/5101 全为 0 |
| 期间 / 对账批次 | 0 / 0 | 尚未关账、未导入账单 |
**结论:**
- 当前库的“毛利 ¥1,047.91 万”不可用于决策:66% 的收入没有成本,真实毛利会显著更低;
- 商品级分析只能覆盖约 9.4% 的订单项;
- 这不是代码必然结果,而是“初始化/回填未完成”的结果;但系统没有在页面上阻止用户把这种毛利当真实毛利(只给了 warning)。
---
## 七、权限、审计与内控
### 7.1 角色/能力矩阵(按代码)
| 能力 | admin | finance | operator | support |
| --- | --- | --- | --- | --- |
| 查看总账/试算平衡 | ✅ | ✅ | ✅(有 `finance.view`) | ❌ |
| 手工凭证 / 关账 / 反关账 | ✅ | ✅ | ❌ | ❌ |
| 成本配置 / 成本版本 | ✅ | ✅ | ❌ | ❌ |
| 手工退款 / 撤销退款 | ✅ | ✅ | ❌ | ❌ |
| 快手平台退款 | ✅ | ✅ | ❌ | ❌ |
| 人工履约待办 / 确认 | ✅ | ❌(路由排除) | ✅ | ✅ |
| 备用金查看 | ✅ 全部 | ✅ 全部 | ✅(主管范围) | ✅ 本人 |
| 备用金发放 | ✅ | ❌ | ✅ | ❌ |
| 备用金缴回 | ✅ | ❌ | ✅ | ✅(本人) |
| 备用金支出 | ✅ | ❌ | ❌ | ✅(本人) |
| 备用金对账 | ✅ | ✅ | ✅ | ❌ |
| 备用金差异调整 | ✅ | ✅ | ❌ | ❌ |
| 积分充值/调整 | ✅ | ✅ | ❌ | ❌ |
### 7.2 内控问题
1. **职责分离不足**:`finance.config` 同时允许配置成本、做手工凭证、退款、关账、调积分;没有制单/审核分离。
2. **关账无审批**:关账/反关账只要求 `finance.config`,没有二次确认/原因/审批。
3. **审计可丢失**:`recordAdminAudit` 捕获异常后只 `logWarn`;财务操作可以成功而无审计。
4. **审计非不可变**:`admin_audit_logs` 没有防篡改/哈希链;应用可删改。
5. **代录范围宽**:`operator` 可以指定任意客服的备用金/积分账户(`resolveFundAccountId` / `resolveCreditOwnerAdminUserId` 只校验存在性),是否符合“主管只能管自己团队”的内控需要业务确认。
6. **前端操作缺少确认**:关账/反关账是单按钮直接 mutation,没有二次确认;对账“标记解决”固定写“后台确认差异已处理”,没有让用户填写原因([AdminFinanceLedgerPage.tsx:216](../apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx#L216))。
---
## 八、性能与运维
### 8.1 当前性能设计
- 财务同步任务 `cost-finance-sync` 默认每 5 分钟跑最近 30 天:
- 履约方式兜底
- 人工商品归一
- 人工配置 rematch
- 预估成本快照
- 工单实际成本快照
- 销售事实重算
- 总账同步
- `sales_daily_facts` 重建是“删 30 天 + 重插 30 天”,目前 5,381 行,成本可接受;但订单量增长后会变重。
- 总账同步按天循环,每天至少 1 次 period 查询 + 1 个事务 + 0–3 条凭证;30 天约 30 个事务。
- 迁移 108 增加了 `idx_orders_basis_date`、`idx_order_items_order_sku_cover` 等索引,方向正确。
### 8.2 风险
- 每 5 分钟全量重算 30 天,会与在线退款/人工确认争锁;`sales_daily_facts` 的 `DELETE` 会锁范围行。
- 没有历史分区;`finance_journal_*`、`sales_daily_facts` 未来会持续增长。
- 没有“同步失败告警/重试队列”:定时任务失败只会在日志里,下一次再试;退款后的即时同步失败不会自动补偿。
- 数据保留/归档目前只覆盖 `cost_exceptions`,不覆盖 `finance_journal_*`、`sales_daily_facts`。
### 8.3 建议
- 销售事实改为按日增量重算(只重算有变化的日期),而不是固定 30 天全量;
- 总账同步与事实重算放在同一任务内做“日期级”幂等,并记录 `last_success_at`;
- 关键任务失败接入告警(至少 `logError` + 监控);
- 对 `finance_journal_entries/lines`、`sales_daily_facts` 规划按月分区或归档。
---
## 九、测试与验证
### 9.1 本次实际执行
| 命令 | 结果 |
| --- | --- |
| `cd apps/backend && npx tsc -p tsconfig.json --noEmit` | 通过 |
| `cd apps/backend && npm run lint:check` | 通过 |
| `cd apps/backend && npm test` | 853 tests,849 pass,4 skipped,0 fail |
| `cd apps/frontend && npm test` | 181 tests,181 pass,0 fail |
| 财务定向单测(15 项) | 全部通过 |
> 4 个 skipped 中包含需要真实 PostgreSQL 的 `manual-fulfillment.integration.test.ts`;默认不设置 `MANUAL_FULFILLMENT_TEST_DATABASE_URL` 时会跳过。
### 9.2 已覆盖
- 人工履约:固定成本、可变成本、重复确认、并发确认、快照失败回滚、代录指定账户;
- 总账:销售事实同步、借贷平衡、手工凭证、不平衡拒绝、关账阻断、对账匹配;
- 备用金/积分:单事务扣款、余额不足、异常收口(集成测试中部分覆盖)。
### 9.3 未覆盖(建议补)
- 已关账期间发生退款/人工确认 → 事实与总账分叉;
- 平台退款金额超过剩余可退 → 平台已调用、本地失败;
- 全额退款撤销 → 收入/异常恢复;
- 备用金对账创建后发生流水 → 确认差额;
- 重复导入同一平台账单;
- 权限矩阵(finance 被排除、operator 代录范围);
- 审计写入失败时财务操作是否应回滚;
- 手工凭证重复 `reference`;
- 金额小数输入。
---
## 十、改进路线图
### P0:上生产前(建议 1–2 周)
| # | 动作 | 验收标准 |
| --- | --- | --- |
| 1 | 关账期间事实冻结/调整机制 | 已关账期间的 `sales_daily_facts` 不再被重算;退款/成本变化走当前期间调整 |
| 2 | 退款状态机 + 幂等 | `(source, external_ref)` 唯一;平台调用前预校验;失败可补偿 |
| 3 | 全额退款撤销修复 | 撤销后 `pay_status` 恢复、事实恢复、异常重开 |
| 4 | 关账门槛 + 锁 | 不满足门槛无法关账;并发写入被阻断;重复关账不覆盖审计 |
| 5 | 备用金对账差额重算 | 确认时按当前余额重算,账实相等 |
| 6 | 数据库硬约束 | 凭证借贷平衡触发器、已过账不可删改、`fund_accounts` 余额校验、`manual_expenses(order_item_id)` 唯一 |
| 7 | 权限/审计 | 拆分 `finance.post/close/reconcile`;关键操作双人复核;审计与业务同事务 |
### P1:2–4 周
| # | 动作 | 验收标准 |
| --- | --- | --- |
| 8 | 对账按订单项/平台明细匹配 | 多品订单、多笔退款可正确匹配 |
| 9 | 统一报表口径 | 旧收支明细、销售毛利、总账有明确主口径与日对账 |
| 10 | 成本版本 append-only | 追溯改价也新增版本;`approved_by` 不再自批 |
| 11 | 总账维度 | 凭证行写 provider/shop/product/order;可出订单/商品损益 |
| 12 | 数据质量门禁 | 上线范围 `cost_missing=0`、人工单 100% 有快照/流水、未归一占比可解释 |
| 13 | 幂等键 | 发放/缴回/拨付/积分充值/手工凭证全部支持 requestId |
### P2:后续
- 完整总账:钱包、押金、提现、充值、寄卖、售后、平台服务费;
- 反冲/red reversal 工作流;
- 利润表、资产负债表、现金流量简表;
- 发票/税务/多币种;
- 总账/事实表分区与归档;
- 前端关账二次确认、对账差异原因必填。
---
## 十一、本次已实施改进(2026-10-27)
本轮按路线图先落 P0,并继续 P1 的 requestId 幂等、成本版本 append-only 与平台对账维度改造。
### 11.1 已完成
| # | 改进 | 关键实现 | 验证 |
| --- | --- | --- | --- |
| 1 | 关账期间销售事实冻结 | `rebuildSalesDailyFacts` 跳过 `finance_periods.status='closed'` 的月份,返回 `skippedClosedPeriods`;退款/撤销/平台退款在已关账期间直接 409 | 本地库临时关闭 2026-09 后重算,9 月 `computed_at` 不变,10 月正常重算 |
| 2 | 总账改为追加调整,不再删除重建 | `syncFinanceLedgerFromFacts` 按日算 delta,使用 `sales_*_adjustment` 凭证;按日 advisory lock 防并发重复 | 新代码中不再有 `DELETE FROM finance_journal_entries`;现有 10 月数据同步 delta=0 |
| 3 | 凭证不可删改 + 借贷平衡硬约束 | 迁移 `104`:`trg_finance_journal_balanced`、`trg_finance_journal_immutable` | 手工插入不平衡凭证被数据库拒绝;删除有分录的已过账凭证被拒绝 |
| 3.1 | 备用金余额勾稽硬约束 | 迁移 `104`:`trg_fund_entries_balance`、`trg_fund_accounts_balance`,按账户校验 `balance_fen = Σ fund_entries.amount_fen` | 余额 100 但无流水的账户被数据库拒绝;期初+流水一致时通过 |
| 4 | 平台退款先校验、先落意图 | `finance_refund_intents` 状态机;`approveAndRecordKuaishouRefund` 在调用平台前校验可退金额;`order_refunds(source, external_ref)` 唯一 | 金额超可退时在调用平台前返回 409;集成测试通过 |
| 5 | 全额退款撤销恢复收入 | `voidOrderRefund` 按剩余 confirmed 退款重算 `orders.pay_status`,并重新打开被自动关闭的成本异常 | 空库集成脚本:退款后 `refunded/0`,撤销后 `paid/5000` |
| 6 | 关账门槛 + 期间锁 | `assertPeriodCloseable` 校验人工缺成本、人工无资金流水、人工金额不一致、未解决对账差异、试算平衡;关账与凭证共用 `finance_period` advisory lock;重复关账 409 | 集成测试先被“人工未确认成本”拦住,补齐后才允许关账 |
| 7 | 备用金对账差额按当前余额重算 | `confirmAdminFundReconciliation` 在锁内用 `actual - current balance` 计算差额,并回写 `system_balance_fen/difference_fen` | 静态回归测试覆盖 |
| 8 | 权限拆分 + finance 纳入人工履约 | 新增 `finance.post / finance.close / finance.reconcile`;人工履约路由与订单查看路由加入 `finance`;前端按新权限显示 | 迁移 `104` + 路由/前端类型检查通过 |
| 9 | 凭证重复 reference 不再静默复用 | `postJournalEntry` 默认重复幂等键抛 409;金额必须是整数分 | 类型检查与全量测试通过 |
| 10 | 关账/反关账二次确认 | 前端 `Popconfirm` 明确提示关账后不可写、会校验门槛 | 前端类型检查通过 |
### 11.2 本次新增/修改的主要文件
- 财务基础:[103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql)(商品/成本/销售事实/退款)
- 财务控制与 L1:[104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql)(人工履约/总账/对账/控制/钱包硬化)
- 拼单与寄卖:[105_work_order_consignment.sql](../apps/backend/src/db/migrations/105_work_order_consignment.sql)
- 总账:[finance-ledger-service.ts](../apps/backend/src/services/finance/finance-ledger-service.ts)
- 销售事实:[sales-facts-service.ts](../apps/backend/src/services/finance/sales-facts-service.ts)
- 退款:[order-refund-service.ts](../apps/backend/src/services/finance/order-refund-service.ts)
- 备用金/积分:[admin-fund-service.ts](../apps/backend/src/services/admin/admin-fund-service.ts)、[platform-credit-service.ts](../apps/backend/src/services/cost-center/platform-credit-service.ts)
- 成本版本:[product-catalog-service.ts](../apps/backend/src/services/cost-center/product-catalog-service.ts)、[affiliate-product-service.ts](../apps/backend/src/services/cost-center/affiliate-product-service.ts)
- 平台对账:[finance-reconciliation-service.ts](../apps/backend/src/services/finance/finance-reconciliation-service.ts)
- 权限:[finance.ts](../apps/backend/src/routes/admin/finance.ts)、[finance-ledger.ts](../apps/backend/src/routes/admin/finance-ledger.ts)、[orders.ts](../apps/backend/src/routes/admin/orders.ts)、[kuaishou-industry.ts](../apps/backend/src/routes/admin/kuaishou-industry.ts)
- 前端:[App.tsx](../apps/frontend/src/App.tsx)、[AdminFinanceLedgerPage.tsx](../apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx)、[AdminOrderDetailPage.tsx](../apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx)、[AdminSalesProfitPage.tsx](../apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx)
- 回归测试:[finance-controls.test.ts](../apps/backend/src/services/finance/finance-controls.test.ts)
### 11.3 验证结果
| 检查 | 结果 |
| --- | --- |
| 后端类型检查 | 通过 |
| 后端 Lint / Prettier | 通过 |
| 后端全量测试 | 866 项,862 pass,4 skipped |
| 前端类型检查 / 测试 | 通过;181 pass |
| 空库全量迁移 `001 → 105` | 通过 |
| 人工履约集成测试(空库) | 通过 |
| 数据库触发器手测 | 不平衡凭证、删除已过账凭证、余额与流水不一致均被拒绝 |
| 销售事实关账冻结手测 | 9 月 `computed_at` 不变,10 月正常重算 |
| P1 资金 requestId 手测 | 同 requestId 第二次返回 409,余额只增加一次 |
| P1 积分 requestId 手测 | 同 requestId 第二次返回 409,积分只增加一次 |
| P1 成本版本 append-only 手测 | 旧版本 retired 且金额不变,新版本 active 从追溯日生效 |
| P1 对账订单项匹配手测 | 一单两品两行全部 matched,`order_item_id` 正确落库 |
### 11.3.1 L1 打手钱包硬化(迁移 104)
| 项 | 内容 |
| --- | --- |
| 金额类型 | `orders.total_amount`、`worker_wallets.*`、`worker_wallet_ledgers.amount/balance_after/frozen_after`、`worker_finance_requests.amount/frozen_amount`、工单/份额/售后/寄卖金额全部 int4 → **BIGINT** |
| 非负约束 | `worker_wallets` 六个余额、流水后余额、提现申请、订单/工单/份额/售后/寄卖金额加 CHECK |
| 奖励幂等 | `worker_wallet_ledgers.event_key` + 触发器 + 唯一索引:工单+份额+流水类型,防未来重复结算 |
| 历史重复 | 新增只读视图 `v_worker_wallet_duplicate_rewards`;排除寄卖货款后为 **8 组**疑似重复 `reward_settlement`(16 条流水,合计 ¥392.40),多计约 **¥196.20**,不自动扣回,需财务确认 |
| 审计脚本 | [audit-worker-wallet.sql](../apps/backend/scripts/audit-worker-wallet.sql) 只读检查负余额、负后余额、历史重复、event_key 缺失 |
### 11.4 P1 本轮已完成
| # | 改进 | 关键实现 | 验证 |
| --- | --- | --- | --- |
| 1 | 资金/积分 requestId 幂等 | 迁移 `104` 给 `fund_entries` / `platform_credit_entries` 加 `request_id` + 账户级唯一索引;重复请求 409,事务回滚 | 同 requestId 发放/充值第二次 409,余额只变一次 |
| 2 | 成本版本追溯改价 append-only | 追溯改价不再 UPDATE 金额,而是退休旧版本 + 新增从追溯日生效的版本;旧版本行保留 | 旧版本 `retired` 且金额不变,新版本 `active` 从追溯日生效 |
| 2.1 | 人工确认关账保护 | 已关账期间的订单项确认直接 409,避免“扣了钱但销售事实/总账没记” | 代码路径 + 回归测试覆盖 |
| 3 | 平台对账订单项/明细维度 | 迁移 `104` 给 `finance_reconciliation_items` 加 `platform_item_id/order_item_id`;账单行可用 `orderItemId` 按订单项收入匹配;退款无外部单号时至少要求金额一致 | 一单两品两行全部 matched,`order_item_id` 正确落库 |
| 4 | 对账重复行判定 | 销售/退款重复键加入平台明细号/订单项/金额,避免一单多品被误判 duplicate | 两行同订单不同订单项不再互判重复 |
### 11.5 仍未完成(按路线图继续)
1. **已关账期间的“当前期间调整”**:目前是“冻结 + 禁止直接退款/撤销”,还没有自动生成“以前期间调整”事实/凭证;跨期退款需先反关账。
2. **成本版本同时间/上游基线导入的更正**:相同生效时间没有可插入的时间区间,仍会有一次就地 UPDATE;上游价格基线导入的部分路径也仍可能就地更正。需要在 schema 上支持同时间多版本或引入版本变更审计表。
3. **总账维度与完整覆盖**:仍只覆盖销售/成本,钱包/押金/提现/寄卖/平台费未入账;凭证行还没有 provider/shop/product 维度。
4. **审计与业务同事务**:审计失败仍只记录告警,未做到“审计失败则财务操作回滚”。
5. **maker-checker**:手工凭证、关账、退款、积分调整仍未强制双人复核。
6. **前端 requestId 复用**:后端已支持 requestId,前端尚未在“同一用户意图重试”时复用同一个 key。
---
## 附录 A:关键文件索引
| 文件 | 作用 |
| --- | --- |
| [finance-ledger-service.ts](../apps/backend/src/services/finance/finance-ledger-service.ts) | 凭证、试算平衡、关账、销售事实同步 |
| [sales-facts-service.ts](../apps/backend/src/services/finance/sales-facts-service.ts) | 销售事实重算、毛利总览 |
| [order-refund-service.ts](../apps/backend/src/services/finance/order-refund-service.ts) | 手工/平台退款、撤销 |
| [manual-fulfillment-service.ts](../apps/backend/src/services/finance/manual-fulfillment-service.ts) | 人工履约确认、扣备用金/积分、写快照 |
| [finance-reconciliation-service.ts](../apps/backend/src/services/finance/finance-reconciliation-service.ts) | 平台账单导入、匹配、解决 |
| [admin-fund-service.ts](../apps/backend/src/services/admin/admin-fund-service.ts) | 备用金账户、流水、支出、对账 |
| [admin-finance-service.ts](../apps/backend/src/services/admin/admin-finance-service.ts) | 旧版收支明细 |
| [cost-snapshot-sync-service.ts](../apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts) | 预估/实际成本快照同步 |
| [product-catalog-service.ts](../apps/backend/src/services/cost-center/product-catalog-service.ts) | 商品、成本版本、追溯改价 |
| [platform-credit-service.ts](../apps/backend/src/services/cost-center/platform-credit-service.ts) | 积分钱包 |
| [100_fund_accounts.sql](../apps/backend/src/db/migrations/100_fund_accounts.sql) | 备用金表(历史基线) |
| [103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql) | 商品、成本中心、销售事实、退款 |
| [104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql) | 人工履约、总账/对账、财务控制、L1 钱包 |
| [105_work_order_consignment.sql](../apps/backend/src/db/migrations/105_work_order_consignment.sql) | 拼单、索引、寄卖对接客服 |
## 附录 B:上生产前建议执行的复核 SQL
```sql
-- 1. 凭证是否借贷平衡(应为 0 行)
SELECT entry_id
FROM finance_journal_lines
GROUP BY entry_id
HAVING SUM(debit_fen) <> SUM(credit_fen);
-- 2. 备用金余额是否等于流水合计(应为 0 行)
SELECT a.id, a.account_no, a.balance_fen, COALESCE(SUM(e.amount_fen), 0) AS entry_sum
FROM fund_accounts a
LEFT JOIN fund_entries e ON e.account_id = a.id
GROUP BY a.id, a.account_no, a.balance_fen
HAVING a.balance_fen <> COALESCE(SUM(e.amount_fen), 0);
-- 3. 人工现金成本与备用金支出是否一致(应为 0 行)
SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen
FROM cost_snapshots cs
JOIN manual_expenses me ON me.order_item_id = cs.order_item_id
WHERE cs.subject_type = 'manual_case'
AND cs.superseded_at IS NULL
AND cs.total_amount_fen <> me.amount_fen;
-- 4. 关账后是否还在改销售事实(应为 0 行)
SELECT f.stat_date, f.computed_at, p.closed_at
FROM sales_daily_facts f
JOIN finance_periods p ON p.period_key = to_char(f.stat_date, 'YYYY-MM')
WHERE p.status = 'closed'
AND f.computed_at > p.closed_at;
-- 5. 平台退款是否重复(应为 0 行)
SELECT source, external_ref, COUNT(*)
FROM order_refunds
WHERE external_ref <> ''
GROUP BY source, external_ref
HAVING COUNT(*) > 1;
-- 6. 同一订单项是否有多条人工支出(应为 0 行)
SELECT order_item_id, COUNT(*)
FROM manual_expenses
WHERE order_item_id IS NOT NULL
GROUP BY order_item_id
HAVING COUNT(*) > 1;
-- 7. 成本缺失与未归一规模(关账门禁输入)
SELECT
COUNT(*) FILTER (WHERE cost_missing_count > 0) AS rows_with_missing_cost,
SUM(cost_missing_count) AS missing_items,
SUM(gross_revenue_fen) FILTER (WHERE cost_stage = 'missing') AS missing_revenue_fen
FROM sales_daily_facts;
-- 8. 总账 vs 销售事实按日收入差异(应为 0 行)
WITH ledger AS (
SELECT e.period_key, e.ref_id::date AS stat_date,
SUM(l.credit_fen) FILTER (WHERE l.account_code = '4001') AS ledger_revenue_fen
FROM finance_journal_entries e
JOIN finance_journal_lines l ON l.entry_id = e.id
WHERE e.event_type = 'sales_revenue'
GROUP BY e.period_key, e.ref_id
)
SELECT f.stat_date, f.gross_revenue_fen, l.ledger_revenue_fen
FROM sales_daily_facts f
FULL JOIN ledger l ON l.stat_date = f.stat_date
WHERE COALESCE(f.gross_revenue_fen, 0) <> COALESCE(l.ledger_revenue_fen, 0);
```
---
## 附录 C:术语
| 术语 | 含义 |
| --- | --- |
| 履约方式 `fulfillment_mode` | 谁执行:`work_order` / `affiliate_dash` / `legacy_platform` / `manual` |
| 成本模型 `cost_model` | 成本怎么算:采购 / 打手报酬 / 人工实际 / 平台费 / 支付费 / 售后损失 / 其他 |
| 销售事实 `sales_daily_facts` | 按日、来源、店铺、商品、履约方式聚合的收入/退款/成本/毛利 |
| 成本快照 `cost_snapshots` | 某个订单项/工单/人工单实际使用的成本版本与金额,历史不回写 |
| 影子总账 | 并行记录复式分录、但不接管钱包余额的账 |
| 备用金 | 公司拨给主管、再由主管发给客服的周转现金 |
| 积分钱包 | 南航/乐玩/ROCK 三个平台的客服积分账户,互不通用 |
| 拼单 | 同一工单由多个打手按份额参与,按份额结算 |