Files
order_site/docs/成本中心与履约模型.md

502 lines
26 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.
# 成本中心与履约模型
> 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。
> 最后校准: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)