From 45c9312e5d28aac2f27f9f31c60535d215efc0f1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 31 Oct 2026 18:11:37 +0800 Subject: [PATCH] =?UTF-8?q?docs(finance):=20=E6=96=B0=E5=A2=9E=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E4=B8=8E=E5=B1=A5=E7=BA=A6=20v2=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=8D=89=E6=A1=88=E4=B8=8E=E5=90=8C=E6=AD=A5=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=AF=B9=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 2 + .../2026-10-31-112-履约渠道拆分-迁移草案.sql | 410 ++++++++++++++++++ .../2026-10-31-同步链路重构-旧函数对照.md | 255 +++++++++++ 3 files changed, 667 insertions(+) create mode 100644 docs/drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql create mode 100644 docs/drafts/2026-10-31-同步链路重构-旧函数对照.md diff --git a/docs/README.md b/docs/README.md index 4a4f2928..4697f4da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | ## 六、厂商协议与外部资料 diff --git a/docs/drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql b/docs/drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql new file mode 100644 index 00000000..779bdef4 --- /dev/null +++ b/docs/drafts/2026-10-31-112-履约渠道拆分-迁移草案.sql @@ -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: 即渠道归属 +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 起点数据重建 + 期初建账 diff --git a/docs/drafts/2026-10-31-同步链路重构-旧函数对照.md b/docs/drafts/2026-10-31-同步链路重构-旧函数对照.md new file mode 100644 index 00000000..29854f03 --- /dev/null +++ b/docs/drafts/2026-10-31-同步链路重构-旧函数对照.md @@ -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 | 总账 | 借贷平衡 |