Files
order_site/docs/成本中心与履约模型.md
T
yml2213andClaude Opus 4.5 fb679d76f6 refactor(finance): 人工成本收敛为「人工录单 → 扣备用金 → 写快照」
公司人工支出登记单与审批报销状态机整链下线,人工成本只留一条链路。

口径调整(2026-10-23 确认):
- 历史人工支出找不回来,直接放弃:不补录、不做样本校准、不做均价估算,
  只保证上线后的新单不丢。
- 人工成本逐单按实际金额,从客服备用金扣款,客服只能用本人账户;
  没有登记单、没有审批、没有报销,录入即计入毛利。

后端:
- 新增 services/finance/manual-order-cost-service.ts:扣备用金 + 写 actual
  成本快照,同一事务,余额不足直接拒绝不允许负余额。
- 成本快照主体从「人工支出登记单 id」改为「客服备用金支出单 id」
  (subject_type='manual_case',subject_id=manual_expenses.id)。
- 删除 services/finance/manual-expense-service.ts(1263 行)与
  routes/admin/finance.ts 下 /finance/manual-expenses* 全套接口。

前端:
- 删除 AdminManualExpensePage.tsx(778 行)与 /admin/manual-expenses 菜单页。
- ManualOrderCostModal 去掉「提交审核 / 立即计入成本」开关,录入即生效。
- 新增 services/admin/manual-order-cost.ts。

迁移:
- 106_manual_expense_records.sql → 106_fund_account_no_readable.sql,
  只保留备用金账户编号可读化。
- 108_drop_manual_expense_records.sql:删除 manual_expense_records 与
  manual_expense_events 两张表。
- 109_isolate_legacy_manual_snapshots.sql:把来源为旧登记链的历史孤儿快照
  subject_id 改为负数,与 manual_expenses.id 彻底隔离。

踩坑修复(E2E 实测发现):主体 id 换空间后与历史孤儿快照撞唯一索引
uq_cost_snapshots_active_subject_item,INSERT ... ON CONFLICT DO NOTHING
静默跳过,导致「钱从备用金扣了、成本快照却没写成」。修法是迁移 109 隔离
legacy 主体 + 后端快照写失败必须抛错回滚(409),杜绝账实不符。

文档:现行 5 篇 + README 基线校准为 dev @ 43f402cf + 迁移 103–109,
修正已删除的旧迁移号,删除「人工支出登记 + 审批 + 报销」口径,
已作废 DDL 草案移入 docs/archive/2026-10-23/。

校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测两笔同订单项各自独立主体、余额扣减与快照金额一一对应;
本地库迁移 108/109 执行后数据量与改造前完全一致。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
2026-10-24 11:31:29 +08:00

23 KiB
Raw Blame History

成本中心与履约模型

状态:现行。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。 最后校准:2026-10-23 代码基线:dev @ 43f402cf + 迁移 103–109(已合并到 dev,未推送、未部署生产) 数据基线:本地生产快照克隆(数据截至 2026-10-14),已按新 schema 原地改造 业务口径以 财务与履约口径总纲-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;业务表只保存快照,历史成本永不回写。

一、迁移基线:12 个合并为 5 个(+ 1 个清理迁移)

原 dev 分支上,商品与成本中心用 12 个迁移(103–114,1,087 行)分批落地,字段反复摇摆。2026-10-23 重写为 5 个:

迁移 内容 行数
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(无载体列)+ 订单项收入固化 + 系统成本项 476
105_sales_daily_facts.sql sales_daily_facts(fulfillment_mode) 104
106_fund_account_no_readable.sql 备用金账户编号可读化(FA-<用户名>) 51
107_order_item_fulfillment_mode.sql order_items.fulfillment_mode 三列 48
108_drop_manual_expense_records.sql 下线人工支出登记单与审批报销状态机(删两张表) 66
109_isolate_legacy_manual_snapshots.sql 隔离历史人工快照的主体 id(改负数) 44

106 原名 106_manual_expense_records.sql,2026-10-23 因下线人工支出登记链而改为 106_fund_account_no_readable.sql;108 是配套的清理迁移。

关于迁移 109(重要坑):人工成本快照的主体从「人工支出登记单 id」改成「客服备用金支出单 id」(manual_expenses.id),两套 id 空间各自从 1 开始会撞车。历史孤儿快照保留原 subject_id 会与新建的 manual_expenses.id 撞唯一索引 uq_cost_snapshots_active_subject_item,导致 INSERT ... ON CONFLICT DO NOTHING 静默跳过——钱从备用金扣了、成本快照却没写成。迁移 109 把来源为旧登记链的历史快照主体改成负数,与正数的 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 快照;版本先为 draft 占位

工单报酬的三种快照:

  • 工单验收后按 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 表示历史孤儿快照(已由迁移 109 隔离),金额保留供毛利参考,不与新链路争主体 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-23 已执行)

生产尚未执行过 103–114,本地库需要从旧 schema 改到新 schema。改造脚本:scripts/rewrite-local-db-2026-10-23.sql(列级改造)+ 迁移 108(删人工支出登记两表)。

改造矩阵(列级 ALTER,不重建表、不动源表):

表 改造
product_catalog 删 carrier_type
cost_items cost_type + carrier_type → cost_model;auto_purchase 编码统一为 sys_affiliate_purchase
cost_snapshots 删 carrier_type、删未使用的 manual_case_id
cost_exceptions 删 carrier_type
sales_daily_facts carrier_type → fulfillment_mode(改名)
manual_expense_records / manual_expense_events 整表删除(迁移 108);成本快照主体改挂 manual_expenses.id
work_product_rules 删 legacy_cost_source
order_items 不动(履约事实、收入固化、商品指向全部保留)

执行顺序:

# 1. 备份(已完成)
#    backups/postgres/order_site_pre_rewrite_20261023.dump
# 2. 删旧迁移记录 + 列级改造
docker exec -i order_site-postgres-1 psql -U postgres -d order_site \
  -v ON_ERROR_STOP=1 < scripts/rewrite-local-db-2026-10-23.sql
# 3. 应用新迁移 103–109
npm run db:migrate
# 4. 重跑履约回填(幂等)
npm run fulfillment:backfill
# 5. 分窗口重算销售事实
npm run finance:sync -- --mode=facts --from=2026-07-10 --to=2026-08-08
# ...按 30 天窗口依次执行

改造后数据量核对(2026-10-23,迁移 108 执行后复验):商品 745、映射 4,426、成本项 3、成本版本 702、快照 158,764、异常 248,878、销售事实 14,798、备用金支出 2、订单项 316,585 —— 与改造前一致。manual_expense_records(1 条测试数据)与 manual_expense_events(2 条)随迁移 108 一并删除。

check-sql-guard.ts 对 103–114 段迁移放开字节校验(这一组在分支内整体重写);001–102 仍必须保持字节不变。


九、索引与性能

索引 用途
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

十、关联文档