- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明
918 lines
57 KiB
Markdown
918 lines
57 KiB
Markdown
# 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 三个平台的客服积分账户,互不通用 |
|
||
| 拼单 | 同一工单由多个打手按份额参与,按份额结算 |
|