Files
order_site/docs/成本中心与履约模型.md
T
yml2213 39fe448b48 feat(cost-exceptions): 支持 --days=0 一次性归档
一次性清理需要「不按时间过滤」的语义,但 CLI 把 days 下限钳在 1:
本地库的 15.2 万条 resolved 异常是 17 小时前重建生成的,
--days=1 一条都命中不了,只能手写 SQL。

- --days=0 表示不过滤时间,归档全部已处理行(open 仍不归档);
- 执行时打印一次性清理提示,避免日常误用;
- 记录 2026-10-25 的实际清理结果:151,939 行 / 152 批 / 3.9 秒,
  热表 260,253 → 108,314(全为 open),VACUUM FULL 后 155 MB → 51 MB,
  归档表 61 MB,主键重叠 0 条。

验证:--days=0 --dry-run 返回 retentionDays=0、archivableRows=0(已清空)。
2026-10-25 13:34:09 +08:00

25 KiB
Raw Blame History

成本中心与履约模型

状态:现行。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。 最后校准:2026-10-25 代码基线:dev @ 465a9f65 + 迁移合并(未推送、未部署生产) 数据基线:生产 dump order_site-20261022-092601.dump(2026-07-10 ~ 2026-10-22);本地已升级到迁移 107,并完成商品、映射、成本快照与销售事实初始化 业务口径以 财务与履约口径总纲-2026-10-23.md 为准;本文只负责数据模型、迁移与运维。 接口字段见 成本中心API契约.md。


〇、模型总览

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 个

