diff --git a/apps/backend/src/db/migrations/112_fulfillment_channel_split.sql b/apps/backend/src/db/migrations/112_fulfillment_channel_split.sql new file mode 100644 index 00000000..86ba3078 --- /dev/null +++ b/apps/backend/src/db/migrations/112_fulfillment_channel_split.sql @@ -0,0 +1,431 @@ +-- 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. 销售事实:允许新渠道与「未分类」桶 +-- --------------------------------------------------------------------------- + +-- 销售事实继续复用 fulfillment_mode 列承载渠道值(113 再改列名), +-- 但必须允许第三方卡券、未分类与归档状态。 +ALTER TABLE sales_daily_facts + DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode; + +ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode + CHECK ( + fulfillment_mode IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', + 'manual', 'unclassified', 'archived' + ) + ); + +COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS + '销售事实维度(渠道):work_order / affiliate_dash / third_party_voucher / manual / unclassified 未分类 / archived 归档'; + +-- --------------------------------------------------------------------------- +-- 9. 人工 SKU 登记表降级为派生数据 +-- --------------------------------------------------------------------------- + +COMMENT ON TABLE manual_sku_registry IS + '【派生数据】人工 SKU 扫描结果,可由 pending/manual 订单项重新生成;真源是 manual_fulfillment_configs'; + +-- --------------------------------------------------------------------------- +-- 10. 完成 +-- --------------------------------------------------------------------------- +-- 迁移后仍需执行(按顺序): +-- 1. scripts/backfill-fulfillment-channel.ts —— 438k 行分批回填 channel/status +-- 2. scripts/finance-cutover-v2.ts —— 断代归档 + 派生数据清零 +-- 3. scripts/rebuild-after-cutover.ts —— 10-31 起点数据重建 + 期初建账