diff --git a/apps/backend/src/db/migrations/147_internal_sku_product_routing.sql b/apps/backend/src/db/migrations/147_internal_sku_product_routing.sql new file mode 100644 index 00000000..15c163bd --- /dev/null +++ b/apps/backend/src/db/migrations/147_internal_sku_product_routing.sql @@ -0,0 +1,1335 @@ +-- 147_internal_sku_product_routing.sql +-- +-- 内部 SKU、标准商品与履约路由硬切: +-- 1. 建立 internal_skus / sku_channel_routes; +-- 2. product_catalog 固化为 source_kind + internal_sku_id; +-- 3. 完成旧路由停写、投影切换与历史兼容面下线; +-- 4. 收紧 source_kind 允许值。 +-- +-- 合并自未上生产的 154–162、168。 + +-- --------------------------------------------------------------------------- +-- 内部 SKU 身份 +-- --------------------------------------------------------------------------- +-- 统一内部 SKU 身份与履约路由: +-- 1. internal_skus 成为内部 SKU 的稳定身份; +-- 2. sku_channel_routes 成为主渠道/兜底渠道的唯一结构化表达; +-- 3. 兼容期由旧 internal_sku_channels 通过触发器同步; +-- 4. product_catalog 增加 source_kind 与 internal_sku_id,不再依赖前缀判断业务来源。 + +CREATE TABLE internal_skus ( + id BIGSERIAL PRIMARY KEY, + internal_sku_key TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + merged_into_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT internal_skus_key_not_blank CHECK (BTRIM(internal_sku_key) <> ''), + CONSTRAINT internal_skus_status_check CHECK (status IN ('active', 'disabled', 'merged')) +); + +CREATE UNIQUE INDEX uq_internal_skus_key + ON internal_skus(internal_sku_key); + +COMMENT ON TABLE internal_skus IS + '中文内部 SKU 稳定身份;所有路由、配置和商品关联最终通过 internal_sku_id 引用'; +COMMENT ON COLUMN internal_skus.internal_sku_key IS + 'NFKC、去空白、转小写后的稳定键'; + +WITH source_rows AS ( + SELECT internal_sku AS raw_value, updated_at FROM internal_sku_channels + UNION ALL + SELECT internal_sku AS raw_value, updated_at FROM internal_sku_mappings + UNION ALL + SELECT sku_key AS raw_value, updated_at FROM manual_fulfillment_configs + UNION ALL + SELECT sku_key AS raw_value, updated_at FROM manual_sku_registry + UNION ALL + SELECT canonical_name AS raw_value, updated_at FROM product_catalog +), +normalized AS ( + SELECT + canonicalize_internal_sku(raw_value) AS internal_sku_key, + BTRIM(raw_value) AS display_name, + updated_at + FROM source_rows + WHERE canonicalize_internal_sku(raw_value) <> '' +), +picked AS ( + SELECT DISTINCT ON (internal_sku_key) + internal_sku_key, + display_name, + updated_at + FROM normalized + ORDER BY internal_sku_key, updated_at DESC NULLS LAST, display_name +) +INSERT INTO internal_skus ( + internal_sku_key, + display_name, + status, + created_at, + updated_at +) +SELECT + internal_sku_key, + display_name, + 'active', + COALESCE(updated_at, NOW()), + COALESCE(updated_at, NOW()) +FROM picked +ON CONFLICT (internal_sku_key) DO UPDATE SET + display_name = CASE + WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name + ELSE internal_skus.display_name + END, + updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at); + +ALTER TABLE internal_sku_mappings + ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL; +ALTER TABLE manual_fulfillment_configs + ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL; +ALTER TABLE product_catalog + ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL, + ADD COLUMN source_kind TEXT; + +UPDATE internal_sku_mappings mapping +SET internal_sku_id = sku.id +FROM internal_skus sku +WHERE sku.internal_sku_key = canonicalize_internal_sku(mapping.internal_sku); + +UPDATE manual_fulfillment_configs config +SET internal_sku_id = sku.id +FROM internal_skus sku +WHERE sku.internal_sku_key = canonicalize_internal_sku(config.sku_key); + +UPDATE product_catalog product +SET source_kind = CASE + WHEN product.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN product.canonical_sku LIKE 'tpl:%' OR product.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN product.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN product.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE 'unclassified' +END; + +UPDATE product_catalog product +SET internal_sku_id = sku.id +FROM internal_skus sku +WHERE sku.internal_sku_key = canonicalize_internal_sku( + CASE + WHEN product.canonical_sku LIKE 'man:%' THEN substring(product.canonical_sku from 5) + WHEN product.canonical_sku LIKE 'tpv:%' THEN substring(product.canonical_sku from 5) + ELSE NULL + END +); + +ALTER TABLE product_catalog + ALTER COLUMN source_kind SET DEFAULT 'unclassified', + ALTER COLUMN source_kind SET NOT NULL; + +CREATE INDEX idx_product_catalog_internal_sku + ON product_catalog(internal_sku_id) + WHERE internal_sku_id IS NOT NULL; +CREATE INDEX idx_product_catalog_source_kind + ON product_catalog(source_kind, status); +CREATE INDEX idx_internal_sku_mappings_internal_sku + ON internal_sku_mappings(internal_sku_id) + WHERE internal_sku_id IS NOT NULL; +CREATE INDEX idx_manual_configs_internal_sku + ON manual_fulfillment_configs(internal_sku_id) + WHERE internal_sku_id IS NOT NULL; + +CREATE TABLE sku_channel_routes ( + id BIGSERIAL PRIMARY KEY, + internal_sku_id BIGINT NOT NULL REFERENCES internal_skus(id) ON DELETE CASCADE, + channel TEXT NOT NULL, + route_role TEXT NOT NULL DEFAULT 'fallback', + priority INTEGER NOT NULL DEFAULT 100, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL, + carrier_config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + source TEXT NOT NULL DEFAULT 'legacy_sync', + 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(), + CONSTRAINT sku_channel_routes_channel_check + CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account')), + CONSTRAINT sku_channel_routes_role_check + CHECK (route_role IN ('primary', 'fallback')), + CONSTRAINT sku_channel_routes_priority_check + CHECK (priority >= 0) +); + +CREATE UNIQUE INDEX uq_sku_channel_routes_channel + ON sku_channel_routes(internal_sku_id, channel); +CREATE UNIQUE INDEX uq_sku_channel_routes_primary + ON sku_channel_routes(internal_sku_id) + WHERE enabled = TRUE AND route_role = 'primary'; +CREATE INDEX idx_sku_channel_routes_lookup + ON sku_channel_routes(channel, enabled, priority) + WHERE enabled = TRUE; + +COMMENT ON TABLE sku_channel_routes IS + '内部 SKU 履约路由:一个主渠道加若干显式兜底渠道'; +COMMENT ON COLUMN sku_channel_routes.carrier_config_json IS + '渠道载体参数,例如 workRuleKey / affiliateSku / manualConfigId / voucherSku'; + +WITH legacy_rows AS ( + SELECT + sku.id AS internal_sku_id, + channel.channel, + channel.priority, + channel.is_fallback, + channel.enabled, + channel.canonical_product_id, + channel.config_json, + channel.source, + channel.note, + channel.created_by_admin_user_id, + channel.created_at, + channel.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY sku.id, channel.channel + ORDER BY channel.is_fallback, channel.priority, channel.id + ) AS channel_rank + FROM internal_sku_channels channel + JOIN internal_skus sku + ON sku.internal_sku_key = canonicalize_internal_sku(channel.internal_sku) + WHERE channel.enabled = TRUE +), +deduplicated AS ( + SELECT * FROM legacy_rows WHERE channel_rank = 1 +), +ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY internal_sku_id + ORDER BY is_fallback, priority, channel + ) AS overall_rank + FROM deduplicated +) +INSERT INTO sku_channel_routes ( + internal_sku_id, + channel, + route_role, + priority, + enabled, + canonical_product_id, + carrier_config_json, + source, + note, + created_by_admin_user_id, + created_at, + updated_at +) +SELECT + internal_sku_id, + channel, + CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END, + CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END, + TRUE, + canonical_product_id, + COALESCE(config_json, '{}'::jsonb), + COALESCE(NULLIF(source, ''), 'legacy_sync'), + COALESCE(note, ''), + created_by_admin_user_id, + COALESCE(created_at, NOW()), + COALESCE(updated_at, NOW()) +FROM ranked +ON CONFLICT (internal_sku_id, channel) DO NOTHING; + +CREATE OR REPLACE FUNCTION ensure_canonical_internal_sku(p_raw TEXT) +RETURNS BIGINT +LANGUAGE plpgsql +AS $$ +DECLARE + v_key TEXT := canonicalize_internal_sku(p_raw); + v_display_name TEXT := BTRIM(COALESCE(p_raw, '')); + v_id BIGINT; +BEGIN + IF v_key = '' THEN + RETURN NULL; + END IF; + + INSERT INTO internal_skus (internal_sku_key, display_name, status, created_at, updated_at) + VALUES (v_key, v_display_name, 'active', NOW(), NOW()) + ON CONFLICT (internal_sku_key) DO UPDATE SET + display_name = CASE + WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name + ELSE internal_skus.display_name + END, + updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at) + RETURNING id INTO v_id; + + RETURN v_id; +END; +$$; + +CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_sku_id BIGINT; +BEGIN + SELECT id INTO v_sku_id + FROM internal_skus + WHERE internal_sku_key = canonicalize_internal_sku(p_raw) + LIMIT 1; + + IF v_sku_id IS NULL THEN + RETURN; + END IF; + + DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id; + + WITH legacy_rows AS ( + SELECT + channel.channel, + channel.priority, + channel.is_fallback, + channel.canonical_product_id, + channel.config_json, + channel.source, + channel.note, + channel.created_by_admin_user_id, + channel.created_at, + channel.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY channel.channel + ORDER BY channel.is_fallback, channel.priority, channel.id + ) AS channel_rank + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) + = canonicalize_internal_sku(p_raw) + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + ORDER BY is_fallback, priority, channel + ) AS overall_rank + FROM legacy_rows + WHERE channel_rank = 1 + ) + INSERT INTO sku_channel_routes ( + internal_sku_id, + channel, + route_role, + priority, + enabled, + canonical_product_id, + carrier_config_json, + source, + note, + created_by_admin_user_id, + created_at, + updated_at + ) + SELECT + v_sku_id, + channel, + CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END, + CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END, + TRUE, + canonical_product_id, + COALESCE(config_json, '{}'::jsonb), + COALESCE(NULLIF(source, ''), 'legacy_sync'), + COALESCE(note, ''), + created_by_admin_user_id, + COALESCE(created_at, NOW()), + COALESCE(updated_at, NOW()) + FROM ranked; +END; +$$; + +CREATE OR REPLACE FUNCTION sync_sku_channel_routes_trigger() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP <> 'INSERT' THEN + PERFORM sync_sku_channel_routes_for_key(OLD.internal_sku); + END IF; + IF TG_OP <> 'DELETE' THEN + PERFORM ensure_canonical_internal_sku(NEW.internal_sku); + PERFORM sync_sku_channel_routes_for_key(NEW.internal_sku); + END IF; + RETURN COALESCE(NEW, OLD); +END; +$$; + +CREATE TRIGGER trg_sync_sku_channel_routes +AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels +FOR EACH ROW +EXECUTE FUNCTION sync_sku_channel_routes_trigger(); + +CREATE OR REPLACE FUNCTION set_internal_sku_id_from_text() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.internal_sku); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_internal_sku_mapping_identity +BEFORE INSERT OR UPDATE OF internal_sku ON internal_sku_mappings +FOR EACH ROW +EXECUTE FUNCTION set_internal_sku_id_from_text(); + +CREATE OR REPLACE FUNCTION set_manual_config_internal_sku_id() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.sku_key); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_manual_config_internal_sku_identity +BEFORE INSERT OR UPDATE OF sku_key ON manual_fulfillment_configs +FOR EACH ROW +EXECUTE FUNCTION set_manual_config_internal_sku_id(); + +CREATE OR REPLACE FUNCTION normalize_product_identity() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.source_kind := COALESCE( + NULLIF(NEW.source_kind, ''), + CASE + WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE 'unclassified' + END + ); + + IF NEW.internal_sku_id IS NULL THEN + SELECT sku.id INTO NEW.internal_sku_id + FROM internal_skus sku + WHERE sku.internal_sku_key = canonicalize_internal_sku( + CASE + WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5) + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5) + ELSE NULL + END + ) + LIMIT 1; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_normalize_product_identity +BEFORE INSERT OR UPDATE OF canonical_sku, canonical_name, source_kind, internal_sku_id +ON product_catalog +FOR EACH ROW +EXECUTE FUNCTION normalize_product_identity(); + +-- --------------------------------------------------------------------------- +-- 商品当前渠道投影 +-- --------------------------------------------------------------------------- +-- 统一商品当前渠道投影,并清理已被其他渠道接管且没有人工兜底配置的旧 man 商品。 + +UPDATE sku_channel_routes route +SET canonical_product_id = product.id, + updated_at = NOW() +FROM product_catalog product +WHERE route.channel = 'work_order' + AND route.canonical_product_id IS NULL + AND product.status = 'active' + AND product.canonical_sku = 'tpl:' || COALESCE(route.carrier_config_json->>'workRuleKey', ''); + +CREATE OR REPLACE VIEW v_product_current AS +SELECT + product.*, + sku.internal_sku_key, + COALESCE( + primary_route.channel, + CASE product.source_kind + WHEN 'affiliate_dash' THEN 'affiliate_dash' + WHEN 'work_order' THEN 'work_order' + WHEN 'third_party_voucher' THEN 'third_party_voucher' + WHEN 'manual' THEN 'manual' + ELSE NULL + END + ) AS active_primary_channel, + COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, + manual_config.id AS manual_config_id, + (manual_config.id IS NOT NULL) AS has_active_manual_config +FROM product_catalog product +LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id +LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 +) primary_route ON TRUE +LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'fallback' +) fallback_routes ON TRUE +LEFT JOIN LATERAL ( + SELECT config.id + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR ( + sku.internal_sku_key IS NOT NULL + AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key + ) + ) + ORDER BY config.id + LIMIT 1 +) manual_config ON TRUE; + +COMMENT ON VIEW v_product_current IS + '标准商品统一投影:来源、当前主渠道、兜底渠道和人工配置状态同源输出'; + +WITH candidates AS ( + SELECT product.id + FROM v_product_current product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND product.active_primary_channel IS NOT NULL + AND product.active_primary_channel <> 'manual' + AND product.has_active_manual_config = FALSE + AND EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + AND route.channel <> 'manual' + ) +) +UPDATE product_catalog product +SET status = 'archived', + attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'legacyManualArchived', TRUE, + 'legacyManualArchiveReason', 'superseded_by_current_primary_channel', + 'legacyManualArchivedAt', NOW() + ), + updated_at = NOW() +FROM candidates +WHERE product.id = candidates.id; + +COMMENT ON COLUMN product_catalog.source_kind IS + '标准商品来源类型;来源不等于当前履约渠道,当前渠道统一读取 v_product_current'; + +-- --------------------------------------------------------------------------- +-- 商品来源触发器修正 +-- --------------------------------------------------------------------------- +-- source_kind 的列默认值会让 BEFORE INSERT 触发器看到“非空但未分类”, +-- 从而跳过前缀回填。这里保留默认值兼容直接写入,同时让已知前缀始终覆盖 +-- unclassified,并修复兼容期内已经写入的误判行。 + +CREATE OR REPLACE FUNCTION normalize_product_identity() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.source_kind := CASE + WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified') + END; + + IF NEW.internal_sku_id IS NULL THEN + SELECT sku.id INTO NEW.internal_sku_id + FROM internal_skus sku + WHERE sku.internal_sku_key = canonicalize_internal_sku( + CASE + WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5) + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5) + ELSE NULL + END + ) + LIMIT 1; + END IF; + + RETURN NEW; +END; +$$; + +UPDATE product_catalog +SET source_kind = CASE + WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN canonical_sku LIKE 'man:%' THEN 'manual' + ELSE source_kind +END, +updated_at = NOW() +WHERE source_kind = 'unclassified' + AND ( + canonical_sku LIKE 'aff:%' + OR canonical_sku LIKE 'tpl:%' + OR canonical_sku LIKE 'wo:%' + OR canonical_sku LIKE 'tpv:%' + OR canonical_sku LIKE 'man:%' + ); + +-- --------------------------------------------------------------------------- +-- 仅兜底路由保持兜底 +-- --------------------------------------------------------------------------- +-- 兼容同步不能把“只有兜底渠道”的旧配置自动升级成主渠道。 +-- 只有旧表中明确 is_fallback = FALSE 的渠道才允许竞争 primary; +-- 如果某个内部 SKU 没有主渠道,则所有启用路由都保持 fallback。 + +CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_sku_id BIGINT; +BEGIN + SELECT id INTO v_sku_id + FROM internal_skus + WHERE internal_sku_key = canonicalize_internal_sku(p_raw) + LIMIT 1; + + IF v_sku_id IS NULL THEN + RETURN; + END IF; + + DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id; + + WITH legacy_rows AS ( + SELECT + channel.channel, + channel.priority, + channel.is_fallback, + channel.canonical_product_id, + channel.config_json, + channel.source, + channel.note, + channel.created_by_admin_user_id, + channel.created_at, + channel.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY channel.channel + ORDER BY channel.priority, channel.id + ) AS channel_rank + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) + = canonicalize_internal_sku(p_raw) + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY is_fallback + ORDER BY priority, channel + ) AS role_rank + FROM legacy_rows + WHERE channel_rank = 1 + ) + INSERT INTO sku_channel_routes ( + internal_sku_id, + channel, + route_role, + priority, + enabled, + canonical_product_id, + carrier_config_json, + source, + note, + created_by_admin_user_id, + created_at, + updated_at + ) + SELECT + v_sku_id, + channel, + CASE + WHEN is_fallback = FALSE AND role_rank = 1 THEN 'primary' + ELSE 'fallback' + END, + CASE + WHEN is_fallback = FALSE AND role_rank = 1 THEN priority + ELSE GREATEST(priority, 90) + END, + TRUE, + canonical_product_id, + COALESCE(config_json, '{}'::jsonb), + COALESCE(NULLIF(source, ''), 'legacy_sync'), + COALESCE(note, ''), + created_by_admin_user_id, + COALESCE(created_at, NOW()), + COALESCE(updated_at, NOW()) + FROM ranked; +END; +$$; + +DO $$ +DECLARE + v_internal_sku TEXT; +BEGIN + FOR v_internal_sku IN + SELECT DISTINCT internal_sku + FROM internal_sku_channels + WHERE enabled = TRUE + LOOP + PERFORM sync_sku_channel_routes_for_key(v_internal_sku); + END LOOP; +END; +$$; + +-- --------------------------------------------------------------------------- +-- 人工来源不推导人工主渠道 +-- --------------------------------------------------------------------------- +-- man: 只表示商品来源,不能证明当前由人工履约。 +-- 兼容期内,自动发货 / 接单平台 / 第三方卡券这类直连来源允许暂时由 source_kind +-- 推导当前渠道;人工客服必须有真实启用的 primary route,否则就是“未路由”。 + +CREATE OR REPLACE VIEW v_product_current AS +SELECT + product.*, + sku.internal_sku_key, + COALESCE( + primary_route.channel, + CASE product.source_kind + WHEN 'affiliate_dash' THEN 'affiliate_dash' + WHEN 'work_order' THEN 'work_order' + WHEN 'third_party_voucher' THEN 'third_party_voucher' + ELSE NULL + END + ) AS active_primary_channel, + COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, + manual_config.id AS manual_config_id, + (manual_config.id IS NOT NULL) AS has_active_manual_config +FROM product_catalog product +LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id +LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 +) primary_route ON TRUE +LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'fallback' +) fallback_routes ON TRUE +LEFT JOIN LATERAL ( + SELECT config.id + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR ( + sku.internal_sku_key IS NOT NULL + AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key + ) + ) + ORDER BY config.id + LIMIT 1 +) manual_config ON TRUE; + +COMMENT ON VIEW v_product_current IS + '标准商品统一投影:来源不等于当前渠道;manual 必须有真实主路由,直连来源兼容期允许回退 source_kind'; + +-- --------------------------------------------------------------------------- +-- 归档孤儿人工商品 +-- --------------------------------------------------------------------------- +-- man: 只表示来源,不能证明当前可履约。 +-- 没有主路由、没有启用人工配置、也没有启用商品映射的 man 商品属于孤儿商品, +-- 只归档,不删除;历史订单、成本版本和成本快照继续保留。 + +WITH candidates AS ( + SELECT product.id + FROM product_catalog product + WHERE product.status = 'active' + AND product.source_kind = 'manual' + AND NOT EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ) + AND NOT EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR config.internal_sku_id = product.internal_sku_id + OR canonicalize_internal_sku(config.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM channel_product_mappings mapping + WHERE mapping.canonical_product_id = product.id + AND mapping.enabled = TRUE + ) +) +UPDATE product_catalog product +SET status = 'archived', + attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'legacyManualArchived', TRUE, + 'legacyManualArchiveReason', 'no_primary_route_no_config_no_mapping', + 'legacyManualArchivedAt', NOW() + ), + updated_at = NOW() +FROM candidates +WHERE product.id = candidates.id; + +COMMENT ON COLUMN product_catalog.source_kind IS + '标准商品来源类型;来源不等于当前履约渠道,孤儿 man 商品只归档不删除'; + +-- --------------------------------------------------------------------------- +-- 履约路由硬切 +-- --------------------------------------------------------------------------- +-- 硬切换履约路由真源: +-- 1. 所有标准商品补齐 internal_sku_id; +-- 2. 运行时唯一路由真源改为 sku_channel_routes; +-- 3. 商品当前渠道不再回退 source_kind; +-- 4. 履约读模型不再读取 internal_sku_channels; +-- 5. 停止旧表触发同步,不再双写。 + +UPDATE product_catalog product +SET internal_sku_id = ensure_canonical_internal_sku(product.canonical_name), + updated_at = NOW() +WHERE product.internal_sku_id IS NULL + AND canonicalize_internal_sku(product.canonical_name) <> ''; + +CREATE OR REPLACE FUNCTION normalize_product_identity() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.source_kind := CASE + WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified') + END; + + IF NEW.internal_sku_id IS NULL THEN + NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.canonical_name); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_sync_sku_channel_routes ON internal_sku_channels; +DROP FUNCTION IF EXISTS sync_sku_channel_routes_trigger(); +DROP FUNCTION IF EXISTS sync_sku_channel_routes_for_key(TEXT); + +COMMENT ON TABLE internal_sku_channels IS + '冻结的旧路由输入表;本迁移后运行时不读不写,仅保留历史数据供追溯'; + +CREATE OR REPLACE VIEW v_product_current AS +SELECT + product.*, + sku.internal_sku_key, + primary_route.channel AS active_primary_channel, + COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, + manual_config.id AS manual_config_id, + (manual_config.id IS NOT NULL) AS has_active_manual_config +FROM product_catalog product +LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id +LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 +) primary_route ON TRUE +LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'fallback' +) fallback_routes ON TRUE +LEFT JOIN LATERAL ( + SELECT config.id + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR ( + sku.internal_sku_key IS NOT NULL + AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key + ) + ) + ORDER BY config.id + LIMIT 1 +) manual_config ON TRUE; + +COMMENT ON VIEW v_product_current IS + '标准商品统一投影:来源与当前渠道分离,当前渠道只来自 sku_channel_routes'; + +CREATE OR REPLACE VIEW v_fulfillment_item_state AS +WITH active_items AS ( + SELECT + oi.*, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku + FROM order_items oi + WHERE oi.fulfillment_status IN ('pending', 'assigned') +), +base AS ( + SELECT + oi.id AS order_item_id, + oi.order_id, + o.id AS local_order_id, + o.provider, + o.platform, + o.shop_id, + o.shop_name, + o.platform_order_id, + o.pay_status, + o.order_status, + oi.fulfillment_status AS item_fulfillment_status, + oi.fulfillment_channel AS stored_channel, + oi.fulfillment_source, + oi.fulfillment_locked, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + oi.sku_code, + oi.sku_name, + oi.quantity, + oi.revenue_amount_fen, + oi.created_at, + oi.updated_at, + oi.internal_sku, + channel_plan.channel AS configured_channel, + channel_plan.config_json AS configured_channel_config, + COALESCE(channel_stats.channel_count, 0) AS channel_count, + COALESCE(latest_task.task_id, 0) AS latest_task_id, + latest_task.executor_key AS latest_task_executor_key, + latest_task.task_status AS latest_task_status, + latest_task.result_code AS latest_task_result_code, + latest_task.result_message AS latest_task_result_message, + latest_task.updated_at AS latest_task_updated_at, + COALESCE(latest_work_order.work_order_id, 0) AS latest_work_order_id, + latest_work_order.status AS latest_work_order_status, + latest_work_order.result_code AS latest_work_order_result_code, + latest_work_order.updated_at AS latest_work_order_updated_at, + EXISTS ( + SELECT 1 + FROM fulfillment_tasks active_task + WHERE active_task.order_item_id = oi.id + AND active_task.executor_key IN ('affiliate_dash', 'manual_dispatch') + AND active_task.task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + ) AS has_active_task, + EXISTS ( + SELECT 1 + FROM work_orders active_work_order + WHERE active_work_order.order_item_id = oi.id + AND active_work_order.status <> 'cancelled' + ) AS has_active_work_order, + EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs cfg + WHERE cfg.enabled = TRUE + AND canonicalize_internal_sku(cfg.sku_key) = oi.internal_sku + ) AS has_manual_config + FROM active_items oi + JOIN orders o ON o.id = oi.order_id + LEFT JOIN LATERAL ( + SELECT route.channel, route.carrier_config_json AS config_json + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND sku.internal_sku_key = oi.internal_sku + ORDER BY + (route.route_role = 'fallback'), + route.priority, + route.channel, + route.id + LIMIT 1 + ) channel_plan ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS channel_count + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND sku.internal_sku_key = oi.internal_sku + ) channel_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT + task.id AS task_id, + task.executor_key, + task.task_status, + task.result_code, + task.result_message, + task.updated_at + FROM fulfillment_tasks task + WHERE task.order_item_id = oi.id + ORDER BY task.id DESC + LIMIT 1 + ) latest_task ON TRUE + LEFT JOIN LATERAL ( + SELECT + work_order.id AS work_order_id, + work_order.status, + ''::text AS result_code, + work_order.updated_at + FROM work_orders work_order + WHERE work_order.order_item_id = oi.id + ORDER BY work_order.id DESC + LIMIT 1 + ) latest_work_order ON TRUE +), +classified AS ( + SELECT + base.*, + COALESCE(base.stored_channel, base.configured_channel) AS intended_channel, + CASE + WHEN base.pay_status = 'refunded' + OR base.order_status IN ('refunded', 'cancelled') + OR base.latest_task_result_code = 'USER_APPLY_REFUND' + THEN TRUE + ELSE FALSE + END AS is_user_refund, + CASE + WHEN base.pay_status = 'cancelled' + OR base.order_status = 'cancelled' + OR base.latest_task_result_code = 'affiliate_dash_cancelled' + THEN TRUE + ELSE FALSE + END AS is_platform_cancelled + FROM base +), +current_attempt AS ( + SELECT + classified.*, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN 'task' + WHEN classified.latest_work_order_id > 0 THEN 'work_order' + ELSE '' + END AS current_attempt_type, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_id + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_id + ELSE 0 + END AS current_attempt_id, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_status + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_status + ELSE '' + END AS current_attempt_status, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_result_code + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_result_code + ELSE '' + END AS current_attempt_result_code + FROM classified +), +state_classified AS ( + SELECT + current_attempt.*, + CASE + WHEN current_attempt.intended_channel IN ('third_party_voucher', 'on_account') + THEN 'not_required' + WHEN current_attempt.intended_channel = 'manual' + AND current_attempt.has_manual_config + THEN 'ready' + WHEN current_attempt.has_active_task OR current_attempt.has_active_work_order + THEN 'ready' + WHEN current_attempt.latest_task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + THEN 'terminal' + WHEN current_attempt.latest_work_order_status = 'cancelled' + THEN 'terminal' + WHEN current_attempt.intended_channel IS NOT NULL + THEN 'missing' + ELSE 'missing' + END AS carrier_status, + CASE + WHEN COALESCE(current_attempt.internal_sku, '') = '' + THEN 'missing_internal_sku' + WHEN current_attempt.channel_count = 0 + THEN 'missing_channel' + ELSE 'configured' + END AS route_status + FROM current_attempt +), +issue_classified AS ( + SELECT + state_classified.*, + CASE + WHEN state_classified.is_user_refund THEN 'user_refund' + WHEN state_classified.is_platform_cancelled THEN 'platform_cancelled' + WHEN state_classified.route_status = 'missing_internal_sku' THEN 'missing_internal_sku' + WHEN state_classified.route_status = 'missing_channel' THEN 'missing_channel' + WHEN state_classified.intended_channel = 'manual' + AND NOT state_classified.has_manual_config + THEN 'manual_config_missing' + WHEN state_classified.intended_channel = 'work_order' + AND BTRIM(COALESCE( + state_classified.configured_channel_config->>'workRuleKey', + '' + )) = '' + THEN 'work_rule_missing' + WHEN state_classified.intended_channel = 'affiliate_dash' + AND BTRIM(COALESCE( + state_classified.configured_channel_config->>'affiliateSku', + '' + )) = '' + THEN 'affiliate_mapping_missing' + WHEN state_classified.carrier_status IN ('ready', 'not_required') THEN '' + WHEN state_classified.latest_task_status = 'manual_review' + THEN 'carrier_manual_review' + WHEN state_classified.latest_task_status IN ('failed', 'retry_pending') + THEN 'carrier_retry_pending' + WHEN state_classified.latest_task_status = 'expired' THEN 'carrier_expired' + WHEN state_classified.latest_task_status = 'closed' + OR state_classified.latest_work_order_status = 'cancelled' + THEN 'carrier_closed' + WHEN state_classified.item_fulfillment_status = 'assigned' + THEN 'state_inconsistent' + WHEN state_classified.intended_channel IN ( + 'affiliate_dash', 'work_order', 'manual' + ) + THEN 'carrier_missing' + ELSE 'state_inconsistent' + END AS issue_code + FROM state_classified +), +categorized AS ( + SELECT + issue_classified.*, + CASE + WHEN issue_code = '' THEN '' + WHEN issue_code IN ('user_refund', 'platform_cancelled') THEN 'after_sales' + WHEN issue_code = 'carrier_manual_review' THEN 'manual_review' + WHEN issue_code IN ('missing_internal_sku', 'missing_channel') THEN 'classification' + WHEN issue_code IN ( + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'configuration' + WHEN issue_code = 'state_inconsistent' THEN 'system' + ELSE 'carrier' + END AS issue_category + FROM issue_classified +) +SELECT + order_item_id, + order_id, + local_order_id, + provider, + platform, + shop_id, + shop_name, + platform_order_id, + pay_status, + order_status, + item_fulfillment_status AS fulfillment_status, + internal_sku, + intended_channel, + stored_channel, + configured_channel, + route_status, + carrier_status, + current_attempt_type, + current_attempt_id, + current_attempt_status, + current_attempt_result_code, + latest_task_id, + latest_task_executor_key, + latest_task_status, + latest_task_result_code, + latest_work_order_id, + latest_work_order_status, + issue_code, + issue_category, + CASE + WHEN issue_code = '' THEN FALSE + WHEN issue_code IN ( + 'user_refund', 'platform_cancelled', 'carrier_manual_review', + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing', + 'missing_internal_sku', 'missing_channel' + ) THEN FALSE + ELSE TRUE + END AS retryable, + CASE + WHEN issue_code IN ('carrier_missing', 'state_inconsistent') THEN TRUE + ELSE FALSE + END AS auto_retryable, + CASE + WHEN issue_code IN ('user_refund', 'platform_cancelled', 'carrier_manual_review') + THEN 'support' + WHEN issue_code IN ( + 'missing_internal_sku', 'missing_channel', 'manual_config_missing', + 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'operator' + WHEN issue_code = '' THEN '' + ELSE 'system' + END AS owner_role, + fulfillment_locked, + CASE + WHEN NOT fulfillment_locked THEN '' + ELSE COALESCE( + NULLIF(fulfillment_pending_detail->>'carrierIssue', ''), + NULLIF(fulfillment_pending_detail->>'reason', ''), + 'operator_assigned' + ) + END AS lock_reason, + fulfillment_pending_reason, + fulfillment_pending_detail, + fulfillment_source, + sku_code, + sku_name, + quantity, + revenue_amount_fen, + created_at, + updated_at, + GREATEST( + 0, + FLOOR(EXTRACT(EPOCH FROM (NOW() - created_at)) / 60) + )::bigint AS age_minutes +FROM categorized; + +COMMENT ON VIEW v_fulfillment_item_state IS + '履约统一读模型:每个订单项一行,仅读取 sku_channel_routes 的当前路由'; + +-- --------------------------------------------------------------------------- +-- 影子同步迁移到新路由 +-- --------------------------------------------------------------------------- +-- 路由唯一真源已经切换到 sku_channel_routes。 +-- 履约影子模型必须跟随新表变化,旧表不再拥有任何同步触发器。 + +DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow ON internal_sku_channels; +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_internal_channel(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_sku_route() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + SELECT internal_sku_key INTO old_key + FROM internal_skus + WHERE id = OLD.internal_sku_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT internal_sku_key INTO new_key + FROM internal_skus + WHERE id = NEW.internal_sku_id; + END IF; + + PERFORM sync_fulfillment_shadow(item.id) + FROM order_items item + WHERE item.fulfillment_status IN ('pending', 'assigned') + AND canonicalize_internal_sku( + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_sku_route_fulfillment_shadow ON sku_channel_routes; +CREATE TRIGGER trg_sku_route_fulfillment_shadow +AFTER INSERT OR UPDATE OR DELETE ON sku_channel_routes +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_shadow_for_sku_route(); + +COMMENT ON FUNCTION sync_fulfillment_shadow_for_sku_route() IS + 'sku_channel_routes 变化后刷新同内部 SKU 的履约影子'; + +-- --------------------------------------------------------------------------- +-- 下线旧路由读取面 +-- --------------------------------------------------------------------------- +-- 前序路由硬切段已把业务读模型和影子同步切到 sku_channel_routes。 +-- 这里删除最后一个旧表只读视图,以及旧表最后一个规范化触发器。 + +DROP VIEW IF EXISTS v_fulfillment_item_state_raw; + +DROP TRIGGER IF EXISTS trg_normalize_internal_sku_channel_key ON internal_sku_channels; +DROP FUNCTION IF EXISTS normalize_internal_sku_channel_key(); + +COMMENT ON TABLE internal_sku_channels IS + '冻结的历史路由输入;无触发器、无运行时读取、无运行时写入'; + +-- --------------------------------------------------------------------------- +-- 收紧商品来源约束 +-- --------------------------------------------------------------------------- +-- 商品来源统一由 product_catalog.source_kind 表达。 +-- 运行期不再解析 canonical_sku 前缀;数据库同步收紧允许值。 + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_product_catalog_source_kind' + ) THEN + ALTER TABLE product_catalog + ADD CONSTRAINT chk_product_catalog_source_kind + CHECK ( + source_kind IN ( + 'affiliate_dash', + 'work_order', + 'third_party_voucher', + 'manual', + 'unclassified' + ) + ) + NOT VALID; + END IF; +END $$; + +ALTER TABLE product_catalog + VALIDATE CONSTRAINT chk_product_catalog_source_kind; diff --git a/apps/backend/src/db/migrations/163_cutover_fulfillment_truth.sql b/apps/backend/src/db/migrations/148_fulfillment_execution_truth.sql similarity index 91% rename from apps/backend/src/db/migrations/163_cutover_fulfillment_truth.sql rename to apps/backend/src/db/migrations/148_fulfillment_execution_truth.sql index a0e938d7..f5cc1230 100644 --- a/apps/backend/src/db/migrations/163_cutover_fulfillment_truth.sql +++ b/apps/backend/src/db/migrations/148_fulfillment_execution_truth.sql @@ -1,5 +1,16 @@ --- 163_cutover_fulfillment_truth.sql +-- 148_fulfillment_execution_truth.sql -- +-- 正式履约执行真源硬切: +-- 1. fulfillments 成为状态、渠道、载体、配置和当前尝试的唯一真源; +-- 2. 任务与工单只追加 fulfillment_attempts; +-- 3. 订单项旧履约列冻结为 legacy_*,统一读取 v_order_item_current; +-- 4. 履约事件、外键级联和无法解析内部 SKU 的口径一次收口。 +-- +-- 合并自未上生产的 163–166。 + +-- --------------------------------------------------------------------------- +-- 履约状态硬切 +-- --------------------------------------------------------------------------- -- 履约状态硬切: -- 1. fulfillments 成为履约状态、渠道、配置关联和当前尝试的唯一真源; -- 2. order_items 只保留原始订单项数据,旧履约列改名冻结,不再读写; @@ -1199,9 +1210,9 @@ ALTER TABLE order_items DROP CONSTRAINT IF EXISTS order_items_fulfillment_decided_by_fkey; COMMENT ON COLUMN order_items.legacy_canonical_product_id IS - '冻结的历史商品归一值;迁移 163 后唯一真源是 fulfillments.canonical_product_id'; + '冻结的历史商品归一值;本迁移后唯一真源是 fulfillments.canonical_product_id'; COMMENT ON COLUMN order_items.legacy_fulfillment_status IS - '冻结的历史履约状态;迁移 163 后唯一真源是 fulfillments.status'; + '冻结的历史履约状态;本迁移后唯一真源是 fulfillments.status'; COMMENT ON TABLE fulfillments IS '履约唯一真源:每订单项一行,保存状态、渠道、配置关联、载体、当前尝试和异常摘要'; @@ -1349,3 +1360,97 @@ WHERE fulfillment.status IN ('pending', 'assigned'); COMMENT ON VIEW v_fulfillment_item_state IS '履约统一读模型:直接投影 fulfillments、fulfillment_attempts、fulfillment_issues 和 sku_channel_routes'; + +-- --------------------------------------------------------------------------- +-- 履约事件外键级联 +-- --------------------------------------------------------------------------- +-- 履约事件已经由 fulfillment_id 直接归属正式履约记录。 +-- 订单项删除时事件应随履约记录级联清理,不能先 SET NULL 再触发 append-only 更新保护。 + +ALTER TABLE fulfillment_state_events + DROP CONSTRAINT IF EXISTS fk_fulfillment_state_events_fulfillment; + +ALTER TABLE fulfillment_state_events + ADD CONSTRAINT fk_fulfillment_state_events_fulfillment + FOREIGN KEY (fulfillment_id) + REFERENCES fulfillments(id) + ON DELETE CASCADE; + +-- --------------------------------------------------------------------------- +-- 履约事件只禁止更新 +-- --------------------------------------------------------------------------- +-- 事件语义是“应用不可改”,父记录删除时允许数据库级联清理。 +-- 旧环境里曾存在 BEFORE DELETE OR UPDATE 的漂移定义,这里统一收回为只阻止 UPDATE。 + +DROP TRIGGER IF EXISTS trg_fulfillment_state_events_append_only + ON fulfillment_state_events; + +CREATE TRIGGER trg_fulfillment_state_events_append_only +BEFORE UPDATE ON fulfillment_state_events +FOR EACH ROW +EXECUTE FUNCTION reject_fulfillment_state_event_mutation(); + +COMMENT ON TRIGGER trg_fulfillment_state_events_append_only + ON fulfillment_state_events IS + '禁止应用修改履约事件;数据库外键级联删除不受影响'; + +-- --------------------------------------------------------------------------- +-- 无法解析内部 SKU 的兜底 +-- --------------------------------------------------------------------------- +-- resolve_internal_sku 可能返回 NULL。正式履约行的 internal_sku 是不带 NULL 的稳定键, +-- 无法解析时统一落空字符串,并立即进入 missing_internal_sku 异常口径。 + +CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO fulfillments ( + order_item_id, + order_id, + internal_sku, + route_status, + channel, + carrier_status, + current_attempt_type, + current_attempt_source_id, + current_attempt_status, + lock_reason, + issue_code, + issue_category, + retryable, + owner_role, + created_at, + updated_at, + status, + source, + locked, + channel_switch_reason + ) VALUES ( + NEW.id, + NEW.order_id, + COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''), + 'unrouted', + NULL, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + NEW.created_at, + NOW(), + 'pending', + '', + FALSE, + '' + ) + ON CONFLICT (order_item_id) DO NOTHING; + + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; diff --git a/apps/backend/src/db/migrations/167_finance_derived_scope.sql b/apps/backend/src/db/migrations/149_finance_derived_scope.sql similarity index 88% rename from apps/backend/src/db/migrations/167_finance_derived_scope.sql rename to apps/backend/src/db/migrations/149_finance_derived_scope.sql index e75b171c..412d01ef 100644 --- a/apps/backend/src/db/migrations/167_finance_derived_scope.sql +++ b/apps/backend/src/db/migrations/149_finance_derived_scope.sql @@ -1,5 +1,10 @@ --- 167_finance_derived_scope.sql +-- 149_finance_derived_scope.sql -- +-- 财务销售范围唯一读模型: +-- v_finance_order_item_scope 统一收入、退款、商品、渠道和业务日, +-- 销售事实、重建与审计共用这一口径。 +-- +-- 合并自未上生产的 167。 -- 财务派生范围唯一读模型: -- 1. 只纳入 pending / assigned 履约项; -- 2. 只纳入 paid / refunded 商业订单; diff --git a/apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql b/apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql deleted file mode 100644 index 77b2aa10..00000000 --- a/apps/backend/src/db/migrations/154_internal_sku_registry_and_routes.sql +++ /dev/null @@ -1,426 +0,0 @@ --- 154_internal_sku_registry_and_routes.sql --- --- 统一内部 SKU 身份与履约路由: --- 1. internal_skus 成为内部 SKU 的稳定身份; --- 2. sku_channel_routes 成为主渠道/兜底渠道的唯一结构化表达; --- 3. 兼容期由旧 internal_sku_channels 通过触发器同步; --- 4. product_catalog 增加 source_kind 与 internal_sku_id,不再依赖前缀判断业务来源。 - -CREATE TABLE internal_skus ( - id BIGSERIAL PRIMARY KEY, - internal_sku_key TEXT NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - merged_into_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT internal_skus_key_not_blank CHECK (BTRIM(internal_sku_key) <> ''), - CONSTRAINT internal_skus_status_check CHECK (status IN ('active', 'disabled', 'merged')) -); - -CREATE UNIQUE INDEX uq_internal_skus_key - ON internal_skus(internal_sku_key); - -COMMENT ON TABLE internal_skus IS - '中文内部 SKU 稳定身份;所有路由、配置和商品关联最终通过 internal_sku_id 引用'; -COMMENT ON COLUMN internal_skus.internal_sku_key IS - 'NFKC、去空白、转小写后的稳定键'; - -WITH source_rows AS ( - SELECT internal_sku AS raw_value, updated_at FROM internal_sku_channels - UNION ALL - SELECT internal_sku AS raw_value, updated_at FROM internal_sku_mappings - UNION ALL - SELECT sku_key AS raw_value, updated_at FROM manual_fulfillment_configs - UNION ALL - SELECT sku_key AS raw_value, updated_at FROM manual_sku_registry - UNION ALL - SELECT canonical_name AS raw_value, updated_at FROM product_catalog -), -normalized AS ( - SELECT - canonicalize_internal_sku(raw_value) AS internal_sku_key, - BTRIM(raw_value) AS display_name, - updated_at - FROM source_rows - WHERE canonicalize_internal_sku(raw_value) <> '' -), -picked AS ( - SELECT DISTINCT ON (internal_sku_key) - internal_sku_key, - display_name, - updated_at - FROM normalized - ORDER BY internal_sku_key, updated_at DESC NULLS LAST, display_name -) -INSERT INTO internal_skus ( - internal_sku_key, - display_name, - status, - created_at, - updated_at -) -SELECT - internal_sku_key, - display_name, - 'active', - COALESCE(updated_at, NOW()), - COALESCE(updated_at, NOW()) -FROM picked -ON CONFLICT (internal_sku_key) DO UPDATE SET - display_name = CASE - WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name - ELSE internal_skus.display_name - END, - updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at); - -ALTER TABLE internal_sku_mappings - ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL; -ALTER TABLE manual_fulfillment_configs - ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL; -ALTER TABLE product_catalog - ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL, - ADD COLUMN source_kind TEXT; - -UPDATE internal_sku_mappings mapping -SET internal_sku_id = sku.id -FROM internal_skus sku -WHERE sku.internal_sku_key = canonicalize_internal_sku(mapping.internal_sku); - -UPDATE manual_fulfillment_configs config -SET internal_sku_id = sku.id -FROM internal_skus sku -WHERE sku.internal_sku_key = canonicalize_internal_sku(config.sku_key); - -UPDATE product_catalog product -SET source_kind = CASE - WHEN product.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' - WHEN product.canonical_sku LIKE 'tpl:%' OR product.canonical_sku LIKE 'wo:%' THEN 'work_order' - WHEN product.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' - WHEN product.canonical_sku LIKE 'man:%' THEN 'manual' - ELSE 'unclassified' -END; - -UPDATE product_catalog product -SET internal_sku_id = sku.id -FROM internal_skus sku -WHERE sku.internal_sku_key = canonicalize_internal_sku( - CASE - WHEN product.canonical_sku LIKE 'man:%' THEN substring(product.canonical_sku from 5) - WHEN product.canonical_sku LIKE 'tpv:%' THEN substring(product.canonical_sku from 5) - ELSE NULL - END -); - -ALTER TABLE product_catalog - ALTER COLUMN source_kind SET DEFAULT 'unclassified', - ALTER COLUMN source_kind SET NOT NULL; - -CREATE INDEX idx_product_catalog_internal_sku - ON product_catalog(internal_sku_id) - WHERE internal_sku_id IS NOT NULL; -CREATE INDEX idx_product_catalog_source_kind - ON product_catalog(source_kind, status); -CREATE INDEX idx_internal_sku_mappings_internal_sku - ON internal_sku_mappings(internal_sku_id) - WHERE internal_sku_id IS NOT NULL; -CREATE INDEX idx_manual_configs_internal_sku - ON manual_fulfillment_configs(internal_sku_id) - WHERE internal_sku_id IS NOT NULL; - -CREATE TABLE sku_channel_routes ( - id BIGSERIAL PRIMARY KEY, - internal_sku_id BIGINT NOT NULL REFERENCES internal_skus(id) ON DELETE CASCADE, - channel TEXT NOT NULL, - route_role TEXT NOT NULL DEFAULT 'fallback', - priority INTEGER NOT NULL DEFAULT 100, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL, - carrier_config_json JSONB NOT NULL DEFAULT '{}'::jsonb, - source TEXT NOT NULL DEFAULT 'legacy_sync', - 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(), - CONSTRAINT sku_channel_routes_channel_check - CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account')), - CONSTRAINT sku_channel_routes_role_check - CHECK (route_role IN ('primary', 'fallback')), - CONSTRAINT sku_channel_routes_priority_check - CHECK (priority >= 0) -); - -CREATE UNIQUE INDEX uq_sku_channel_routes_channel - ON sku_channel_routes(internal_sku_id, channel); -CREATE UNIQUE INDEX uq_sku_channel_routes_primary - ON sku_channel_routes(internal_sku_id) - WHERE enabled = TRUE AND route_role = 'primary'; -CREATE INDEX idx_sku_channel_routes_lookup - ON sku_channel_routes(channel, enabled, priority) - WHERE enabled = TRUE; - -COMMENT ON TABLE sku_channel_routes IS - '内部 SKU 履约路由:一个主渠道加若干显式兜底渠道'; -COMMENT ON COLUMN sku_channel_routes.carrier_config_json IS - '渠道载体参数,例如 workRuleKey / affiliateSku / manualConfigId / voucherSku'; - -WITH legacy_rows AS ( - SELECT - sku.id AS internal_sku_id, - channel.channel, - channel.priority, - channel.is_fallback, - channel.enabled, - channel.canonical_product_id, - channel.config_json, - channel.source, - channel.note, - channel.created_by_admin_user_id, - channel.created_at, - channel.updated_at, - ROW_NUMBER() OVER ( - PARTITION BY sku.id, channel.channel - ORDER BY channel.is_fallback, channel.priority, channel.id - ) AS channel_rank - FROM internal_sku_channels channel - JOIN internal_skus sku - ON sku.internal_sku_key = canonicalize_internal_sku(channel.internal_sku) - WHERE channel.enabled = TRUE -), -deduplicated AS ( - SELECT * FROM legacy_rows WHERE channel_rank = 1 -), -ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - PARTITION BY internal_sku_id - ORDER BY is_fallback, priority, channel - ) AS overall_rank - FROM deduplicated -) -INSERT INTO sku_channel_routes ( - internal_sku_id, - channel, - route_role, - priority, - enabled, - canonical_product_id, - carrier_config_json, - source, - note, - created_by_admin_user_id, - created_at, - updated_at -) -SELECT - internal_sku_id, - channel, - CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END, - CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END, - TRUE, - canonical_product_id, - COALESCE(config_json, '{}'::jsonb), - COALESCE(NULLIF(source, ''), 'legacy_sync'), - COALESCE(note, ''), - created_by_admin_user_id, - COALESCE(created_at, NOW()), - COALESCE(updated_at, NOW()) -FROM ranked -ON CONFLICT (internal_sku_id, channel) DO NOTHING; - -CREATE OR REPLACE FUNCTION ensure_canonical_internal_sku(p_raw TEXT) -RETURNS BIGINT -LANGUAGE plpgsql -AS $$ -DECLARE - v_key TEXT := canonicalize_internal_sku(p_raw); - v_display_name TEXT := BTRIM(COALESCE(p_raw, '')); - v_id BIGINT; -BEGIN - IF v_key = '' THEN - RETURN NULL; - END IF; - - INSERT INTO internal_skus (internal_sku_key, display_name, status, created_at, updated_at) - VALUES (v_key, v_display_name, 'active', NOW(), NOW()) - ON CONFLICT (internal_sku_key) DO UPDATE SET - display_name = CASE - WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name - ELSE internal_skus.display_name - END, - updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at) - RETURNING id INTO v_id; - - RETURN v_id; -END; -$$; - -CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT) -RETURNS VOID -LANGUAGE plpgsql -AS $$ -DECLARE - v_sku_id BIGINT; -BEGIN - SELECT id INTO v_sku_id - FROM internal_skus - WHERE internal_sku_key = canonicalize_internal_sku(p_raw) - LIMIT 1; - - IF v_sku_id IS NULL THEN - RETURN; - END IF; - - DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id; - - WITH legacy_rows AS ( - SELECT - channel.channel, - channel.priority, - channel.is_fallback, - channel.canonical_product_id, - channel.config_json, - channel.source, - channel.note, - channel.created_by_admin_user_id, - channel.created_at, - channel.updated_at, - ROW_NUMBER() OVER ( - PARTITION BY channel.channel - ORDER BY channel.is_fallback, channel.priority, channel.id - ) AS channel_rank - FROM internal_sku_channels channel - WHERE channel.enabled = TRUE - AND canonicalize_internal_sku(channel.internal_sku) - = canonicalize_internal_sku(p_raw) - ), - ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - ORDER BY is_fallback, priority, channel - ) AS overall_rank - FROM legacy_rows - WHERE channel_rank = 1 - ) - INSERT INTO sku_channel_routes ( - internal_sku_id, - channel, - route_role, - priority, - enabled, - canonical_product_id, - carrier_config_json, - source, - note, - created_by_admin_user_id, - created_at, - updated_at - ) - SELECT - v_sku_id, - channel, - CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END, - CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END, - TRUE, - canonical_product_id, - COALESCE(config_json, '{}'::jsonb), - COALESCE(NULLIF(source, ''), 'legacy_sync'), - COALESCE(note, ''), - created_by_admin_user_id, - COALESCE(created_at, NOW()), - COALESCE(updated_at, NOW()) - FROM ranked; -END; -$$; - -CREATE OR REPLACE FUNCTION sync_sku_channel_routes_trigger() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - IF TG_OP <> 'INSERT' THEN - PERFORM sync_sku_channel_routes_for_key(OLD.internal_sku); - END IF; - IF TG_OP <> 'DELETE' THEN - PERFORM ensure_canonical_internal_sku(NEW.internal_sku); - PERFORM sync_sku_channel_routes_for_key(NEW.internal_sku); - END IF; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_sync_sku_channel_routes -AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels -FOR EACH ROW -EXECUTE FUNCTION sync_sku_channel_routes_trigger(); - -CREATE OR REPLACE FUNCTION set_internal_sku_id_from_text() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.internal_sku); - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_internal_sku_mapping_identity -BEFORE INSERT OR UPDATE OF internal_sku ON internal_sku_mappings -FOR EACH ROW -EXECUTE FUNCTION set_internal_sku_id_from_text(); - -CREATE OR REPLACE FUNCTION set_manual_config_internal_sku_id() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.sku_key); - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_manual_config_internal_sku_identity -BEFORE INSERT OR UPDATE OF sku_key ON manual_fulfillment_configs -FOR EACH ROW -EXECUTE FUNCTION set_manual_config_internal_sku_id(); - -CREATE OR REPLACE FUNCTION normalize_product_identity() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.source_kind := COALESCE( - NULLIF(NEW.source_kind, ''), - CASE - WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' - WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' - WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' - WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' - ELSE 'unclassified' - END - ); - - IF NEW.internal_sku_id IS NULL THEN - SELECT sku.id INTO NEW.internal_sku_id - FROM internal_skus sku - WHERE sku.internal_sku_key = canonicalize_internal_sku( - CASE - WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5) - WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5) - ELSE NULL - END - ) - LIMIT 1; - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_normalize_product_identity -BEFORE INSERT OR UPDATE OF canonical_sku, canonical_name, source_kind, internal_sku_id -ON product_catalog -FOR EACH ROW -EXECUTE FUNCTION normalize_product_identity(); diff --git a/apps/backend/src/db/migrations/155_product_current_projection.sql b/apps/backend/src/db/migrations/155_product_current_projection.sql deleted file mode 100644 index f60d22ed..00000000 --- a/apps/backend/src/db/migrations/155_product_current_projection.sql +++ /dev/null @@ -1,96 +0,0 @@ --- 155_product_current_projection.sql --- --- 统一商品当前渠道投影,并清理已被其他渠道接管且没有人工兜底配置的旧 man 商品。 - -UPDATE sku_channel_routes route -SET canonical_product_id = product.id, - updated_at = NOW() -FROM product_catalog product -WHERE route.channel = 'work_order' - AND route.canonical_product_id IS NULL - AND product.status = 'active' - AND product.canonical_sku = 'tpl:' || COALESCE(route.carrier_config_json->>'workRuleKey', ''); - -CREATE OR REPLACE VIEW v_product_current AS -SELECT - product.*, - sku.internal_sku_key, - COALESCE( - primary_route.channel, - CASE product.source_kind - WHEN 'affiliate_dash' THEN 'affiliate_dash' - WHEN 'work_order' THEN 'work_order' - WHEN 'third_party_voucher' THEN 'third_party_voucher' - WHEN 'manual' THEN 'manual' - ELSE NULL - END - ) AS active_primary_channel, - COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, - manual_config.id AS manual_config_id, - (manual_config.id IS NOT NULL) AS has_active_manual_config -FROM product_catalog product -LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id -LEFT JOIN LATERAL ( - SELECT route.channel - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'primary' - ORDER BY route.priority, route.channel - LIMIT 1 -) primary_route ON TRUE -LEFT JOIN LATERAL ( - SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'fallback' -) fallback_routes ON TRUE -LEFT JOIN LATERAL ( - SELECT config.id - FROM manual_fulfillment_configs config - WHERE config.enabled = TRUE - AND ( - config.canonical_product_id = product.id - OR ( - sku.internal_sku_key IS NOT NULL - AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key - ) - ) - ORDER BY config.id - LIMIT 1 -) manual_config ON TRUE; - -COMMENT ON VIEW v_product_current IS - '标准商品统一投影:来源、当前主渠道、兜底渠道和人工配置状态同源输出'; - -WITH candidates AS ( - SELECT product.id - FROM v_product_current product - WHERE product.status = 'active' - AND product.canonical_sku LIKE 'man:%' - AND product.active_primary_channel IS NOT NULL - AND product.active_primary_channel <> 'manual' - AND product.has_active_manual_config = FALSE - AND EXISTS ( - SELECT 1 - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'primary' - AND route.channel <> 'manual' - ) -) -UPDATE product_catalog product -SET status = 'archived', - attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( - 'legacyManualArchived', TRUE, - 'legacyManualArchiveReason', 'superseded_by_current_primary_channel', - 'legacyManualArchivedAt', NOW() - ), - updated_at = NOW() -FROM candidates -WHERE product.id = candidates.id; - -COMMENT ON COLUMN product_catalog.source_kind IS - '标准商品来源类型;来源不等于当前履约渠道,当前渠道统一读取 v_product_current'; diff --git a/apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql b/apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql deleted file mode 100644 index 57ab0e9d..00000000 --- a/apps/backend/src/db/migrations/156_fix_product_source_kind_default.sql +++ /dev/null @@ -1,53 +0,0 @@ --- 156_fix_product_source_kind_default.sql --- --- source_kind 的列默认值会让 BEFORE INSERT 触发器看到“非空但未分类”, --- 从而跳过前缀回填。这里保留默认值兼容直接写入,同时让已知前缀始终覆盖 --- unclassified,并修复兼容期内已经写入的误判行。 - -CREATE OR REPLACE FUNCTION normalize_product_identity() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.source_kind := CASE - WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' - WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' - WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' - WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' - ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified') - END; - - IF NEW.internal_sku_id IS NULL THEN - SELECT sku.id INTO NEW.internal_sku_id - FROM internal_skus sku - WHERE sku.internal_sku_key = canonicalize_internal_sku( - CASE - WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5) - WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5) - ELSE NULL - END - ) - LIMIT 1; - END IF; - - RETURN NEW; -END; -$$; - -UPDATE product_catalog -SET source_kind = CASE - WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' - WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order' - WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' - WHEN canonical_sku LIKE 'man:%' THEN 'manual' - ELSE source_kind -END, -updated_at = NOW() -WHERE source_kind = 'unclassified' - AND ( - canonical_sku LIKE 'aff:%' - OR canonical_sku LIKE 'tpl:%' - OR canonical_sku LIKE 'wo:%' - OR canonical_sku LIKE 'tpv:%' - OR canonical_sku LIKE 'man:%' - ); diff --git a/apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql b/apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql deleted file mode 100644 index 69721021..00000000 --- a/apps/backend/src/db/migrations/157_preserve_fallback_only_routes.sql +++ /dev/null @@ -1,105 +0,0 @@ --- 157_preserve_fallback_only_routes.sql --- --- 兼容同步不能把“只有兜底渠道”的旧配置自动升级成主渠道。 --- 只有旧表中明确 is_fallback = FALSE 的渠道才允许竞争 primary; --- 如果某个内部 SKU 没有主渠道,则所有启用路由都保持 fallback。 - -CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT) -RETURNS VOID -LANGUAGE plpgsql -AS $$ -DECLARE - v_sku_id BIGINT; -BEGIN - SELECT id INTO v_sku_id - FROM internal_skus - WHERE internal_sku_key = canonicalize_internal_sku(p_raw) - LIMIT 1; - - IF v_sku_id IS NULL THEN - RETURN; - END IF; - - DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id; - - WITH legacy_rows AS ( - SELECT - channel.channel, - channel.priority, - channel.is_fallback, - channel.canonical_product_id, - channel.config_json, - channel.source, - channel.note, - channel.created_by_admin_user_id, - channel.created_at, - channel.updated_at, - ROW_NUMBER() OVER ( - PARTITION BY channel.channel - ORDER BY channel.priority, channel.id - ) AS channel_rank - FROM internal_sku_channels channel - WHERE channel.enabled = TRUE - AND canonicalize_internal_sku(channel.internal_sku) - = canonicalize_internal_sku(p_raw) - ), - ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - PARTITION BY is_fallback - ORDER BY priority, channel - ) AS role_rank - FROM legacy_rows - WHERE channel_rank = 1 - ) - INSERT INTO sku_channel_routes ( - internal_sku_id, - channel, - route_role, - priority, - enabled, - canonical_product_id, - carrier_config_json, - source, - note, - created_by_admin_user_id, - created_at, - updated_at - ) - SELECT - v_sku_id, - channel, - CASE - WHEN is_fallback = FALSE AND role_rank = 1 THEN 'primary' - ELSE 'fallback' - END, - CASE - WHEN is_fallback = FALSE AND role_rank = 1 THEN priority - ELSE GREATEST(priority, 90) - END, - TRUE, - canonical_product_id, - COALESCE(config_json, '{}'::jsonb), - COALESCE(NULLIF(source, ''), 'legacy_sync'), - COALESCE(note, ''), - created_by_admin_user_id, - COALESCE(created_at, NOW()), - COALESCE(updated_at, NOW()) - FROM ranked; -END; -$$; - -DO $$ -DECLARE - v_internal_sku TEXT; -BEGIN - FOR v_internal_sku IN - SELECT DISTINCT internal_sku - FROM internal_sku_channels - WHERE enabled = TRUE - LOOP - PERFORM sync_sku_channel_routes_for_key(v_internal_sku); - END LOOP; -END; -$$; diff --git a/apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql b/apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql deleted file mode 100644 index 49880ed8..00000000 --- a/apps/backend/src/db/migrations/158_manual_source_is_not_manual_route.sql +++ /dev/null @@ -1,57 +0,0 @@ --- 158_manual_source_is_not_manual_route.sql --- --- man: 只表示商品来源,不能证明当前由人工履约。 --- 兼容期内,自动发货 / 接单平台 / 第三方卡券这类直连来源允许暂时由 source_kind --- 推导当前渠道;人工客服必须有真实启用的 primary route,否则就是“未路由”。 - -CREATE OR REPLACE VIEW v_product_current AS -SELECT - product.*, - sku.internal_sku_key, - COALESCE( - primary_route.channel, - CASE product.source_kind - WHEN 'affiliate_dash' THEN 'affiliate_dash' - WHEN 'work_order' THEN 'work_order' - WHEN 'third_party_voucher' THEN 'third_party_voucher' - ELSE NULL - END - ) AS active_primary_channel, - COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, - manual_config.id AS manual_config_id, - (manual_config.id IS NOT NULL) AS has_active_manual_config -FROM product_catalog product -LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id -LEFT JOIN LATERAL ( - SELECT route.channel - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'primary' - ORDER BY route.priority, route.channel - LIMIT 1 -) primary_route ON TRUE -LEFT JOIN LATERAL ( - SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'fallback' -) fallback_routes ON TRUE -LEFT JOIN LATERAL ( - SELECT config.id - FROM manual_fulfillment_configs config - WHERE config.enabled = TRUE - AND ( - config.canonical_product_id = product.id - OR ( - sku.internal_sku_key IS NOT NULL - AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key - ) - ) - ORDER BY config.id - LIMIT 1 -) manual_config ON TRUE; - -COMMENT ON VIEW v_product_current IS - '标准商品统一投影:来源不等于当前渠道;manual 必须有真实主路由,直连来源兼容期允许回退 source_kind'; diff --git a/apps/backend/src/db/migrations/159_archive_orphan_manual_products.sql b/apps/backend/src/db/migrations/159_archive_orphan_manual_products.sql deleted file mode 100644 index bac427f1..00000000 --- a/apps/backend/src/db/migrations/159_archive_orphan_manual_products.sql +++ /dev/null @@ -1,49 +0,0 @@ --- 159_archive_orphan_manual_products.sql --- --- man: 只表示来源,不能证明当前可履约。 --- 没有主路由、没有启用人工配置、也没有启用商品映射的 man 商品属于孤儿商品, --- 只归档,不删除;历史订单、成本版本和成本快照继续保留。 - -WITH candidates AS ( - SELECT product.id - FROM product_catalog product - WHERE product.status = 'active' - AND product.source_kind = 'manual' - AND NOT EXISTS ( - SELECT 1 - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'primary' - ) - AND NOT EXISTS ( - SELECT 1 - FROM manual_fulfillment_configs config - WHERE config.enabled = TRUE - AND ( - config.canonical_product_id = product.id - OR config.internal_sku_id = product.internal_sku_id - OR canonicalize_internal_sku(config.sku_key) - = canonicalize_internal_sku(substring(product.canonical_sku from 5)) - ) - ) - AND NOT EXISTS ( - SELECT 1 - FROM channel_product_mappings mapping - WHERE mapping.canonical_product_id = product.id - AND mapping.enabled = TRUE - ) -) -UPDATE product_catalog product -SET status = 'archived', - attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( - 'legacyManualArchived', TRUE, - 'legacyManualArchiveReason', 'no_primary_route_no_config_no_mapping', - 'legacyManualArchivedAt', NOW() - ), - updated_at = NOW() -FROM candidates -WHERE product.id = candidates.id; - -COMMENT ON COLUMN product_catalog.source_kind IS - '标准商品来源类型;来源不等于当前履约渠道,孤儿 man 商品只归档不删除'; diff --git a/apps/backend/src/db/migrations/160_switch_fulfillment_routing_to_routes.sql b/apps/backend/src/db/migrations/160_switch_fulfillment_routing_to_routes.sql deleted file mode 100644 index 0101504b..00000000 --- a/apps/backend/src/db/migrations/160_switch_fulfillment_routing_to_routes.sql +++ /dev/null @@ -1,433 +0,0 @@ --- 160_switch_fulfillment_routing_to_routes.sql --- --- 硬切换履约路由真源: --- 1. 所有标准商品补齐 internal_sku_id; --- 2. 运行时唯一路由真源改为 sku_channel_routes; --- 3. 商品当前渠道不再回退 source_kind; --- 4. 履约读模型不再读取 internal_sku_channels; --- 5. 停止旧表触发同步,不再双写。 - -UPDATE product_catalog product -SET internal_sku_id = ensure_canonical_internal_sku(product.canonical_name), - updated_at = NOW() -WHERE product.internal_sku_id IS NULL - AND canonicalize_internal_sku(product.canonical_name) <> ''; - -CREATE OR REPLACE FUNCTION normalize_product_identity() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.source_kind := CASE - WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' - WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' - WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' - WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' - ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified') - END; - - IF NEW.internal_sku_id IS NULL THEN - NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.canonical_name); - END IF; - - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS trg_sync_sku_channel_routes ON internal_sku_channels; -DROP FUNCTION IF EXISTS sync_sku_channel_routes_trigger(); -DROP FUNCTION IF EXISTS sync_sku_channel_routes_for_key(TEXT); - -COMMENT ON TABLE internal_sku_channels IS - '冻结的旧路由输入表;迁移 160 后运行时不读不写,仅保留历史数据供追溯'; - -CREATE OR REPLACE VIEW v_product_current AS -SELECT - product.*, - sku.internal_sku_key, - primary_route.channel AS active_primary_channel, - COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, - manual_config.id AS manual_config_id, - (manual_config.id IS NOT NULL) AS has_active_manual_config -FROM product_catalog product -LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id -LEFT JOIN LATERAL ( - SELECT route.channel - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'primary' - ORDER BY route.priority, route.channel - LIMIT 1 -) primary_route ON TRUE -LEFT JOIN LATERAL ( - SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels - FROM sku_channel_routes route - WHERE route.internal_sku_id = product.internal_sku_id - AND route.enabled = TRUE - AND route.route_role = 'fallback' -) fallback_routes ON TRUE -LEFT JOIN LATERAL ( - SELECT config.id - FROM manual_fulfillment_configs config - WHERE config.enabled = TRUE - AND ( - config.canonical_product_id = product.id - OR ( - sku.internal_sku_key IS NOT NULL - AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key - ) - ) - ORDER BY config.id - LIMIT 1 -) manual_config ON TRUE; - -COMMENT ON VIEW v_product_current IS - '标准商品统一投影:来源与当前渠道分离,当前渠道只来自 sku_channel_routes'; - -CREATE OR REPLACE VIEW v_fulfillment_item_state AS -WITH active_items AS ( - SELECT - oi.*, - resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi - WHERE oi.fulfillment_status IN ('pending', 'assigned') -), -base AS ( - SELECT - oi.id AS order_item_id, - oi.order_id, - o.id AS local_order_id, - o.provider, - o.platform, - o.shop_id, - o.shop_name, - o.platform_order_id, - o.pay_status, - o.order_status, - oi.fulfillment_status AS item_fulfillment_status, - oi.fulfillment_channel AS stored_channel, - oi.fulfillment_source, - oi.fulfillment_locked, - oi.fulfillment_pending_reason, - oi.fulfillment_pending_detail, - oi.sku_code, - oi.sku_name, - oi.quantity, - oi.revenue_amount_fen, - oi.created_at, - oi.updated_at, - oi.internal_sku, - channel_plan.channel AS configured_channel, - channel_plan.config_json AS configured_channel_config, - COALESCE(channel_stats.channel_count, 0) AS channel_count, - COALESCE(latest_task.task_id, 0) AS latest_task_id, - latest_task.executor_key AS latest_task_executor_key, - latest_task.task_status AS latest_task_status, - latest_task.result_code AS latest_task_result_code, - latest_task.result_message AS latest_task_result_message, - latest_task.updated_at AS latest_task_updated_at, - COALESCE(latest_work_order.work_order_id, 0) AS latest_work_order_id, - latest_work_order.status AS latest_work_order_status, - latest_work_order.result_code AS latest_work_order_result_code, - latest_work_order.updated_at AS latest_work_order_updated_at, - EXISTS ( - SELECT 1 - FROM fulfillment_tasks active_task - WHERE active_task.order_item_id = oi.id - AND active_task.executor_key IN ('affiliate_dash', 'manual_dispatch') - AND active_task.task_status NOT IN ( - 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' - ) - ) AS has_active_task, - EXISTS ( - SELECT 1 - FROM work_orders active_work_order - WHERE active_work_order.order_item_id = oi.id - AND active_work_order.status <> 'cancelled' - ) AS has_active_work_order, - EXISTS ( - SELECT 1 - FROM manual_fulfillment_configs cfg - WHERE cfg.enabled = TRUE - AND canonicalize_internal_sku(cfg.sku_key) = oi.internal_sku - ) AS has_manual_config - FROM active_items oi - JOIN orders o ON o.id = oi.order_id - LEFT JOIN LATERAL ( - SELECT route.channel, route.carrier_config_json AS config_json - FROM sku_channel_routes route - JOIN internal_skus sku ON sku.id = route.internal_sku_id - WHERE route.enabled = TRUE - AND sku.internal_sku_key = oi.internal_sku - ORDER BY - (route.route_role = 'fallback'), - route.priority, - route.channel, - route.id - LIMIT 1 - ) channel_plan ON TRUE - LEFT JOIN LATERAL ( - SELECT COUNT(*)::int AS channel_count - FROM sku_channel_routes route - JOIN internal_skus sku ON sku.id = route.internal_sku_id - WHERE route.enabled = TRUE - AND sku.internal_sku_key = oi.internal_sku - ) channel_stats ON TRUE - LEFT JOIN LATERAL ( - SELECT - task.id AS task_id, - task.executor_key, - task.task_status, - task.result_code, - task.result_message, - task.updated_at - FROM fulfillment_tasks task - WHERE task.order_item_id = oi.id - ORDER BY task.id DESC - LIMIT 1 - ) latest_task ON TRUE - LEFT JOIN LATERAL ( - SELECT - work_order.id AS work_order_id, - work_order.status, - ''::text AS result_code, - work_order.updated_at - FROM work_orders work_order - WHERE work_order.order_item_id = oi.id - ORDER BY work_order.id DESC - LIMIT 1 - ) latest_work_order ON TRUE -), -classified AS ( - SELECT - base.*, - COALESCE(base.stored_channel, base.configured_channel) AS intended_channel, - CASE - WHEN base.pay_status = 'refunded' - OR base.order_status IN ('refunded', 'cancelled') - OR base.latest_task_result_code = 'USER_APPLY_REFUND' - THEN TRUE - ELSE FALSE - END AS is_user_refund, - CASE - WHEN base.pay_status = 'cancelled' - OR base.order_status = 'cancelled' - OR base.latest_task_result_code = 'affiliate_dash_cancelled' - THEN TRUE - ELSE FALSE - END AS is_platform_cancelled - FROM base -), -current_attempt AS ( - SELECT - classified.*, - CASE - WHEN classified.latest_task_id > 0 - AND ( - classified.latest_work_order_id = 0 - OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at - ) - THEN 'task' - WHEN classified.latest_work_order_id > 0 THEN 'work_order' - ELSE '' - END AS current_attempt_type, - CASE - WHEN classified.latest_task_id > 0 - AND ( - classified.latest_work_order_id = 0 - OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at - ) - THEN classified.latest_task_id - WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_id - ELSE 0 - END AS current_attempt_id, - CASE - WHEN classified.latest_task_id > 0 - AND ( - classified.latest_work_order_id = 0 - OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at - ) - THEN classified.latest_task_status - WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_status - ELSE '' - END AS current_attempt_status, - CASE - WHEN classified.latest_task_id > 0 - AND ( - classified.latest_work_order_id = 0 - OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at - ) - THEN classified.latest_task_result_code - WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_result_code - ELSE '' - END AS current_attempt_result_code - FROM classified -), -state_classified AS ( - SELECT - current_attempt.*, - CASE - WHEN current_attempt.intended_channel IN ('third_party_voucher', 'on_account') - THEN 'not_required' - WHEN current_attempt.intended_channel = 'manual' - AND current_attempt.has_manual_config - THEN 'ready' - WHEN current_attempt.has_active_task OR current_attempt.has_active_work_order - THEN 'ready' - WHEN current_attempt.latest_task_status IN ( - 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' - ) - THEN 'terminal' - WHEN current_attempt.latest_work_order_status = 'cancelled' - THEN 'terminal' - WHEN current_attempt.intended_channel IS NOT NULL - THEN 'missing' - ELSE 'missing' - END AS carrier_status, - CASE - WHEN COALESCE(current_attempt.internal_sku, '') = '' - THEN 'missing_internal_sku' - WHEN current_attempt.channel_count = 0 - THEN 'missing_channel' - ELSE 'configured' - END AS route_status - FROM current_attempt -), -issue_classified AS ( - SELECT - state_classified.*, - CASE - WHEN state_classified.is_user_refund THEN 'user_refund' - WHEN state_classified.is_platform_cancelled THEN 'platform_cancelled' - WHEN state_classified.route_status = 'missing_internal_sku' THEN 'missing_internal_sku' - WHEN state_classified.route_status = 'missing_channel' THEN 'missing_channel' - WHEN state_classified.intended_channel = 'manual' - AND NOT state_classified.has_manual_config - THEN 'manual_config_missing' - WHEN state_classified.intended_channel = 'work_order' - AND BTRIM(COALESCE( - state_classified.configured_channel_config->>'workRuleKey', - '' - )) = '' - THEN 'work_rule_missing' - WHEN state_classified.intended_channel = 'affiliate_dash' - AND BTRIM(COALESCE( - state_classified.configured_channel_config->>'affiliateSku', - '' - )) = '' - THEN 'affiliate_mapping_missing' - WHEN state_classified.carrier_status IN ('ready', 'not_required') THEN '' - WHEN state_classified.latest_task_status = 'manual_review' - THEN 'carrier_manual_review' - WHEN state_classified.latest_task_status IN ('failed', 'retry_pending') - THEN 'carrier_retry_pending' - WHEN state_classified.latest_task_status = 'expired' THEN 'carrier_expired' - WHEN state_classified.latest_task_status = 'closed' - OR state_classified.latest_work_order_status = 'cancelled' - THEN 'carrier_closed' - WHEN state_classified.item_fulfillment_status = 'assigned' - THEN 'state_inconsistent' - WHEN state_classified.intended_channel IN ( - 'affiliate_dash', 'work_order', 'manual' - ) - THEN 'carrier_missing' - ELSE 'state_inconsistent' - END AS issue_code - FROM state_classified -), -categorized AS ( - SELECT - issue_classified.*, - CASE - WHEN issue_code = '' THEN '' - WHEN issue_code IN ('user_refund', 'platform_cancelled') THEN 'after_sales' - WHEN issue_code = 'carrier_manual_review' THEN 'manual_review' - WHEN issue_code IN ('missing_internal_sku', 'missing_channel') THEN 'classification' - WHEN issue_code IN ( - 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing' - ) THEN 'configuration' - WHEN issue_code = 'state_inconsistent' THEN 'system' - ELSE 'carrier' - END AS issue_category - FROM issue_classified -) -SELECT - order_item_id, - order_id, - local_order_id, - provider, - platform, - shop_id, - shop_name, - platform_order_id, - pay_status, - order_status, - item_fulfillment_status AS fulfillment_status, - internal_sku, - intended_channel, - stored_channel, - configured_channel, - route_status, - carrier_status, - current_attempt_type, - current_attempt_id, - current_attempt_status, - current_attempt_result_code, - latest_task_id, - latest_task_executor_key, - latest_task_status, - latest_task_result_code, - latest_work_order_id, - latest_work_order_status, - issue_code, - issue_category, - CASE - WHEN issue_code = '' THEN FALSE - WHEN issue_code IN ( - 'user_refund', 'platform_cancelled', 'carrier_manual_review', - 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing', - 'missing_internal_sku', 'missing_channel' - ) THEN FALSE - ELSE TRUE - END AS retryable, - CASE - WHEN issue_code IN ('carrier_missing', 'state_inconsistent') THEN TRUE - ELSE FALSE - END AS auto_retryable, - CASE - WHEN issue_code IN ('user_refund', 'platform_cancelled', 'carrier_manual_review') - THEN 'support' - WHEN issue_code IN ( - 'missing_internal_sku', 'missing_channel', 'manual_config_missing', - 'work_rule_missing', 'affiliate_mapping_missing' - ) THEN 'operator' - WHEN issue_code = '' THEN '' - ELSE 'system' - END AS owner_role, - fulfillment_locked, - CASE - WHEN NOT fulfillment_locked THEN '' - ELSE COALESCE( - NULLIF(fulfillment_pending_detail->>'carrierIssue', ''), - NULLIF(fulfillment_pending_detail->>'reason', ''), - 'operator_assigned' - ) - END AS lock_reason, - fulfillment_pending_reason, - fulfillment_pending_detail, - fulfillment_source, - sku_code, - sku_name, - quantity, - revenue_amount_fen, - created_at, - updated_at, - GREATEST( - 0, - FLOOR(EXTRACT(EPOCH FROM (NOW() - created_at)) / 60) - )::bigint AS age_minutes -FROM categorized; - -COMMENT ON VIEW v_fulfillment_item_state IS - '履约统一读模型:每个订单项一行,仅读取 sku_channel_routes 的当前路由'; diff --git a/apps/backend/src/db/migrations/161_move_shadow_sync_to_sku_routes.sql b/apps/backend/src/db/migrations/161_move_shadow_sync_to_sku_routes.sql deleted file mode 100644 index 3b592402..00000000 --- a/apps/backend/src/db/migrations/161_move_shadow_sync_to_sku_routes.sql +++ /dev/null @@ -1,46 +0,0 @@ --- 161_move_shadow_sync_to_sku_routes.sql --- --- 路由唯一真源已经切换到 sku_channel_routes。 --- 履约影子模型必须跟随新表变化,旧表不再拥有任何同步触发器。 - -DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow ON internal_sku_channels; -DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_internal_channel(); - -CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_sku_route() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -DECLARE - old_key TEXT; - new_key TEXT; -BEGIN - IF TG_OP <> 'INSERT' THEN - SELECT internal_sku_key INTO old_key - FROM internal_skus - WHERE id = OLD.internal_sku_id; - END IF; - IF TG_OP <> 'DELETE' THEN - SELECT internal_sku_key INTO new_key - FROM internal_skus - WHERE id = NEW.internal_sku_id; - END IF; - - PERFORM sync_fulfillment_shadow(item.id) - FROM order_items item - WHERE item.fulfillment_status IN ('pending', 'assigned') - AND canonicalize_internal_sku( - resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) - ) IN (old_key, new_key); - - RETURN COALESCE(NEW, OLD); -END; -$$; - -DROP TRIGGER IF EXISTS trg_sku_route_fulfillment_shadow ON sku_channel_routes; -CREATE TRIGGER trg_sku_route_fulfillment_shadow -AFTER INSERT OR UPDATE OR DELETE ON sku_channel_routes -FOR EACH ROW -EXECUTE FUNCTION sync_fulfillment_shadow_for_sku_route(); - -COMMENT ON FUNCTION sync_fulfillment_shadow_for_sku_route() IS - 'sku_channel_routes 变化后刷新同内部 SKU 的履约影子'; diff --git a/apps/backend/src/db/migrations/162_drop_legacy_routing_read_surfaces.sql b/apps/backend/src/db/migrations/162_drop_legacy_routing_read_surfaces.sql deleted file mode 100644 index c090e79d..00000000 --- a/apps/backend/src/db/migrations/162_drop_legacy_routing_read_surfaces.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 162_drop_legacy_routing_read_surfaces.sql --- --- 160/161 已把业务读模型和影子同步切到 sku_channel_routes。 --- 这里删除最后一个旧表只读视图,以及旧表最后一个规范化触发器。 - -DROP VIEW IF EXISTS v_fulfillment_item_state_raw; - -DROP TRIGGER IF EXISTS trg_normalize_internal_sku_channel_key ON internal_sku_channels; -DROP FUNCTION IF EXISTS normalize_internal_sku_channel_key(); - -COMMENT ON TABLE internal_sku_channels IS - '冻结的历史路由输入;无触发器、无运行时读取、无运行时写入'; diff --git a/apps/backend/src/db/migrations/164_fulfillment_event_cascade.sql b/apps/backend/src/db/migrations/164_fulfillment_event_cascade.sql deleted file mode 100644 index 1e394cb0..00000000 --- a/apps/backend/src/db/migrations/164_fulfillment_event_cascade.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 164_fulfillment_event_cascade.sql --- --- 履约事件已经由 fulfillment_id 直接归属正式履约记录。 --- 订单项删除时事件应随履约记录级联清理,不能先 SET NULL 再触发 append-only 更新保护。 - -ALTER TABLE fulfillment_state_events - DROP CONSTRAINT IF EXISTS fk_fulfillment_state_events_fulfillment; - -ALTER TABLE fulfillment_state_events - ADD CONSTRAINT fk_fulfillment_state_events_fulfillment - FOREIGN KEY (fulfillment_id) - REFERENCES fulfillments(id) - ON DELETE CASCADE; diff --git a/apps/backend/src/db/migrations/165_fulfillment_event_append_only_update.sql b/apps/backend/src/db/migrations/165_fulfillment_event_append_only_update.sql deleted file mode 100644 index da17d291..00000000 --- a/apps/backend/src/db/migrations/165_fulfillment_event_append_only_update.sql +++ /dev/null @@ -1,16 +0,0 @@ --- 165_fulfillment_event_append_only_update.sql --- --- 事件语义是“应用不可改”,父记录删除时允许数据库级联清理。 --- 旧环境里曾存在 BEFORE DELETE OR UPDATE 的漂移定义,这里统一收回为只阻止 UPDATE。 - -DROP TRIGGER IF EXISTS trg_fulfillment_state_events_append_only - ON fulfillment_state_events; - -CREATE TRIGGER trg_fulfillment_state_events_append_only -BEFORE UPDATE ON fulfillment_state_events -FOR EACH ROW -EXECUTE FUNCTION reject_fulfillment_state_event_mutation(); - -COMMENT ON TRIGGER trg_fulfillment_state_events_append_only - ON fulfillment_state_events IS - '禁止应用修改履约事件;数据库外键级联删除不受影响'; diff --git a/apps/backend/src/db/migrations/166_fulfillment_insert_resolved_sku.sql b/apps/backend/src/db/migrations/166_fulfillment_insert_resolved_sku.sql deleted file mode 100644 index c160c2cc..00000000 --- a/apps/backend/src/db/migrations/166_fulfillment_insert_resolved_sku.sql +++ /dev/null @@ -1,59 +0,0 @@ --- 166_fulfillment_insert_resolved_sku.sql --- --- resolve_internal_sku 可能返回 NULL。正式履约行的 internal_sku 是不带 NULL 的稳定键, --- 无法解析时统一落空字符串,并立即进入 missing_internal_sku 异常口径。 - -CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO fulfillments ( - order_item_id, - order_id, - internal_sku, - route_status, - channel, - carrier_status, - current_attempt_type, - current_attempt_source_id, - current_attempt_status, - lock_reason, - issue_code, - issue_category, - retryable, - owner_role, - created_at, - updated_at, - status, - source, - locked, - channel_switch_reason - ) VALUES ( - NEW.id, - NEW.order_id, - COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''), - 'unrouted', - NULL, - 'missing', - '', - NULL, - '', - '', - '', - '', - FALSE, - '', - NEW.created_at, - NOW(), - 'pending', - '', - FALSE, - '' - ) - ON CONFLICT (order_item_id) DO NOTHING; - - PERFORM refresh_fulfillment_derived(NEW.id); - RETURN NEW; -END; -$$; diff --git a/apps/backend/src/db/migrations/168_product_source_kind_constraint.sql b/apps/backend/src/db/migrations/168_product_source_kind_constraint.sql deleted file mode 100644 index 32fce05c..00000000 --- a/apps/backend/src/db/migrations/168_product_source_kind_constraint.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 168_product_source_kind_constraint.sql --- --- 商品来源统一由 product_catalog.source_kind 表达。 --- 运行期不再解析 canonical_sku 前缀;数据库同步收紧允许值。 - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'chk_product_catalog_source_kind' - ) THEN - ALTER TABLE product_catalog - ADD CONSTRAINT chk_product_catalog_source_kind - CHECK ( - source_kind IN ( - 'affiliate_dash', - 'work_order', - 'third_party_voucher', - 'manual', - 'unclassified' - ) - ) - NOT VALID; - END IF; -END $$; - -ALTER TABLE product_catalog - VALIDATE CONSTRAINT chk_product_catalog_source_kind; diff --git a/apps/backend/src/services/finance/finance-controls.test.ts b/apps/backend/src/services/finance/finance-controls.test.ts index 31f75820..fe9a42bf 100644 --- a/apps/backend/src/services/finance/finance-controls.test.ts +++ b/apps/backend/src/services/finance/finance-controls.test.ts @@ -157,7 +157,7 @@ test('销售毛利必须同时暴露缺失成本收入,避免只看毛利绝 test('销售事实必须只统计已支付订单,并扣减已确认退款', async () => { const [source, scopeMigration] = await Promise.all([ read(new URL('./sales-facts-channel-service.ts', import.meta.url)), - read(new URL('../../db/migrations/167_finance_derived_scope.sql', import.meta.url)), + read(new URL('../../db/migrations/149_finance_derived_scope.sql', import.meta.url)), ]) assert.ok( scopeMigration.includes("order_record.pay_status IN ('paid', 'refunded')"), diff --git a/apps/backend/src/services/finance/finance-derived-rebuild.test.ts b/apps/backend/src/services/finance/finance-derived-rebuild.test.ts index 72978757..274b8a49 100644 --- a/apps/backend/src/services/finance/finance-derived-rebuild.test.ts +++ b/apps/backend/src/services/finance/finance-derived-rebuild.test.ts @@ -6,7 +6,7 @@ function read(relativePath: string) { return readFileSync(new URL(relativePath, import.meta.url), 'utf8') } -const migration = read('../../db/migrations/167_finance_derived_scope.sql') +const migration = read('../../db/migrations/149_finance_derived_scope.sql') const salesFacts = read('./sales-facts-channel-service.ts') const ledger = read('./finance-ledger-service.ts') const rebuild = read('../../../scripts/rebuild-finance-derived.ts') diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts index d624aa5f..1587d7ab 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts @@ -7,7 +7,7 @@ function read(relativePath: string) { } const migration = read('../../db/migrations/140_fulfillment_state_events.sql') -const cutoverMigration = read('../../db/migrations/163_cutover_fulfillment_truth.sql') +const cutoverMigration = read('../../db/migrations/148_fulfillment_execution_truth.sql') const stateService = read('./fulfillment-state-service.ts') const channelAssign = read('./channel-assign-service.ts') const carrierReconciliation = read('./carrier-reconciliation-service.ts') diff --git a/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts index a2812b11..291ab44c 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts @@ -15,15 +15,15 @@ const configSyncMigration = readFileSync( 'utf8', ) const routeCutoverMigration = readFileSync( - new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', import.meta.url), + new URL('../../db/migrations/147_internal_sku_product_routing.sql', import.meta.url), 'utf8', ) const truthCutoverMigration = readFileSync( - new URL('../../db/migrations/163_cutover_fulfillment_truth.sql', import.meta.url), + new URL('../../db/migrations/148_fulfillment_execution_truth.sql', import.meta.url), 'utf8', ) const insertSkuMigration = readFileSync( - new URL('../../db/migrations/166_fulfillment_insert_resolved_sku.sql', import.meta.url), + new URL('../../db/migrations/148_fulfillment_execution_truth.sql', import.meta.url), 'utf8', ) @@ -35,7 +35,7 @@ test('正式履约模型包含履约、尝试和异常三张表', () => { assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)')) }) -test('迁移 163 删除影子双写并直接维护正式履约真源', () => { +test('迁移 148 删除影子双写并直接维护正式履约真源', () => { assert.ok( truthCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow'), ) diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index cc49e9e2..b7b91409 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.2 +version: 2.3 updated_at: 2026-11-08 --- @@ -12,7 +12,7 @@ updated_at: 2026-11-08 > 状态:**唯一权威文档**。 > 最后校准:2026-11-08。 > 生产基线:迁移 `146`。 -> 开发基线:迁移 `168`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `149`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -106,7 +106,7 @@ updated_at: 2026-11-08 - 计算成本。 - 决定标准商品。 -`order_items` 只保存订单项与平台商品事实。迁移 `163` 后,重复的履约状态、 +`order_items` 只保存订单项与平台商品事实。迁移 `148` 后,重复的履约状态、 渠道、配置关联和商品归一字段已改名为 `legacy_*` 冻结留档; 运行时不得读取或写入这些旧列。订单项履约状态统一从 `v_order_item_current` 或 `fulfillments` 读取。 @@ -332,7 +332,7 @@ finance_journal_* -- 总账凭证,可重建 2026-11-08 生产只读核对结果: -- 生产迁移 `146`,本地开发基线迁移 `168`。 +- 生产迁移 `146`,本地开发基线迁移 `149`。 - 启用标准商品 `1127`:`aff:` 30、`tpl:` 903、`tpv:` 46、`man:` 148。 - `man:` 商品中只有 105 个当前主渠道为人工;41 个已走其他主渠道,2 个无主渠道。 - 人工页头部显示 `107`,商品列表显示 `148`,相差的 41 个就是跨渠道旧商品。 @@ -594,26 +594,12 @@ assigned 且 carrier_status=missing 必须进入异常队列 - 变更前备份:`backups/postgres/order_site-pre-unified-20261108.dump`, SHA-256 `661d7751d76fcd7db80af92e97d1e84245a25c60b0ec76b8d83ca930fcbb5142`。 -- 迁移 `154`:建立 `internal_skus`、`sku_channel_routes` 与兼容同步触发器。 -- 迁移 `155`:建立 `v_product_current`,并归档可确认失效的旧 `man:` 商品。 -- 迁移 `156`:修复 `source_kind` 默认值导致的前缀回填失效。 -- 迁移 `157`:保证只有兜底配置的 SKU 不会被误升级成主渠道。 -- 迁移 `158`:禁止 `man:` 来源直接冒充人工主渠道;没有 primary route 时显示“未路由”。 -- 迁移 `159`:只归档无主路由、无人工配置、无启用映射的孤儿 `man:` 商品。 -- 迁移 `160`:商品身份全部补齐,运行硬切到 `sku_channel_routes`,删除旧同步触发器; - `v_product_current` 不再回退 `source_kind`,`v_fulfillment_item_state` 不再读取旧表。 -- 迁移 `161`:履约影子同步触发器从旧表迁移到 `sku_channel_routes`。 -- 迁移 `162`:删除旧路由只读视图和旧表最后一个触发器。 -- 迁移 `163`:履约状态硬切到 `fulfillments`;新增正式状态、配置关联和商品字段; - 任务/工单只维护尝试历史;订单项旧状态列改名冻结;建立 `v_order_item_current`。 -- 迁移 `164`:履约事件外键改为随正式履约记录级联删除。 -- 迁移 `165`:履约事件只阻止 UPDATE,数据库级联删除不再被 append-only 触发器阻断。 -- 迁移 `166`:无法解析内部 SKU 时,正式履约行的 `internal_sku` 统一落空字符串, - 并进入 `missing_internal_sku` 异常,而不是触发非空约束。 -- 迁移 `167`:建立 `v_finance_order_item_scope`,统一销售事实、重建与审计的收入、 - 退款、商品、渠道和业务日口径;增加销售凭证按日查询索引。 -- 迁移 `168`:商品来源 `source_kind` 增加数据库约束;商品筛选、人工 SKU 清单和成本 - 分支统一读取 `source_kind` / `internal_sku_id`,运行期不再按 `canonical_sku` 前缀判断来源。 +- 迁移 `147`:统一内部 SKU、标准商品来源与履约路由;建立 `internal_skus`、 + `sku_channel_routes`、`v_product_current` 和商品来源约束,完成旧路由读取面下线。 +- 迁移 `148`:履约状态、配置、载体和当前尝试硬切到 `fulfillments`; + 任务/工单只维护尝试历史;订单项旧履约列冻结并统一读取 `v_order_item_current`。 +- 迁移 `149`:建立 `v_finance_order_item_scope`,统一销售事实、重建与审计的收入、 + 退款、商品、渠道和业务日口径,并增加销售凭证按日查询索引。 - 商品列表、总览、渠道映射和商品下拉统一读取商品当前渠道。 - 运行期代码停止写入 `product_channel_capabilities`,该表仅保留到兼容期结束。 - 只读审计:`npm run audit:product-routing -- --strict`。 @@ -621,7 +607,7 @@ assigned 且 carrier_status=missing 必须进入异常队列 - 自动/卡券/接单主渠道商品的人工配置均保留为显式 `fallback`,没有被误升为人工主渠道。 - 1 个无主路由、无人工配置、无启用映射的 `man:` 商品已归档,未删除历史引用。 - 旧 `internal_sku_channels` 已冻结:运行时不再读、不再写,也不再有视图或触发器依赖。 -- 空库从迁移 `001` 顺序执行到 `168` 已通过;新库不再保留任何旧履约影子同步触发器。 +- 空库从迁移 `001` 顺序执行到 `149` 已通过;新库不再保留任何旧履约影子同步触发器。 - 无主路由的活动商品会进入“未路由”,不自动猜测渠道。 当前本地仍需业务处理的数据: @@ -681,7 +667,7 @@ assigned 且 carrier_status=missing 必须进入异常队列 5. 全部业务读 SQL 切到 `v_order_item_current`,禁止直接读取订单项旧状态列。 6. 状态事件、任务尝试、工单尝试和配置变化触发器统一围绕正式履约表工作。 7. 真实 PostgreSQL 集成测试覆盖状态写入、幂等、事件留痕、当前尝试和配置关联。 -8. 本地存量库与空库 `001 → 167` 均完成硬切验证。 +8. 新空库 `001 → 149` 与迁移 `145` 备份升级库均完成硬切验证。 验收: @@ -757,6 +743,8 @@ npm run audit:finance -- --strict 本地已完成: +- 数据库迁移由 `154–168` 重组为 `147–149`:内部 SKU/商品路由、履约执行、 + 财务派生三段各一个文件;未迁入旧 `dev` 的 `147–153`。 - 删除会直接写已改名订单项列的 `reclassify-post-cutover.ts` 和 `merge-duplicate-products.ts`。 - 删除重复的 `finance:rebuild-cutover` 入口,部署脚本统一调用 @@ -772,10 +760,10 @@ npm run audit:finance -- --strict 生产升级演练(本地临时库): - 输入:`backups/postgres/order_site-20261106-192520.dump`,备份迁移 `145`; - 恢复后执行 `146 → 168`。 + 恢复后执行 `146 → 149`。 - 数据规模:订单 `456777`、订单项 `456762`、商品 `1572`、正式履约 `456762`。 - 升级和迁移全部成功;恢复时唯一失败的是备份自身依赖顺序导致的函数索引, - 在函数恢复后按原定义补建,随后迁移 `163` 正常删除该索引。 + 在函数恢复后按原定义补建,随后迁移 `148` 正常删除该索引。 - 财务重建前存在旧事实/总账差异;执行一次 `finance:rebuild-derived --execute` 后, 销售范围 `21653` 项、收入 `170241674` 分、退款 `77370` 分、成本 `104881187` 分, 事实与总账完全对平,严格审计 `critical: []`。 @@ -880,7 +868,7 @@ docs/archive/ 本地阶段 0–6 已按硬切方案完成,生产仍停留在迁移 `146`。后续顺序固定为: 1. 生产执行前重新生成全量备份,并核对备份迁移号确为 `146`。 -2. 在生产依次执行迁移 `147 → 168`,每阶段运行对应严格审计; +2. 在生产依次执行迁移 `147 → 149`,每阶段运行对应严格审计; 无法确定的数据进入未路由或待分类,不做猜测。 3. 执行一次 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index d64fce9e..b52f9f64 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -219,7 +219,7 @@ docker compose ps docker compose logs -f backend ``` -## 财务/成本上线后的初始化(当前开发基线 168) +## 财务/成本上线后的初始化(当前开发基线 149) 代码与迁移只负责建表;**存量订单回填、备用金账户、积分余额、商品目录、人工 SKU 不会自动创建**。 部署成功后按「回填 → 商品初始化 → 财务同步 → 验收」四步走: diff --git a/scripts/verify-finance-launch.sql b/scripts/verify-finance-launch.sql index 854ee9fa..bbd795ed 100644 --- a/scripts/verify-finance-launch.sql +++ b/scripts/verify-finance-launch.sql @@ -73,7 +73,7 @@ INSERT INTO _launch_checks SELECT 3, '03', '履约状态合法(fulfillments.status)', v::text, '0', CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END, - '存在非法状态:检查迁移 163 与履约状态写入口' + '存在非法状态:检查迁移 148 与履约状态写入口' FROM ( SELECT count(*) AS v FROM fulfillments