迁移 104 里对 order_items 的全表 UPDATE 实测 34.4 秒(375,447 行), 远超默认语句超时 15s,单事务迁移会拖住启动甚至直接失败。 - 104 只保留 DDL 与索引/约束,删除全表 UPDATE; - 新增 scripts/backfill-revenue-basis.ts:按 id 分批、幂等、支持 --dry-run / --batch-size / --max-batches(kebab 与 camel 两种写法); - package.json 增加 revenue:backfill; - migrate.ts 启动时做一次带索引计数,仍有 unknown 时打印告警并给出命令, 不阻塞启动; - 文档补充回填步骤与「不执行的后果」。 验证: - 实测 37.5 万行全表更新 34.4s → 脚本按批执行,单批 5000 行; - 功能验证:单条订单项置 unknown → 脚本回填 → 金额与 basis 恢复正确; - 启动告警验证:保留 1 条 unknown 重启 → 日志出现告警;回填后计数归零; - 全新库从 102 迁移到 107 仍成功(2s)。
24 KiB
成本中心与履约模型
状态:现行。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。 最后校准:2026-10-25 代码基线:
dev @ 465a9f65 + 迁移合并(未推送、未部署生产) 数据基线:生产 dumporder_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
四条不变式:
- 履约方式只有一处存:
order_items.fulfillment_mode。快照、异常、销售事实要展示履约方式时 joinorder_items。 - 商品来源只由
canonical_sku前缀表达,商品表不存载体列。 - 成本模型只表达"怎么算":
cost_items.cost_model与履约方式正交,不复用同一字段。 - 成本金额只在一处改:
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 个标准商品,内部编码
tpl:<rule_key>;暂无历史工单的模板也先建商品。 - 历史工单直接命中模板,以
work_orders.product_rule_id作为映射的第一手证据,不只看原始sku_code。 - 同一
provider + shop_id + sku_code只命中一个模板时,生成sku_code精确映射。 - 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
- 同一编码 + 同一名称仍命中多个模板时,不选最新、不按次数猜;跳过并列入冲突清单。
- 来源 + 店铺内名称系列唯一时,额外生成
sku_name_series兜底,永远排在编码类映射之后。 - 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入
normalized_quantity,不能把"5个"再写成quantity_multiplier=5,否则会重复乘算。 - 重跑初始化时只清理
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(metadatasource=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 决策边界(写进代码注释与测试)
fulfillment_mode允许短暂为NULL;订单项创建时通常还没有工单/任务,不能强制非空。- 上线后新订单项超过 24 小时仍为
NULL,进入异常队列;不是要求在创建瞬间就有值。 - 模式可以因业务改派而变化,但每次变化必须写原因与操作人,禁止静默
UPDATE。 - 商品来源不能反推履约方式。 当前 11,349 个人工项指向
tpl:*商品,就是典型反例。 voucher_state只做展示与核销流程,不参与任何成本/履约判断。- 三种载体同时命中的订单项必须先告警人工复核;当前本地为 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 及之后的迁移。本地库与代码对齐有两条路径:
- 全新升级:从生产 dump 恢复到
101/102,直接npm run db:migrate升到107; - 已有本地库:清掉
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 |
十、关联文档
- 业务口径: 财务与履约口径总纲-2026-10-23.md
- 接口契约: 成本中心API契约.md
- 人工支出与备用金: 人工支出与备用金.md
- 影子总账: 财务系统集成方案.md
- 运维: 数据库SQL约束与运维.md、部署启动与数据库重建.md