原 dev 分支上,商品、成本中心、人工履约用 10 个迁移(103–112,1,275 行)分批落地,字段反复摇摆。其中:

  • 108(下线人工支出登记)与 109(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是空操作;
  • 111 是给 104 自身种子打的补丁(draft 占位改 active 占位);
  • 112 的 resolution_note 列本就属于异常表。

2026-10-25 收敛为 5 个(合计 1,151 行):

迁移 内容 行数
103_product_catalog.sql product_catalog(无载体列)+ channel_product_mappings(含匹配键)+ order_items 商品/归一字段 152
104_cost_center.sql cost_items(cost_model)+ cost_versions + cost_snapshots + cost_exceptions(含 resolution_note)+ 订单项收入固化 + 系统成本项 + 遗留占位清理 503
105_sales_daily_facts.sql order_items.fulfillment_mode 三列 + sales_daily_facts 166
106_order_refunds.sql order_refunds 退款表 38
107_manual_fulfillment.sql 人工 SKU 登记/配置 + 三平台积分钱包 + 备用金账户编号可读化 292

合并原则:同一批上线的结构性 DDL 才合并;数据回填与业务口径注释跟着所属表走。103 保持独立(商品主数据是成本与销售事实的共同上游),106 保持独立(退款是独立业务线)。

关于历史人工快照的负数 subject_id(重要坑,已固化在 104 的列注释里):人工成本快照的主体从「人工支出登记单 id」改成「客服备用金支出单 id」(manual_expenses.id),两套 id 空间各自从 1 开始会撞车。撞上唯一索引 uq_cost_snapshots_active_subject_item 后,INSERT ... ON CONFLICT DO NOTHING 会静默跳过——钱从备用金扣了、成本快照却没写成。历史孤儿快照的主体一律用负数,与正数的 manual_expenses.id 彻底隔离;新建链路不再产生负数主体。

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

id
canonical_sku           -- 内部稳定编码;前缀表达来源
canonical_name
category
unit_name               -- 件/个/颗等,配合 normalized_quantity
status                  -- active / archived
attributes_json

canonical_sku 是内部稳定编码,创建后不随平台改名。attributes_json 会被价格导入覆盖,不能作为统计源。

2.2 channel_product_mappings

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,只有加入名称键才能消歧。

匹配优先级:

店铺 + SKU 编码
  > 来源 + SKU 编码
  > 店铺 + SKU 编码 + 精确名称
  > 来源 + SKU 编码 + 精确名称
  > 店铺 + SKU 编码 + 名称系列
  > 来源 + SKU 编码 + 名称系列
  > 店铺 + 名称系列
  > 来源 + 名称系列

匹配不到的商品进入 product_mapping_missing 异常队列,不能直接拿原始 sku_code 当报表维度。

2.3 order_items 的商品与数量字段

canonical_product_id    -- 归一到标准商品
normalized_quantity     -- 从 SKU 名称解析并固化;未解析时为 NULL
normalized_sku_name     -- NFKC、小写、去空白后的名称
normalized_sku_series   -- 剥离数量并归一同义字后的名称系列

normalized_quantity 不预填。 预填会把"5 个"类 SKU 永久锁死成 1,必须由回填脚本按 SKU 名称解析后写入。quantity 长期几乎恒为 1,禁止直接拿它当销量。

2.4 接单模板批量初始化

662 个接单模板不允许逐个手工建商品。后台「从接单模板初始化」按以下规则批量生成:

  1. 每个启用模板生成 1 个标准商品,内部编码 tpl:<rule_key>;暂无历史工单的模板也先建商品。
  2. 历史工单直接命中模板,以 work_orders.product_rule_id 作为映射的第一手证据,不只看原始 sku_code。
  3. 同一 provider + shop_id + sku_code 只命中一个模板时,生成 sku_code 精确映射。
  4. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
  5. 同一编码 + 同一名称仍命中多个模板时,不选最新、不按次数猜;跳过并列入冲突清单。
  6. 来源 + 店铺内名称系列唯一时,额外生成 sku_name_series 兜底,永远排在编码类映射之后。
  7. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 normalized_quantity,不能把"5个"再写成 quantity_multiplier=5,否则会重复乘算。
  8. 重跑初始化时只清理 source='import' 且备注为"接单模板 … 自动初始化"的映射,并重置由这些映射带出的订单项 canonical_product_id;人工映射、成本版本、快照和真实订单/工单不删除。

本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。

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

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

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=manual_expenses.id(客服备用金支出单),没有独立的登记单表;
  • 负数 subject_id 表示历史孤儿快照(约定固化在迁移 104 的列注释里),金额保留供毛利参考,不与新链路争主体 id。

3.5 cost_exceptions

id
subject_type, subject_id
order_item_id, work_order_id
reason                 -- product_mapping_missing / cost_missing / manual_expense_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 在迁移 104 里固化:

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

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

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 放进迁移会拖慢部署,也无法在中途失败后按批重试。

npm run fulfillment:backfill

只填 NULL,可重复执行。判定 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 重写后重跑):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 分钟先归集待判定的订单项,再算成本与销售事实。


八、迁移基线与本地库同步(2026-10-25 更新)

生产尚未执行过 103 及之后的迁移。本地库与代码对齐有两条路径:

  1. 全新升级:从生产 dump 恢复到 101 / 102,直接 npm run db:migrate 升到 107;
  2. 已有本地库:清掉 schema_migrations 里 103–112 的记录后重跑。所有迁移都是 IF NOT EXISTS,数据步骤幂等:
DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-2])_';

2026-10-25 实测:按第 2 条路径升级后的库,与全新构建的库 schema 完全一致(pg_dump --schema-only 仅随机 token 不同),种子数据(3 个系统成本项、2 个全局占位版本、4 个人工履约档案)也一致。

旧的逐列改造脚本 scripts/rewrite-local-db-2026-10-23.sql 已随迁移合并作废,移入 archive/2026-10-23/;只在追溯当时 carrier_type → cost_model / fulfillment_mode 的改造矩阵时查阅,不要在新环境执行。

8.1 收入口径回填(迁移 104 不做全表更新)

order_items.revenue_amount_fen / revenue_basis 在迁移 104 里只建列,不刷历史数据: 375,447 行的全表 UPDATE 实测约 34 秒,超过默认语句超时(DATABASE_STATEMENT_TIMEOUT_MS=15s), 放进单事务迁移会拖住启动甚至直接失败。

历史行由脚本分批回填(幂等、可续跑):

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 复用空间即可。


十、关联文档