docs(finance): 新增财务与履约 v2 迁移草案与同步链路对照

This commit is contained in:
yml2213
2026-10-31 18:11:37 +08:00
parent 22400fbec6
commit 45c9312e5d
3 changed files with 667 additions and 0 deletions
+2
View File
@@ -159,6 +159,8 @@
| --- | --- | --- |
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填 | **已过时**:`carrier_type` 已从商品/成本/快照/异常表删除;只读查询里的口径结论已吸收进总纲 §二 |
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` | 已落地为 `103`–`105`;草案里的 `carrier_type` 已被 `cost_model` / `fulfillment_mode` 取代,仅作设计追溯 |
| [drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql](drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql) | 渠道/状态/来源拆列、`sku_channel_rules`、异常两级化、水位表、卡券成本项 | 已落地为 `apps/backend/src/db/migrations/112_fulfillment_channel_split.sql`;草案保留作设计追溯 |
| [drafts/2026-10-31-同步链路重构-旧函数对照.md](drafts/2026-10-31-同步链路重构-旧函数对照.md) | 16 个旧同步/回填入口 → 6 个新管道作业的对照与验收口径 | 已落地:三条管道服务 + 六个定时任务 + 后台「履约渠道」页 |
| [archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql](archive/2026-10-23/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | **已作废**:`manual_expense_records` / `manual_expense_events` 已随迁移 `104` 归并下线,人工成本改走「人工录单 → 扣备用金 → 写快照」;文件已移入 archive |
## 六、厂商协议与外部资料
@@ -0,0 +1,410 @@
-- 112_fulfillment_channel_split.sql —— 草案(已落地为 apps/backend/src/db/migrations/112_fulfillment_channel_split.sql)
--
-- 目标:把「渠道 / 状态 / 决策来源」从一列拆成三列,并把渠道决策从
-- 「有没有任务」反推改为「配置驱动」。
--
-- 生产前置:
-- · 生产迁移停在 111(2026-10-30 20:32);本迁移为 112,编号未被占用。
-- · 生产数据:orders 438,740 / order_items 438,750 / 10.1 GB。
-- · order_items 上任何全表 UPDATE 约 34s,超过 15s 语句超时——
-- 所以本迁移**只做 DDL 与小表种子**,438k 行回填交给
-- `scripts/backfill-fulfillment-channel.ts`(分批、幂等、可续跑)。
--
-- 本迁移不做的事:
-- · 不删除 fulfillment_mode(等代码切换完成后由 113 删除)
-- · 不清派生数据(由断代脚本执行)
-- · 不改历史订单(回填脚本按断代日归档)
--
-- 与旧模型的关系:
-- 旧:order_items.fulfillment_mode ∈ work_order / affiliate_dash /
-- legacy_platform / manual(四值混渠道与兜底)
-- 新:fulfillment_channel ∈ work_order / affiliate_dash /
-- third_party_voucher / manual(可为空)
-- fulfillment_status ∈ pending / assigned / archived
-- fulfillment_source ∈ auto_rule / auto_mapping / carrier_evidence /
-- manual_confirm / operator_assigned / cutover_archive
--
-- 迁移由 migrate.ts 包在单事务中执行,因此这里不使用 BEGIN/COMMIT。
-- ---------------------------------------------------------------------------
-- 1. 订单项:三个新列 + 锁定 + 决策人
-- ---------------------------------------------------------------------------
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS fulfillment_channel TEXT,
ADD COLUMN IF NOT EXISTS fulfillment_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS fulfillment_source TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS fulfillment_locked BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS fulfillment_decided_by BIGINT
REFERENCES admin_users(id) ON DELETE SET NULL;
-- 约束:渠道与状态正交,且 pending 不得带渠道、assigned 必须有渠道。
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_channel'
) THEN
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_channel;
END IF;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_channel
CHECK (
fulfillment_channel IS NULL
OR fulfillment_channel IN (
'work_order', 'affiliate_dash', 'third_party_voucher', 'manual'
)
);
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_status'
) THEN
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_status;
END IF;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_status
CHECK (fulfillment_status IN ('pending', 'assigned', 'archived'));
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_order_items_fulfillment_channel_status'
) THEN
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_channel_status;
END IF;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_channel_status
CHECK (
(fulfillment_status = 'pending' AND fulfillment_channel IS NULL)
OR (fulfillment_status = 'assigned' AND fulfillment_channel IS NOT NULL)
OR fulfillment_status = 'archived'
);
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_source'
) THEN
ALTER TABLE order_items DROP CONSTRAINT chk_order_items_fulfillment_source;
END IF;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_source
CHECK (
fulfillment_source IN (
'', 'auto_rule', 'auto_mapping', 'carrier_evidence',
'manual_confirm', 'operator_assigned', 'cutover_archive'
)
);
END $$;
COMMENT ON COLUMN order_items.fulfillment_channel IS
'履约渠道(新唯一真源):work_order 接单平台 / affiliate_dash 自动发货 / third_party_voucher 第三方卡券(仅统计)/ manual 人工;pending 时为空';
COMMENT ON COLUMN order_items.fulfillment_status IS
'渠道状态:pending 待分类 / assigned 已确定 / archived 断代归档';
COMMENT ON COLUMN order_items.fulfillment_source IS
'渠道决策来源:auto_rule 规则命中 / auto_mapping 映射命中 / carrier_evidence 历史载体证据 / manual_confirm 人工确认 / operator_assigned 运营指定 / cutover_archive 断代归档';
COMMENT ON COLUMN order_items.fulfillment_locked IS
'运营指定渠道后置 true:channel_assign 跳过该行,直到运营解锁';
COMMENT ON COLUMN order_items.fulfillment_decided_by IS
'最近一次渠道决策的后台用户;自动决策为空';
-- 旧列标记为废弃(113 删除)。保留是为了让过渡期代码仍可读历史值。
COMMENT ON COLUMN order_items.fulfillment_mode IS
'【已废弃,勿写入】旧履约方式四值枚举;读取请改用 fulfillment_channel + fulfillment_status。计划由 113 迁移删除';
-- ---------------------------------------------------------------------------
-- 2. 新索引:按状态与渠道取数,全部走部分索引
-- ---------------------------------------------------------------------------
-- 待分类队列:只扫 pending 行。
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending_id_v2
ON order_items (id)
WHERE fulfillment_status = 'pending';
-- 渠道维度的常规读路径(列表筛选、日报汇总)。
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_channel_status
ON order_items (fulfillment_channel, fulfillment_status)
INCLUDE (id)
WHERE fulfillment_status <> 'archived';
-- 商品归一:已定渠道但还没挂商品的行。
CREATE INDEX IF NOT EXISTS idx_order_items_unmapped_id
ON order_items (id)
WHERE fulfillment_status = 'assigned' AND canonical_product_id IS NULL;
-- 成本快照:已定渠道 + 已归一 + 参与核算的渠道。
CREATE INDEX IF NOT EXISTS idx_order_items_channel_product
ON order_items (fulfillment_channel, canonical_product_id)
INCLUDE (id)
WHERE fulfillment_status = 'assigned'
AND canonical_product_id IS NOT NULL;
COMMENT ON INDEX idx_order_items_fulfillment_pending_id_v2 IS
'待分类队列:pending 订单项部分索引(2026-10-31 渠道拆分)';
COMMENT ON INDEX idx_order_items_unmapped_id IS
'商品归一管道:已定渠道未归一部分索引';
COMMENT ON INDEX idx_order_items_channel_product IS
'成本快照管道:渠道 + 商品维度覆盖索引';
-- 注意:旧索引 idx_order_items_fulfillment_pending_id(108)与
-- idx_order_items_fulfillment_product(110)在 113 里删除;
-- 过渡期保留,避免旧代码路径失速。
-- ---------------------------------------------------------------------------
-- 3. 渠道规则表(L1):精确键规则,覆盖自动发货 / 人工 / 卡券 / 运营覆盖
-- ---------------------------------------------------------------------------
-- 设计说明:
-- · 接单平台(work_order)不走本表:它由接单模板 + 商品匹配引擎决定,
-- 规则引擎的结论由 channel_assign 直接写成 channel=work_order。
-- · 本表只处理「精确键」类渠道:affiliate_dash / manual /
-- third_party_voucher,以及运营对任意渠道的显式覆盖。
-- · provider / shop_id 为空串表示「不限」。
-- · 同一 SKU 命中多条同优先级但渠道不同的规则 → 不自动选,
-- 订单落 pending 并进 routing_conflict 队列。
CREATE TABLE IF NOT EXISTS sku_channel_rules (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL DEFAULT '',
shop_id TEXT NOT NULL DEFAULT '',
match_key_type TEXT NOT NULL DEFAULT 'sku_code',
match_key TEXT NOT NULL,
channel TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
source TEXT NOT NULL DEFAULT 'operator',
source_ref_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
note TEXT NOT NULL DEFAULT '',
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_key_type'
) THEN
ALTER TABLE sku_channel_rules
ADD CONSTRAINT chk_sku_channel_rules_key_type
CHECK (match_key_type IN ('sku_code', 'internal_sku', 'sku_name'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_channel'
) THEN
ALTER TABLE sku_channel_rules
ADD CONSTRAINT chk_sku_channel_rules_channel
CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_source'
) THEN
ALTER TABLE sku_channel_rules
ADD CONSTRAINT chk_sku_channel_rules_source
CHECK (source IN ('operator', 'affiliate', 'manual', 'voucher', 'template'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sku_channel_rules_key'
) THEN
ALTER TABLE sku_channel_rules
ADD CONSTRAINT chk_sku_channel_rules_key
CHECK (btrim(match_key) <> '');
END IF;
END $$;
-- 同一 (来源范围, 键类型, 键, 渠道) 只允许一条;运营覆盖用更高优先级而非重复行。
CREATE UNIQUE INDEX IF NOT EXISTS uq_sku_channel_rules_key_channel
ON sku_channel_rules (provider, shop_id, match_key_type, match_key, channel);
-- 解析路径:provider/shop 精确优先,空串表示不限;按优先级升序取第一条。
CREATE INDEX IF NOT EXISTS idx_sku_channel_rules_lookup
ON sku_channel_rules (match_key_type, match_key, enabled, priority)
INCLUDE (provider, shop_id, channel);
COMMENT ON TABLE sku_channel_rules IS
'L1 渠道路由规则:精确键(SKU / 内部 SKU / SKU 名称)→ 渠道;接单平台由模板引擎决定,不在此表';
COMMENT ON COLUMN sku_channel_rules.priority IS
'越小越优先:运营指定 10 / 自动发货 60 / 人工 70;同优先级多渠道=冲突';
COMMENT ON COLUMN sku_channel_rules.source_ref_id IS
'来源对象 id:affiliate=product_catalog.id / manual=manual_fulfillment_configs.id / 其余为空';
-- ---------------------------------------------------------------------------
-- 4. 从现有配置生成初始规则(小表,836 行级,可在迁移内完成)
-- ---------------------------------------------------------------------------
-- 4.1 自动发货:商品目录里的 aff:<sku> 即渠道归属
INSERT INTO sku_channel_rules (
provider, shop_id, match_key_type, match_key, channel,
priority, source, source_ref_id, enabled, note, created_at, updated_at
)
SELECT
'', '',
'sku_code',
btrim(substr(p.canonical_sku, 5)),
'affiliate_dash',
60,
'affiliate',
p.id,
TRUE,
'由商品目录 aff: 前缀生成',
NOW(), NOW()
FROM product_catalog p
WHERE p.canonical_sku LIKE 'aff:%'
AND p.status = 'active'
AND btrim(substr(p.canonical_sku, 5)) <> ''
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING;
-- 4.2 人工:已配置的人工 SKU(生产 43 条,按内部 SKU 精确匹配)
INSERT INTO sku_channel_rules (
provider, shop_id, match_key_type, match_key, channel,
priority, source, source_ref_id, enabled, note, created_at, updated_at
)
SELECT
'', '',
'internal_sku',
btrim(cfg.sku_key),
'manual',
70,
'manual',
cfg.id,
cfg.enabled,
'由人工履约配置生成',
NOW(), NOW()
FROM manual_fulfillment_configs cfg
WHERE btrim(cfg.sku_key) <> ''
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING;
-- 4.3 第三方卡券:不预置,运营在「渠道规则」页按 SKU 添加(source='voucher')。
-- ---------------------------------------------------------------------------
-- 5. 成本项:第三方卡券采购成本(仅用于统计,无载体)
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_cost_model'
) THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_cost_model
CHECK (cost_model IN (
'affiliate_purchase', 'work_order_reward', 'manual_actual',
'third_party_voucher_cost', 'platform_fee', 'payment_fee',
'after_sales_loss', 'other'
));
ELSE
ALTER TABLE cost_items DROP CONSTRAINT chk_cost_items_cost_model;
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_cost_model
CHECK (cost_model IN (
'affiliate_purchase', 'work_order_reward', 'manual_actual',
'third_party_voucher_cost', 'platform_fee', 'payment_fee',
'after_sales_loss', 'other'
));
END IF;
END $$;
INSERT INTO cost_items (
code, name, cost_model, pricing_mode, unit_name, currency,
status, note, created_at, updated_at
)
VALUES (
'sys_third_party_voucher_cost',
'第三方卡券采购成本',
'third_party_voucher_cost',
'per_item',
'件',
'CNY',
'active',
'第三方卡券渠道:仅统计收入与采购成本,不创建履约载体、不展示给买家',
NOW(), NOW()
)
ON CONFLICT (code) DO NOTHING;
-- 不预置 0 元占位版本:0 元会被误当成真实成本。
-- 运营在成本中心按商品配置采购价后再生成版本。
-- ---------------------------------------------------------------------------
-- 6. 异常表:从「一单一条」改为「SKU 级问题头 + 明细」
-- ---------------------------------------------------------------------------
-- 先摘掉旧的逐单唯一约束(subject_type, subject_id, reason)。
DROP INDEX IF EXISTS uq_cost_exceptions_subject_reason;
ALTER TABLE cost_exceptions
ADD COLUMN IF NOT EXISTS scope_key TEXT,
ADD COLUMN IF NOT EXISTS item_count INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS first_seen_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
-- 存量行(断代脚本会整体清空)给一个可用的 scope_key,避免 NOT NULL 失败。
UPDATE cost_exceptions
SET scope_key = COALESCE(
NULLIF(scope_key, ''),
reason || '|' || COALESCE(order_item_id::text, subject_id::text)
),
first_seen_at = COALESCE(first_seen_at, created_at),
last_seen_at = COALESCE(last_seen_at, created_at)
WHERE scope_key IS NULL OR first_seen_at IS NULL OR last_seen_at IS NULL;
-- 只有 open / ignored 的问题头保持唯一;resolved 允许重复出现(历史留痕)。
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_scope_open
ON cost_exceptions (scope_key)
WHERE status <> 'resolved';
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_reason_status
ON cost_exceptions (reason, status, last_seen_at DESC);
CREATE TABLE IF NOT EXISTS cost_exception_items (
exception_id BIGINT NOT NULL REFERENCES cost_exceptions(id) ON DELETE CASCADE,
order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (exception_id, order_item_id)
);
CREATE INDEX IF NOT EXISTS idx_cost_exception_items_item
ON cost_exception_items (order_item_id);
COMMENT ON TABLE cost_exceptions IS
'成本/分类问题队列(SKU 级):scope_key 相同的订单项聚合为一条问题,明细见 cost_exception_items';
COMMENT ON COLUMN cost_exceptions.reason IS
'classification_missing 待分类 / routing_conflict 规则冲突 / product_mapping_missing 未归一 / cost_missing 成本缺失 / manual_config_missing 人工 SKU 未配置';
COMMENT ON COLUMN cost_exceptions.item_count IS
'当前牵涉的订单项数(明细去重后的计数)';
-- ---------------------------------------------------------------------------
-- 7. 管道水位表
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS finance_watermarks (
pipeline TEXT PRIMARY KEY,
last_item_id BIGINT NOT NULL DEFAULT 0,
last_stat_date DATE,
last_success_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO finance_watermarks (pipeline, last_item_id, last_stat_date, last_success_at)
VALUES
('channel_assign', 0, NULL, NULL),
('carrier_dispatch', 0, NULL, NULL),
('product_normalize', 0, NULL, NULL),
('cost_snapshot', 0, NULL, NULL),
('sales_facts', 0, NULL, NULL),
('ledger_post', 0, NULL, NULL)
ON CONFLICT (pipeline) DO NOTHING;
COMMENT ON TABLE finance_watermarks IS
'各同步管道水位:last_item_id 用于订单项级增量,last_stat_date 用于按日聚合';
-- ---------------------------------------------------------------------------
-- 8. 人工 SKU 登记表降级为派生数据
-- ---------------------------------------------------------------------------
COMMENT ON TABLE manual_sku_registry IS
'【派生数据】人工 SKU 扫描结果,可由 pending/manual 订单项重新生成;真源是 manual_fulfillment_configs';
-- ---------------------------------------------------------------------------
-- 9. 完成
-- ---------------------------------------------------------------------------
-- 迁移后仍需执行(按顺序):
-- 1. scripts/backfill-fulfillment-channel.ts —— 438k 行分批回填 channel/status
-- 2. scripts/finance-cutover-v2.ts —— 断代归档 + 派生数据清零
-- 3. scripts/rebuild-after-cutover.ts —— 10-31 起点数据重建 + 期初建账
@@ -0,0 +1,255 @@
# 同步链路重构:16 个旧入口 → 6 个新作业
> 状态:**已实施**(2026-10-31):三条管道服务、六个定时任务、后台「履约渠道」页、断代与重建脚本均已落地
> 日期:2026-10-31
> 关联迁移草案:[2026-10-31-112-履约渠道拆分-迁移草案.sql](2026-10-31-112-履约渠道拆分-迁移草案.sql)
> 生产基线:迁移 111,orders 438,740 / order_items 438,750,库 10.1 GB
---
## 一、为什么必须重构
现在有 **16 个会写库的同步/回填入口**,它们全部直接改 `order_items` 这张"共享黑板"。三个结构性后果:
1. **同一件事多条路径**:商品归一 4 条、成本快照 3 条、事实重建 2 个入口
2. **定时任务跨层写**:一个 job 同时写履约、商品、成本、事实、总账,任一步失败就半途
3. **下游覆写上游**:`cost_finance_sync` 把 `fulfillment_reason` 覆盖成自己的名字——生产上 **127,250 条**真实判定原因被冲掉
目标不是"优化这些函数",而是**拆成三条单行道**:下游只读上游,永不回写。
---
## 二、旧入口全量清单
| # | 函数 | 所在文件 | 触发 | 写入 |
|---|---|---|---|---|
| 1 | `runCostFinanceSyncJob` | `services/scheduler/cost-finance-sync-job.ts` | 定时 | 串 2/3/4/5/6/15/16 |
| 2 | `reconcilePendingFulfillmentModes` | `services/fulfillment/fulfillment-mode-service.ts` | 定时/手工/脚本 | `fulfillment_mode` |
| 3 | `normalizeManualProducts` | `services/cost-center/manual-product-normalization-service.ts` | 定时 | 建 `man:` 商品 + `canonical_product_id` |
| 4 | `rematchManualFulfillments` | `services/cost-center/manual-fulfillment-config-service.ts` | 定时/按钮 | `fulfillment_config_id` |
| 5 | `syncEstimatedOrderItemCostSnapshots` | `services/cost-center/cost-snapshot-sync-service.ts` | 定时 | 全渠道预估快照 |
| 6 | `syncActualWorkOrderCostSnapshots` | 同上 | 定时 | 工单实际快照 |
| 7 | `syncAdminAffiliateDashProducts` | `services/cost-center/affiliate-product-service.ts` | 手工 | 建 `aff:` 商品 + 映射 |
| 8 | `backfillAdminAffiliateDashSkus` | 同上 | 手工 | 回填订单项商品 |
| 9 | `backfillAdminWorkOrderProductSkus` | `services/cost-center/work-order-product-service.ts` | 手工 | 建 `tpl:/wo:` 商品 + 映射 + 回填 |
| 10 | `backfillAdminProductMappings` | `services/cost-center/product-catalog-service.ts` | 手工 | 通用映射回填 |
| 11 | `applyAdminTemplateProductBootstrap` | `services/cost-center/template-bootstrap-service.ts` | 手工(一次性) | 建模板商品 + 映射 + 成本版本 |
| 12 | `syncAdminWorkOrderTemplateCostVersions` | `services/cost-center/work-order-product-service.ts` | 手工 | 规则奖励价 → 成本版本 |
| 13 | `syncAdminAffiliateDashCostSnapshots` | `services/cost-center/affiliate-product-service.ts` | 手工 | 再调 5 + 重算事实 |
| 14 | `rebuildFactsForRange` | 同上 | 被 8/9/13 调用 | 销售事实 |
| 15 | `rebuildSalesDailyFacts` | `services/finance/sales-facts-service.ts` | 定时/手工 | 销售事实(与 14 重复入口) |
| 16 | `syncFinanceLedgerFromFacts` | `services/finance/finance-ledger-service.ts` | 定时 | 总账分录 |
| — | `backfillAdminNormalizedQuantities` | `product-catalog-service.ts` | 手工 | 归一数量 |
| — | `scripts/sync-cost-finance.ts` | 脚本 | 手工 | 同 1 |
| — | `scripts/backfill-fulfillment-mode.ts` | 脚本 | 手工 | 同 2 |
---
## 三、新架构:三条管道 + 一个巡检
```
管道 A 渠道判定:pending → assigned(只写 channel/status/source)
管道 B 商品归一:null → canonical_product_id(只写商品列)
管道 C 成本 → 事实 → 总账(只读 A/B,只写自己的表)
巡检 只读比对,只告警不修复
```
**四条铁律**
1. 每条管道只写自己负责的列/表
2. 下游只读上游,禁止回写
3. 每个作业幂等,重跑结果一致
4. 每个作业有水位,失败只影响自己
---
## 四、六个作业定义
| 作业 | 扫描条件 | 写入 | 水位 | 建议频率 |
|---|---|---|---|---|
| `channel_assign` | `status='pending' AND fulfillment_locked=false` | `channel/status/source/decided_at` | `last_item_id` | 1 分钟 |
| `carrier_dispatch` | `status='assigned' AND channel<>'third_party_voucher'` 且无载体 | 工单 / 自动发货任务 / 人工待履约 | `last_item_id` | 1 分钟 |
| `product_normalize` | `status='assigned' AND canonical_product_id IS NULL` | `canonical_product_id` | `last_item_id` | 5 分钟 |
| `cost_snapshot` | `status='assigned' AND canonical_product_id IS NOT NULL` 且缺快照 | `cost_snapshots`(含 actual 升级) | `last_item_id` | 5 分钟 |
| `sales_facts` | 有变动的业务日期 | `sales_daily_facts` | `last_stat_date` | 10 分钟 |
| `ledger_post` | 事实变动且期间开放 | `finance_journal_*` | 期间 | 10 分钟 |
| `consistency_check` | 全量只读 | 只写告警日志 | — | 1 小时 |
### 4.1 `channel_assign` 的判定顺序
```
1. 已锁定 → 跳过
2. 精确规则(sku_channel_rules,按优先级升序)
· 先按 provider+shop 精确匹配,再按 provider='' 或 shop='' 的通配
· 同优先级命中多个渠道 → routing_conflict,落 pending
3. 接单模板匹配(复用现有商品匹配引擎)
· 命中 → work_order
4. 都未命中 → pending(不兜底人工)
```
> 接单平台不进 `sku_channel_rules`:它的匹配依赖模板文本条件(763 条规则里 757 条 `sku_code` 为空、match_type 全是 `contains`),交给既有匹配引擎更准确。
### 4.2 `carrier_dispatch` 的渠道差异
| 渠道 | 动作 |
|---|---|
| `affiliate_dash` | 创建自动发货任务(需要 L2 配置,缺失进「履约配置缺失」) |
| `work_order` | 创建工单(模板已在匹配时确认存在) |
| `manual` | 挂 `fulfillment_config_id`(配置缺失进「人工 SKU 未配置」) |
| `third_party_voucher` | **不做任何事**(仅统计) |
### 4.3 `product_normalize`
```
resolveCanonicalProduct(provider, shop_id, sku_code, sku_name)
① 精确映射:channel_product_mappings (sku_code / sku_code_name)
② 系列映射:(sku_code_series / sku_name_series)
③ 都未命中 → NULL(不建商品,进 product_mapping_missing)
```
**禁止自动建商品**。商品目录只允许两个来源:运营手工新建、断代 bootstrap 一次性生成。
### 4.4 `cost_snapshot`
统一入口,按 `(channel, canonical_product_id | work_product_rule_id, 业务日期)` 查 active 版本:
| 渠道 | 成本来源 | 快照阶段 |
|---|---|---|
| `affiliate_dash` | 商品采购价版本 | estimated |
| `work_order` | 规则奖励价版本;工单结算后升级 | estimated → actual |
| `manual` | 确认履约时录入 | actual |
| `third_party_voucher` | 商品采购价版本 | estimated |
版本缺失 → 写 0 + `cost_missing`,**绝不把 0 当真实成本**。
### 4.5 `sales_facts` / `ledger_post`
- 事实按业务日期重算(先删后插),已关账期间跳过
- 事实必须包含 pending 收入的「未分类桶」,不能丢
- 总账只从事实生成,事实变了补调整分录
---
## 五、旧 → 新 对照
| 旧 | 新归属 | 处置 |
|---|---|---|
| 1 `runCostFinanceSyncJob` | 拆成 6 个作业 | **删除** |
| 2 `reconcilePendingFulfillmentModes` | `channel_assign` | 重写,只写新三列 |
| 3 `normalizeManualProducts` | `product_normalize` | 删除"建商品"分支,保留匹配 |
| 4 `rematchManualFulfillments` | `carrier_dispatch` | 合并进人工载体挂载 |
| 5 `syncEstimatedOrderItemCostSnapshots` | `cost_snapshot` | 重写为统一入口 |
| 6 `syncActualWorkOrderCostSnapshots` | `cost_snapshot` | 合并为 actual 升级分支 |
| 7 `syncAdminAffiliateDashProducts` | 一次性 bootstrap | **降级为迁移脚本** |
| 8 `backfillAdminAffiliateDashSkus` | `product_normalize` | **删除** |
| 9 `backfillAdminWorkOrderProductSkus` | `product_normalize` + bootstrap | **删除** |
| 10 `backfillAdminProductMappings` | `product_normalize` | **删除** |
| 11 `applyAdminTemplateProductBootstrap` | 一次性迁移 | **保留为脚本,不进日常菜单** |
| 12 `syncAdminWorkOrderTemplateCostVersions` | 模板事件 + 夜间巡检 | 改事件驱动,按钮降级为修复 |
| 13 `syncAdminAffiliateDashCostSnapshots` | `cost_snapshot` | **删除** |
| 14 `rebuildFactsForRange` | `sales_facts` | **删除**(与 15 合并) |
| 15 `rebuildSalesDailyFacts` | `sales_facts` | 保留为内部实现 |
| 16 `syncFinanceLedgerFromFacts` | `ledger_post` | 保留为内部实现 |
| `backfillAdminNormalizedQuantities` | 一次性回填 | 保留脚本 |
| `scripts/sync-cost-finance.ts` | — | 删除 |
**净结果**:日常菜单从 11 个按钮降到 2 个(渠道规则、成本配置),其余全自动。
---
## 六、接单模板同步的重设计
### 现状
- 模板在「接单平台」配置,成本中心靠**手动按钮**把奖励价写成成本版本
- 模板改价后成本版本不自动跟进
- 生产 763 条规则全部 `enabled`、757 条 `sku_code` 为空、`match_type` 全为 `contains`
### 新设计
| 时机 | 动作 | 事务边界 |
|---|---|---|
| 模板新增/改价 | 写一条规则级成本版本,旧版本 `retired` | 与模板保存同事务 |
| 模板停用 | 成本版本 `retired` | 同上 |
| 每天巡检 | 比对 763 条规则价格 vs active 版本,缺失或金额不一致 → 补版本 + 告警 | 独立作业 |
规则:
- `effective_from` = 模板变更时间(不是同步时间)
- 同日多次改价只留最后一版
- 模板停用不影响历史快照
---
## 七、卡券渠道的同步边界
| 环节 | 是否参与 |
|---|---|
| `channel_assign` | ✅ 通过 `sku_channel_rules`(source=`voucher`) |
| `carrier_dispatch` | ❌ 跳过 |
| `product_normalize` | ✅ 与其他渠道一致 |
| `cost_snapshot` | ✅ 采购成本 |
| 事实/总账 | ✅ 正常计入 |
副作用:**无任务、无领取页、无通知、无人工队列**。
---
## 八、人工 43 条配置的保留
| 项 | 处理 |
|---|---|
| `manual_fulfillment_configs` 43 行 | 原样保留(`sku_key` / `platform_key` / `credit_quantity` / `cost_mode` 不动) |
| 其中 `canonical_product_id` | 保留 |
| `sku_channel_rules` | 由这 43 行自动生成 `channel=manual` 规则 |
| `manual_sku_registry` 573 行 | 标记为派生,断代后重新生成 |
生产现状:近 7 天有 277 个 manual SKU 在跑,只配置了 29 个——缺口进「人工 SKU 未配置」队列。
---
## 九、定时任务清单(`scheduled_jobs`)
生产当前只注册了 3 个任务,**没有财务同步**。新清单:
| id | type | 频率 |
|---|---|---|
| `work-order-timeout` | 既有 | 60s |
| `deposit-unfreeze` | 既有 | 60s |
| `cloudtentacles-health` | 既有 | 300s |
| `channel-assign` | 新增 | 60s |
| `carrier-dispatch` | 新增 | 60s |
| `product-normalize` | 新增 | 300s |
| `cost-snapshot` | 新增 | 300s |
| `sales-facts` | 新增 | 600s |
| `ledger-post` | 新增 | 600s |
| `consistency-check` | 新增 | 3600s |
---
## 十、迁移脚本清单
| 脚本 | 作用 | 幂等 |
|---|---|---|
| `scripts/backfill-fulfillment-channel.ts` | 438k 行分批回填三列 | ✅ 可续跑 |
| `scripts/bootstrap-channel-rules.ts` | 从 aff 商品 / 人工配置生成规则 | ✅ |
| `scripts/finance-cutover-v2.ts` | 断代归档 + 派生数据清零 | 一次性(有 cutover_meta 护栏) |
| `scripts/rebuild-after-cutover.ts` | 10-31 起点重建 + 期初建账 | ✅ 可重放 |
| `scripts/bootstrap-template-products.ts` | 模板商品一次性生成(原 11 号) | ✅ |
---
## 十一、验收
| # | 检查 | 期望 |
|---|---|---|
| 1 | 跑完 `channel_assign` | `fulfillment_reason` 零变更 |
| 2 | 跑完 `cost_snapshot` | 渠道字段零变更 |
| 3 | 跑完 `sales_facts` | 订单项字段零变更 |
| 4 | 同一订单项连跑三次 | 结果完全一致 |
| 5 | 未配置 SKU 新单 | pending,且不产生成本异常 |
| 6 | 卡券渠道订单 | 无 task / 无工单 / 无 claim token |
| 7 | 43 条人工配置 | 数量与字段完全一致 |
| 8 | 763 条模板规则 | 全有 active 成本版本 |
| 9 | 自动发货订单项 | 100% 挂上 `aff:` 商品 |
| 10 | 事实合计 | = 订单收入(含未分类桶) |
| 11 | 总账 | 借贷平衡 |