502 lines
26 KiB
Markdown
502 lines
26 KiB
Markdown
# 成本中心与履约模型
|
||
|
||
> 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。
|
||
> 最后校准:2026-10-27
|
||
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移基线收敛为 `103`–`105`)
|
||
> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地只跑了近 30 天增量,历史数字仅作基线。
|
||
> 上线初始化/验收见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
|
||
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责数据模型、迁移与运维。
|
||
> 接口字段见 [成本中心API契约.md](成本中心API契约.md)。
|
||
|
||
---
|
||
|
||
## 〇、模型总览
|
||
|
||
```text
|
||
orders ──┬── order_items ──── fulfillment_mode (唯一履约事实)
|
||
│ ├── canonical_product_id ── product_catalog
|
||
│ ├── normalized_quantity / normalized_sku_name / normalized_sku_series
|
||
│ └── revenue_amount_fen / revenue_basis
|
||
│
|
||
├── cost_snapshots ──── cost_versions ──── cost_items (cost_model)
|
||
├── cost_exceptions (order_item_id → order_items.fulfillment_mode)
|
||
├── sales_daily_facts (fulfillment_mode)
|
||
└── manual_expenses (客服备用金支出) ── subject_id ── cost_snapshots
|
||
```
|
||
|
||
四条不变式:
|
||
|
||
1. **履约方式只有一处存**:`order_items.fulfillment_mode`。快照、异常、销售事实要展示履约方式时 join `order_items`。
|
||
2. **商品来源只由 `canonical_sku` 前缀表达**,商品表不存载体列。
|
||
3. **成本模型只表达"怎么算"**:`cost_items.cost_model` 与履约方式正交,不复用同一字段。
|
||
4. **成本金额只在一处改**:`cost_versions`;业务表只保存快照,历史成本永不回写。
|
||
|
||
---
|
||
|
||
## 一、迁移基线:10 个收敛为 5 个
|
||
|
||
2026-10-27 最终收敛为 3 个领域迁移(合计约 2,177 行):
|
||
|
||
| 迁移 | 内容 | 行数 |
|
||
| --- | --- | ---: |
|
||
| `103_finance_foundation.sql` | 商品目录、渠道映射、成本项/版本/快照/异常、订单项收入与履约方式、销售事实、退款单 | 861 |
|
||
| `104_finance_controls.sql` | 人工 SKU/配置、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | 1,119 |
|
||
| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | 197 |
|
||
|
||
原来 103–114 的 12 个补丁文件已删除;生产当前在 102,只需执行 103–105。
|
||
|
||
**合并原则**:同一批上线的结构性 DDL 按领域合并为 `103`(商品/成本/销售/退款)、`104`(人工/总账/控制)、`105`(拼单/寄卖);不再按单表或几行补丁拆文件。
|
||
|
||
**人工成本快照主体(2026-10-26 定稿)**:`manual_case` 的 `subject_id` 统一为 `order_item_id`,不再使用 `manual_expenses.id` / `platform_credit_entries.id`(两张表各自 BIGSERIAL,必然撞 id)。资金来源只放 `metadata_json.fundExpenseId / creditEntryId`。迁移 `104` 增加订单项级唯一索引 `uq_cost_snapshots_manual_order_item`,一个订单项最多一条有效人工快照;`subject_id` 为正数的历史行归一到订单项,负数历史孤儿快照按迁移 `103` 约定保留、不参与新链路。
|
||
|
||
### 1.1 字段替换对照
|
||
|
||
| 旧字段 | 新字段 | 出现位置 |
|
||
| --- | --- | --- |
|
||
| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 |
|
||
| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 |
|
||
| `cost_snapshots.carrier_type` | (删除) | DB |
|
||
| `cost_exceptions.carrier_type` | (删除) | DB |
|
||
| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 |
|
||
| `sales_daily_facts.carrier_type` | `sales_daily_facts.fulfillment_mode` | DB / 销售事实接口 |
|
||
| `work_product_rules.legacy_cost_source` | (删除) | DB |
|
||
| `cost_versions.cost_item_carrier_type` | `cost_versions.cost_item_cost_model` | 成本版本接口 |
|
||
| `pricing_mode: per_hour / fixed_daily` | (删除,只剩三种) | 成本项接口 |
|
||
| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB |
|
||
|
||
### 1.2 成本模型映射
|
||
|
||
| 旧 `cost_type` | 新 `cost_model` |
|
||
| --- | --- |
|
||
| `auto_purchase` | `affiliate_purchase` |
|
||
| `work_order_reward` | `work_order_reward` |
|
||
| `manual_service` | `manual_actual` |
|
||
| `platform_fee` | `platform_fee` |
|
||
| `payment_fee` | `payment_fee` |
|
||
| `after_sales_loss` | `after_sales_loss` |
|
||
| `shipping_other` / `other` | `other` |
|
||
|
||
### 1.3 商品来源前缀
|
||
|
||
| 前缀 | 来源 | 对应履约方式(仅参考,不作推导依据) |
|
||
| --- | --- | --- |
|
||
| `aff:` | 自动发货 | `affiliate_dash` |
|
||
| `tpl:` | 接单模板 | `work_order` |
|
||
| `wo:` | 历史工单证据 | `work_order` |
|
||
| `man:` | 人工 | `manual` |
|
||
| 其他 | 未分类 | 不得推断 |
|
||
|
||
本地现状:`aff:` 38 个、`tpl:` 662 个、`wo:` 45 个,`man:` 0 个(P1 待建)。
|
||
|
||
---
|
||
|
||
## 二、商品主数据
|
||
|
||
### 2.1 `product_catalog`
|
||
|
||
```text
|
||
id
|
||
canonical_sku -- 内部稳定编码;前缀表达来源
|
||
canonical_name
|
||
category
|
||
unit_name -- 件/个/颗等,配合 normalized_quantity
|
||
status -- active / archived
|
||
attributes_json
|
||
```
|
||
|
||
`canonical_sku` 是内部稳定编码,创建后不随平台改名。`attributes_json` 会被价格导入覆盖,**不能作为统计源**。
|
||
|
||
### 2.2 `channel_product_mappings`
|
||
|
||
```text
|
||
provider -- 落库值:91kaquan / pdd / douyin_mashida
|
||
shop_id -- 空串 = 来源级;具体店铺优先
|
||
platform_sku_code
|
||
platform_sku_name
|
||
platform_sku_name_key -- 归一后的精确名称或数量系列键
|
||
match_type -- sku_code / sku_code_name / sku_code_series / sku_name_series
|
||
canonical_product_id
|
||
quantity_multiplier -- 数量已在归一数量中体现,系列 SKU 固定为 1
|
||
confidence -- exact / rule / manual
|
||
source -- manual / import / rule
|
||
enabled
|
||
```
|
||
|
||
唯一键 = `provider + shop_id + platform_sku_code + match_type + platform_sku_name_key`。91 在同一店铺复用 `sku_code`,只有加入名称键才能消歧。
|
||
|
||
匹配优先级:
|
||
|
||
```text
|
||
店铺 + SKU 编码
|
||
> 来源 + SKU 编码
|
||
> 店铺 + SKU 编码 + 精确名称
|
||
> 来源 + SKU 编码 + 精确名称
|
||
> 店铺 + SKU 编码 + 名称系列
|
||
> 来源 + SKU 编码 + 名称系列
|
||
> 店铺 + 名称系列
|
||
> 来源 + 名称系列
|
||
```
|
||
|
||
匹配不到的商品进入 `product_mapping_missing` 异常队列,**不能直接拿原始 `sku_code` 当报表维度**。
|
||
|
||
### 2.3 `order_items` 的商品与数量字段
|
||
|
||
```text
|
||
canonical_product_id -- 归一到标准商品
|
||
normalized_quantity -- 从 SKU 名称解析并固化;未解析时为 NULL
|
||
normalized_sku_name -- NFKC、小写、去空白后的名称
|
||
normalized_sku_series -- 剥离数量并归一同义字后的名称系列
|
||
```
|
||
|
||
**`normalized_quantity` 不预填。** 预填会把"5 个"类 SKU 永久锁死成 1,必须由回填脚本按 SKU 名称解析后写入。`quantity` 长期几乎恒为 1,禁止直接拿它当销量。
|
||
|
||
### 2.4 接单模板批量初始化
|
||
|
||
接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成:
|
||
|
||
1. **商品全量同步,不依赖订单**:所有启用模板都建/刷新标准商品,内部编码 `tpl:<rule_key>`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。
|
||
2. **渠道映射仍只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板只建商品、不建映射——没有订单就没有平台 SKU 可映射。
|
||
3. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。
|
||
4. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。
|
||
5. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
|
||
6. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。
|
||
7. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。
|
||
8. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。
|
||
9. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。
|
||
|
||
本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。
|
||
|
||
全量同步改版后(2026-10-29,本地库预览 09-30 ~ 10-29):模板 710 个全部启用、范围内有工单证据 388 个;商品新建 704 个 / 复用 6 个,候选映射 3,198 条。之前被跳过的 322 个无单模板,现在也会有商品(可定价),只是暂时没有渠道映射。
|
||
|
||
### 2.5 自动发货商品
|
||
|
||
自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:<skuCode>`,**不写 `channel_product_mappings`**。渠道映射只代表"平台 SKU 能归一到标准商品",不代表自动发货。
|
||
|
||
上游价格用 `apps/backend/scripts/sync-affiliate-dash-prices.ts` 批量导入(清单见 `apps/backend/scripts/data/affiliate-dash-prices.json`,38 条):按 `aff:<skuCode>` 补建/更新商品,并给 `sys_affiliate_purchase` 成本项生成 `scope=product` 的按件版本。金额变化时先停用旧版本再插入新版本,历史快照不变。首次导入历史基线用 `--effective-from=2026-07-10`。
|
||
|
||
---
|
||
|
||
## 三、成本中心
|
||
|
||
### 3.1 成本模型与适用履约方式
|
||
|
||
| `cost_model` | 适用履约方式 | 计价方式 | 成本来源 |
|
||
| --- | --- | --- | --- |
|
||
| `affiliate_purchase` | `affiliate_dash` | `per_item` | 上游 SKU 固定成本版本 |
|
||
| `work_order_reward` | `work_order` | `per_order` | 模板规则级版本 / 工单实际报酬 |
|
||
| `manual_actual` | `manual` | `per_order` | 订单页逐单录入的实际支出 |
|
||
| `platform_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 |
|
||
| `payment_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 |
|
||
| `after_sales_loss` | 全部 | `per_order` | 未配置 |
|
||
| `other` | 全部 | `per_order` | 未配置 |
|
||
|
||
后端判定见 `cost-center-shared.ts` 的 `costModelScopeSql()`;通用模型对所有履约方式生效,专属模型只对对应履约方式生效。
|
||
|
||
### 3.2 `cost_items` / `cost_versions`
|
||
|
||
```text
|
||
cost_items
|
||
id, code, name
|
||
cost_model -- 见 §3.1
|
||
pricing_mode -- per_item / per_order / percent_of_revenue
|
||
unit_name, currency, status, note
|
||
|
||
cost_versions
|
||
id, cost_item_id
|
||
scope_type -- global / provider / shop / product / rule
|
||
provider, shop_id
|
||
canonical_product_id, work_product_rule_id
|
||
amount_fen -- 固定金额;percent_of_revenue 时用 rate_bps
|
||
rate_bps -- 万分比,10000 = 100%
|
||
effective_from, effective_to
|
||
source -- manual / upstream_sync / import
|
||
status -- draft / active / retired
|
||
change_reason, created_by, approved_by
|
||
```
|
||
|
||
范围优先级:`rule > product > shop > provider > global`。成本版本按**业务发生时间**(`paid_at` / `accepted_at`)解析,不是回填时的 `now()`。
|
||
|
||
改价只新增版本;历史订单走 `cost_snapshots`,不受影响。
|
||
|
||
### 3.3 系统内置成本项
|
||
|
||
| 编码 | 成本模型 | 说明 |
|
||
| --- | --- | --- |
|
||
| `sys_work_order_reward` | `work_order_reward` | 662 个接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 |
|
||
| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 |
|
||
| `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 |
|
||
|
||
工单报酬的三种快照:
|
||
|
||
- 工单**验收**后按 `work_orders.reward_amount` 写 `actual`(本地 95,870 条);
|
||
- 未验收但已确定报酬的工单先按同一金额写 `estimated`(metadata `source=work_order_reward_committed`,本地 21,217 条);
|
||
- 取消工单不计成本。
|
||
|
||
### 3.4 `cost_snapshots`
|
||
|
||
```text
|
||
id
|
||
subject_type -- order_item / work_order / manual_case
|
||
subject_id, order_id, order_item_id, work_order_id
|
||
cost_item_id, cost_version_id, canonical_product_id
|
||
quantity, unit_amount_fen, total_amount_fen
|
||
snapshot_stage -- estimated / actual / adjustment
|
||
source -- resolved / legacy_rule / manual_input / missing
|
||
metadata_json
|
||
cost_basis_at -- 解析成本版本时用的业务时间
|
||
resolved_at, superseded_at, created_at
|
||
```
|
||
|
||
规则:
|
||
|
||
- 改价只新增 `cost_versions`,不覆盖历史;
|
||
- 同一主体 + 同一成本项只允许一条"当前有效"快照(`uq_cost_snapshots_active_subject_item`);
|
||
- 预估快照代表支付时点的成本,**不因后续改价重写**;只有实际成本允许替代当前有效行;
|
||
- 匹配不到成本时写 `cost_missing` 异常,**不能默认 0**;
|
||
- 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`;
|
||
- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=order_item_id`;资金流水 id 只写 `metadata_json`,**没有独立的登记单表**;
|
||
- 负数 `subject_id` 表示历史孤儿快照(迁移 `103` 约定),迁移 `104` 保留不动,不参与新链路;
|
||
- 一个订单项最多一条有效人工快照(`uq_cost_snapshots_manual_order_item`)。
|
||
|
||
### 3.5 `cost_exceptions`
|
||
|
||
```text
|
||
id
|
||
subject_type, subject_id
|
||
order_item_id, work_order_id
|
||
reason -- product_mapping_missing / cost_missing / manual_cost_missing
|
||
status -- open / resolved / ignored
|
||
detail_json
|
||
created_at, resolved_at, resolved_by
|
||
```
|
||
|
||
`open` 数量必须在后台暴露。按履约方式筛选时用 `EXISTS`/`IN` 半连接 `order_items.fulfillment_mode`(本地 248,878 条,`IN` 半连接 + `idx_order_items_fulfillment_id` 索引约 170ms)。
|
||
|
||
---
|
||
|
||
## 四、订单项收入固化
|
||
|
||
`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `103` 里固化:
|
||
|
||
| `revenue_basis` | 含义 |
|
||
| --- | --- |
|
||
| `order_total` | 一单一品,订单实付全部归该订单项 |
|
||
| `equal_split` | 多订单项按等额拆分,余数给最后一项 |
|
||
| `platform_item` | 有平台明细时替换 |
|
||
| `manual` / `unknown` | 未定 |
|
||
|
||
本地全量 316,585 项都是 `order_total`(当前一单一品),但模型按"订单项"设计,不把"一单一品"写死。
|
||
|
||
收入按 `orders.paid_at` 归属销售日(Asia/Shanghai),没有 `paid_at` 时退回 `created_at`。
|
||
|
||
---
|
||
|
||
## 五、销售事实
|
||
|
||
### 5.1 `sales_daily_facts`
|
||
|
||
```text
|
||
stat_date -- Asia/Shanghai,按 paid_at 归属
|
||
provider, shop_id
|
||
canonical_product_id -- 允许为空:未归一桶
|
||
fulfillment_mode -- work_order / affiliate_dash / legacy_platform / manual
|
||
order_count, item_count, quantity
|
||
gross_revenue_fen, refund_amount_fen, net_revenue_fen
|
||
cost_amount_fen, cost_missing_count, cost_stage
|
||
gross_profit_fen
|
||
computed_at
|
||
```
|
||
|
||
口径:
|
||
|
||
- 收入 = `order_items.revenue_amount_fen`,不直接拿整单金额;
|
||
- 成本来自 `cost_snapshots` 当前有效行;没有快照的订单项计入 `cost_missing_count`;
|
||
- 未归一商品允许 `canonical_product_id` 为空,单独成"未归一"行,保证总收入对平;
|
||
- `cost_stage`:`missing`(全部无快照)/ `partial`(部分无)/ `estimated`(全部预估)/ `actual`(全部实际);
|
||
- `cost_missing_count > 0` 时该行毛利不直接用于决策;
|
||
- 退款暂未接入,`refund_amount_fen` 先按 `pay_status='refunded'` 预留(整单全额冲减,无部分退款)。
|
||
|
||
### 5.2 本地重算结果(2026-10-23)
|
||
|
||
| 履约方式 | 事实行 | 订单项 | 收入 | 成本 | 缺失成本 |
|
||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||
| `work_order` | 9,585 | 118,053 | ¥533.57 万 | ¥452.83 万 | 966 |
|
||
| `affiliate_dash` | 3,163 | 41,676 | ¥508.97 万 | ¥497.41 万 | 0 |
|
||
| `manual` | 1,849 | 102,955 | ¥1,219.20 万 | ¥0.32 万 | 102,954 |
|
||
| `legacy_platform` | 201 | 53,901 | ¥389.12 万 | ¥0 | 53,901 |
|
||
|
||
---
|
||
|
||
## 六、履约事实
|
||
|
||
### 6.1 `order_items.fulfillment_mode`
|
||
|
||
```text
|
||
fulfillment_mode -- work_order / affiliate_dash / legacy_platform / manual;允许 NULL
|
||
fulfillment_decided_at -- 最后一次判定时间
|
||
fulfillment_reason -- 判定来源:order_sync / reconcile / backfill
|
||
```
|
||
|
||
判定顺序固定:**工单 → 自动发货任务 → 历史平台任务 → 人工**。当前库中三种载体没有重叠,顺序不影响结果;后续出现重叠必须告警,不能静默覆盖。
|
||
|
||
决策服务在 `services/fulfillment/fulfillment-mode-service.ts`:
|
||
|
||
- `classifyFulfillmentMode()` —— 纯函数,供单测与文档对照;
|
||
- `decideOrderItemsFulfillmentModes()` —— 订单同步后批量落库;
|
||
- `reconcilePendingFulfillmentModes()` —— 24 小时兜底归集;
|
||
- `getFulfillmentModeCounts()` —— 计数核对。
|
||
|
||
### 6.2 回填
|
||
|
||
回填是**一次性脚本**,不塞进迁移:31 万行 `UPDATE` 放进迁移会拖慢部署,也无法在中途失败后按批重试。
|
||
|
||
```bash
|
||
npm run fulfillment:backfill
|
||
```
|
||
|
||
只填 `NULL`,可重复执行。判定 SQL:
|
||
|
||
```sql
|
||
WITH decided AS (
|
||
SELECT
|
||
oi.id,
|
||
CASE
|
||
WHEN EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||
THEN 'work_order'
|
||
WHEN EXISTS (
|
||
SELECT 1 FROM fulfillment_tasks ft
|
||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||
) THEN 'affiliate_dash'
|
||
WHEN EXISTS (
|
||
SELECT 1 FROM fulfillment_tasks ft
|
||
WHERE ft.order_item_id = oi.id
|
||
AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei')
|
||
) THEN 'legacy_platform'
|
||
ELSE 'manual'
|
||
END AS fulfillment_mode
|
||
FROM order_items oi
|
||
)
|
||
UPDATE order_items oi
|
||
SET fulfillment_mode = d.fulfillment_mode,
|
||
fulfillment_decided_at = now(),
|
||
fulfillment_reason = 'backfill_2026_10_23'
|
||
FROM decided d
|
||
WHERE oi.id = d.id
|
||
AND oi.fulfillment_mode IS NULL;
|
||
```
|
||
|
||
> 上述计数是 2026-10-23 历史回填的结果,仅作迁移正确性参照;当前本地生产副本只跑了近 30 天增量,**不要用这些数字判断上线是否完成**。
|
||
> 本地验收(2026-10-23 重写后重跑):`work_order` 118,053 / `manual` 102,955 / `legacy_platform` 53,901 / `affiliate_dash` 41,676,四项相加 = 316,585,`NULL = 0`。
|
||
|
||
### 6.3 决策边界(写进代码注释与测试)
|
||
|
||
1. `fulfillment_mode` 允许短暂为 `NULL`;订单项创建时通常还没有工单/任务,不能强制非空。
|
||
2. 上线后新订单项超过 **24 小时**仍为 `NULL`,进入异常队列;不是要求在创建瞬间就有值。
|
||
3. 模式可以因业务改派而变化,但每次变化必须写原因与操作人,禁止静默 `UPDATE`。
|
||
4. **商品来源不能反推履约方式。** 当前 11,349 个人工项指向 `tpl:*` 商品,就是典型反例。
|
||
5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。
|
||
6. 三种载体同时命中的订单项必须先告警人工复核;当前本地为 0。
|
||
|
||
---
|
||
|
||
## 七、同步脚本与调度
|
||
|
||
| 命令 | 作用 |
|
||
| --- | --- |
|
||
| `npm run fulfillment:backfill` | 履约方式分批回填(只填 NULL) |
|
||
| `npm run finance:sync -- --mode=all` | 预估快照 + 实际快照 + 销售事实重算 |
|
||
| `npm run finance:sync -- --mode=estimated` | 只算预估快照并写缺失成本异常 |
|
||
| `npm run finance:sync -- --mode=actual` | 只把工单快照升级为实际金额 |
|
||
| `npm run finance:sync -- --mode=facts` | 只重算销售事实 |
|
||
|
||
长历史重算要分窗口:单次 `rebuildSalesDailyFacts` 超过约 400 天会被语句超时(`DATABASE_STATEMENT_TIMEOUT_MS`,默认 15 秒)打断。本地 2026-07-10 ~ 2026-10-23 分 4 段重算,正常。
|
||
|
||
定时任务 `services/scheduler/cost-finance-sync-job.ts` 每 5 分钟先归集待判定的订单项,再算成本与销售事实;默认只处理最近 7 天(可在 `scheduled_jobs` 配置里调大,测试/小机器不建议超过 30 天,避免单条语句超时)。
|
||
|
||
---
|
||
|
||
## 八、迁移基线与本地库同步(2026-10-27 收敛)
|
||
|
||
生产数据库当前在 **102**。dev 已把原 103–114 收敛为 3 个领域迁移:
|
||
|
||
| 迁移 | 内容 |
|
||
| --- | --- |
|
||
| `103_finance_foundation.sql` | 商品目录、成本中心、成本项/版本/快照、销售事实、退款单 |
|
||
| `104_finance_controls.sql` | 人工履约、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 |
|
||
| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 |
|
||
|
||
- 生产升级:**102 → 103 → 104 → 105**,只执行 3 个迁移文件。
|
||
- 全新库:`001 → 105` 一次跑完。
|
||
- 已跑过旧 103–114 的测试库:**直接重建**,或把 `schema_migrations` 清到 102 后重新执行 103–105;旧的逐补丁校准脚本已删除。
|
||
- 生产库只到 102,不依赖旧 103–114 的迁移记录。
|
||
|
||
### 8.1 收入口径回填(迁移 103 不做全表更新)
|
||
|
||
`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `103` 里**只建列,不刷历史数据**:
|
||
375,447 行的全表 `UPDATE` 实测约 **34 秒**,超过默认语句超时(`DATABASE_STATEMENT_TIMEOUT_MS=15s`),
|
||
放进单事务迁移会拖住启动甚至直接失败。
|
||
|
||
历史行由脚本分批回填(幂等、可续跑):
|
||
|
||
```bash
|
||
npm run revenue:backfill -- --dry-run # 统计待回填量并抽样预览
|
||
npm run revenue:backfill # 默认每批 5000 行
|
||
npm run revenue:backfill -- --batch-size=2000 --max-batches=100
|
||
```
|
||
|
||
服务启动时会做一次带索引的计数:若仍有 `revenue_basis='unknown'` 的订单项,会打印告警并提示执行上面的命令。
|
||
**不执行回填的后果是销售事实收入偏低**(订单项收入按 0 计),不会报错,所以部署后必须确认该计数为 0。
|
||
|
||
`check-sql-guard.ts` 改为以 `main` 为基线校验迁移字节:**已发布的迁移不可修改**,未合并分支上的新迁移仍可重写;基线可用 `MIGRATION_BASELINE_REF` 覆盖,找不到基线时跳过该项检查。
|
||
|
||
---
|
||
|
||
## 九、索引与性能
|
||
|
||
| 索引 | 用途 |
|
||
| --- | --- |
|
||
| `idx_order_items_fulfillment_mode (fulfillment_mode, order_id)` | 按履约方式筛订单项 |
|
||
| `idx_order_items_fulfillment_id (fulfillment_mode, id)` | 异常按履约方式半连接 |
|
||
| `idx_order_items_canonical_product` | 按标准商品查订单项 |
|
||
| `idx_order_items_normalized_sku_lookup` | 按归一名称/系列匹配映射 |
|
||
| `idx_channel_product_mappings_match_lookup` | 映射查找 |
|
||
| `uq_channel_product_mappings_match_key` | 映射唯一键 |
|
||
| `idx_cost_versions_lookup / product / rule` | 成本版本解析 |
|
||
| `uq_cost_snapshots_active_subject_item` | 每个主体 + 成本项只允许一条有效快照 |
|
||
| `idx_cost_exceptions_status_created` | 异常队列列表 |
|
||
| `idx_sales_daily_facts_date / shop / product / mode` | 销售事实各维度 |
|
||
| `idx_work_orders_rule_cover` | 成本版本页按接单规则聚合 |
|
||
| `idx_order_items_revenue_cover` | 收入聚合 |
|
||
| `idx_work_categories_name` | 商品目录按分类名 lateral join |
|
||
| `idx_cost_exceptions_archivable` | 归档任务扫描「已处理且超期」的异常(部分索引,`status <> 'open'`) |
|
||
|
||
### 9.1 成本异常归档(60 天)
|
||
|
||
`cost_exceptions` 只增不减:本地快照 260,253 行(resolved 151,939 / open 108,314)。已处理的行只是审计痕迹,继续留在热表会拖慢按状态+时间的分页,并让唯一索引持续膨胀。
|
||
|
||
| 项 | 规则 |
|
||
| --- | --- |
|
||
| 归档对象 | `status <> 'open'` 且 `COALESCE(resolved_at, created_at)` 超过保留期 |
|
||
| open 行 | **永不归档**——它是待办队列,超期也要留在页面上 |
|
||
| 保留期 | `COST_EXCEPTION_RETENTION_DAYS`,默认 **60** 天 |
|
||
| 落点 | `cost_exceptions_archive`(保留原 id 与时间字段,加 `archived_at`) |
|
||
| 执行 | `data-retention` 每日任务;每批 1000 行,`DELETE ... RETURNING` + `INSERT` 同语句,单批原子 |
|
||
| 手动 | `npm run cost-exceptions:archive -- --dry-run` / `-- --days=90` |
|
||
| 一次性清理 | `npm run cost-exceptions:archive -- --days=0`(不按时间过滤,归档全部已处理行;open 仍不归档) |
|
||
|
||
归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。
|
||
|
||
> **2026-10-25 已执行一次性清理(历史库)**:`--days=0` 口径归档 151,939 条 resolved 异常(152 批,3.9 秒)。该数字只作历史记录。
|
||
> 热表 260,253 → 108,314 行(全部为 open),`VACUUM FULL` 后体积 155 MB → 51 MB(heap 38 / idx 13),
|
||
> 归档表 61 MB;主键重叠校验 0 条。生产环境不要在业务时段跑 `VACUUM FULL`,日常由 autovacuum 复用空间即可。
|
||
|
||
---
|
||
|
||
## 十、关联文档
|
||
|
||
- 业务口径: [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md)
|
||
- 接口契约: [成本中心API契约.md](成本中心API契约.md)
|
||
- 人工支出与备用金: [人工支出与备用金.md](人工支出与备用金.md)
|
||
- 影子总账: [财务系统集成方案.md](财务系统集成方案.md)
|
||
- 运维: [数据库SQL约束与运维.md](数据库SQL约束与运维.md)、[部署启动与数据库重建.md](部署启动与数据库重建.md)
|