fix(finance): 重写履约路由迁移与统一渠道配置

重写 120/121 迁移,统一内部 SKU、渠道 id、config_json 和旧规则转换。

后台渠道配置改读统一渠道表,卡券与人工配置不再写旧规则。

增加安全渠道切换,并排除已关闭、失败或过期的历史任务载体。

按生产 119 基线重跑迁移、完整履约财务链路和严格预检。
This commit is contained in:
yml2213
2026-11-02 15:29:13 +08:00
parent 2f87445ed9
commit 6bf28489e0
31 changed files with 958 additions and 464 deletions
@@ -1,10 +1,22 @@
-- 120_fulfillment_routing_invariants.sql
--
-- 履约路由唯一口径与数据清理:
-- 1. 订单项内部 SKU 由一个不可变 SQL 函数统一解析;
-- 2. 人工配置自动成为 internal_sku_channels 的 manual 能力;
-- 3. manual 与其他渠道并存时统一降为兜底,避免同优先级被静默抢占;
-- 4. 可确定的旧 sku_code 规则迁移为内部 SKU 渠道并停用旧规则。
-- 生产迁移 119 之后的履约路由统一迁移。
--
-- 目标:
-- 1. 订单项内部 SKU 只有一个 SQL 解析口径;
-- 2. internal_sku_channels 成为唯一渠道配置源;
-- 3. 人工配置、旧 sku_code 规则和渠道适配参数全部回填到统一表;
-- 4. 原有 sku_code 规则只做一次性迁移,迁移后可确定的规则全部停用。
-- 稳定 id 让后台渠道配置可以通过统一 API 增删改,而不必继续暴露旧规则表。
ALTER TABLE internal_sku_channels
ADD COLUMN IF NOT EXISTS id BIGSERIAL,
ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS created_by_admin_user_id BIGINT
REFERENCES admin_users(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_internal_sku_channels_id
ON internal_sku_channels (id);
CREATE OR REPLACE FUNCTION resolve_internal_sku(
p_manual_sku_key TEXT,
@@ -33,7 +45,7 @@ $$;
COMMENT ON FUNCTION resolve_internal_sku(TEXT, TEXT, TEXT) IS
'订单项内部 SKU 唯一 SQL 口径:中文 manual_sku_key/sku_name 优先,再按历史生成列顺序回退';
-- 人工配置本身就是一个 manual 渠道声明。存在其他渠道时默认作为 90 优先级兜底。
-- 人工配置就是 manual 渠道声明。存在其他渠道时先按 90 优先级兜底。
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
@@ -42,15 +54,13 @@ SELECT
BTRIM(cfg.sku_key),
'manual',
CASE WHEN EXISTS (
SELECT 1
FROM internal_sku_channels c
SELECT 1 FROM internal_sku_channels c
WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key)
AND c.enabled = TRUE
AND c.channel <> 'manual'
) THEN 90 ELSE 10 END,
CASE WHEN EXISTS (
SELECT 1
FROM internal_sku_channels c
SELECT 1 FROM internal_sku_channels c
WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key)
AND c.enabled = TRUE
AND c.channel <> 'manual'
@@ -69,20 +79,241 @@ SELECT
FROM manual_fulfillment_configs cfg
WHERE BTRIM(COALESCE(cfg.sku_key, '')) <> ''
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = CASE
WHEN internal_sku_channels.source = 'operator' THEN internal_sku_channels.priority
ELSE EXCLUDED.priority
END,
is_fallback = CASE
WHEN internal_sku_channels.source = 'operator' THEN internal_sku_channels.is_fallback
ELSE EXCLUDED.is_fallback
END,
priority = CASE WHEN internal_sku_channels.source = 'operator'
THEN internal_sku_channels.priority ELSE EXCLUDED.priority END,
is_fallback = CASE WHEN internal_sku_channels.source = 'operator'
THEN internal_sku_channels.is_fallback ELSE EXCLUDED.is_fallback END,
enabled = EXCLUDED.enabled,
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
updated_at = NOW();
-- 只要同一内部 SKU 还有自动发货/接单/卡券能力,人工渠道就不能作为并列主渠道。
-- 旧 sku_code 规则能稳定解析出的内部 SKU,统一补到入口映射表。
WITH legacy_candidates AS (
SELECT
rule.provider,
rule.shop_id,
rule.match_key AS sku_code,
BTRIM(product.canonical_name) AS internal_sku,
10 AS priority
FROM sku_channel_rules rule
JOIN product_catalog product
ON product.canonical_sku = CONCAT('aff:', rule.match_key)
AND product.status = 'active'
WHERE rule.match_key_type = 'sku_code'
AND BTRIM(COALESCE(product.canonical_name, '')) <> ''
UNION ALL
SELECT
rule.provider,
rule.shop_id,
rule.match_key AS sku_code,
matched.internal_sku,
20 AS priority
FROM sku_channel_rules rule
CROSS JOIN LATERAL (
SELECT
MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku,
COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count
FROM order_items oi
JOIN orders source_order ON source_order.id = oi.order_id
WHERE oi.sku_code = rule.match_key
AND (rule.provider = '' OR source_order.provider = rule.provider)
AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id)
) matched
WHERE rule.match_key_type = 'sku_code'
AND matched.internal_count = 1
AND BTRIM(COALESCE(matched.internal_sku, '')) <> ''
),
ranked_legacy_candidates AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY provider, shop_id, sku_code
ORDER BY priority, internal_sku
) AS candidate_rank
FROM legacy_candidates
)
INSERT INTO internal_sku_mappings (
provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note, created_at, updated_at
)
SELECT
provider, shop_id, 'sku_code', sku_code, internal_sku,
priority, TRUE, 'legacy-rule', '由旧 sku_code 渠道规则迁移', NOW(), NOW()
FROM ranked_legacy_candidates
WHERE candidate_rank = 1
ON CONFLICT (provider, shop_id, match_type, match_value) DO UPDATE SET
internal_sku = EXCLUDED.internal_sku,
priority = LEAST(internal_sku_mappings.priority, EXCLUDED.priority),
enabled = TRUE,
source = EXCLUDED.source,
note = EXCLUDED.note,
updated_at = NOW();
-- 可确定的旧 sku_code 渠道转换为 internal_sku_channels。
WITH resolved_rules AS (
SELECT
rule.id,
rule.provider,
rule.shop_id,
rule.match_key,
rule.channel,
rule.priority,
COALESCE(
(
SELECT BTRIM(product.canonical_name)
FROM product_catalog product
WHERE product.canonical_sku = CONCAT('aff:', rule.match_key)
AND product.status = 'active'
ORDER BY product.id
LIMIT 1
),
matched.internal_sku
) AS internal_sku,
matched.canonical_product_id
FROM sku_channel_rules rule
LEFT JOIN LATERAL (
SELECT
MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku,
COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count,
MIN(oi.canonical_product_id) AS canonical_product_id
FROM order_items oi
JOIN orders source_order ON source_order.id = oi.order_id
WHERE oi.sku_code = rule.match_key
AND (rule.provider = '' OR source_order.provider = rule.provider)
AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id)
) matched ON matched.internal_count = 1
WHERE rule.match_key_type = 'sku_code'
AND rule.enabled = TRUE
)
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT
BTRIM(internal_sku),
channel,
MIN(priority),
FALSE,
TRUE,
MIN(canonical_product_id),
jsonb_build_object(
'legacySkuCode', MIN(match_key),
'legacyProvider', MIN(provider),
'legacyShopId', MIN(shop_id)
),
'legacy-rule',
NOW(),
NOW()
FROM resolved_rules
WHERE BTRIM(COALESCE(internal_sku, '')) <> ''
GROUP BY BTRIM(internal_sku), channel
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
enabled = TRUE,
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
updated_at = NOW();
-- 已转成内部 SKU 的旧规则停用;真正无法解析的规则保留 enabled,交给严格预检阻断。
WITH resolved_rule_ids AS (
SELECT rule.id
FROM sku_channel_rules rule
LEFT JOIN LATERAL (
SELECT
MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku,
COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count
FROM order_items oi
JOIN orders source_order ON source_order.id = oi.order_id
WHERE oi.sku_code = rule.match_key
AND (rule.provider = '' OR source_order.provider = rule.provider)
AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id)
) matched ON matched.internal_count = 1
WHERE rule.match_key_type = 'sku_code'
AND rule.enabled = TRUE
AND (
EXISTS (
SELECT 1 FROM product_catalog product
WHERE product.canonical_sku = CONCAT('aff:', rule.match_key)
AND product.status = 'active'
)
OR BTRIM(COALESCE(matched.internal_sku, '')) <> ''
)
)
UPDATE sku_channel_rules rule
SET enabled = FALSE,
note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'),
updated_at = NOW()
FROM resolved_rule_ids
WHERE rule.id = resolved_rule_ids.id;
-- 旧 internal_sku 规则已完整复制到统一表,不再参与运行时判定。
UPDATE sku_channel_rules
SET enabled = FALSE,
note = CONCAT(COALESCE(note, ''), ' [internal_sku_channels 已成为真源]'),
updated_at = NOW()
WHERE match_key_type = 'internal_sku' AND enabled = TRUE;
-- 回填适配参数,避免统一表只有“渠道名”而实际还要猜 affiliate SKU / 工单模板。
WITH affiliate_config AS (
SELECT
BTRIM(product.canonical_name) AS internal_sku,
MIN(SUBSTRING(product.canonical_sku FROM 5)) AS affiliate_sku,
MIN(product.id) AS canonical_product_id
FROM product_catalog product
WHERE product.status = 'active' AND product.canonical_sku LIKE 'aff:%'
GROUP BY BTRIM(product.canonical_name)
)
UPDATE internal_sku_channels channel
SET config_json = channel.config_json || jsonb_build_object(
'affiliateSku', affiliate_config.affiliate_sku
),
canonical_product_id = COALESCE(channel.canonical_product_id, affiliate_config.canonical_product_id),
updated_at = NOW()
FROM affiliate_config
WHERE channel.channel = 'affiliate_dash'
AND BTRIM(channel.internal_sku) = affiliate_config.internal_sku;
WITH work_order_config AS (
SELECT
BTRIM(product.canonical_name) AS internal_sku,
MIN(SUBSTRING(product.canonical_sku FROM 5)) AS work_rule_key,
MIN(product.id) AS canonical_product_id
FROM product_catalog product
WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpl:%'
GROUP BY BTRIM(product.canonical_name)
)
UPDATE internal_sku_channels channel
SET config_json = channel.config_json || jsonb_build_object(
'workRuleKey', work_order_config.work_rule_key
),
canonical_product_id = COALESCE(channel.canonical_product_id, work_order_config.canonical_product_id),
updated_at = NOW()
FROM work_order_config
WHERE channel.channel = 'work_order'
AND BTRIM(channel.internal_sku) = work_order_config.internal_sku;
WITH voucher_config AS (
SELECT
BTRIM(product.canonical_name) AS internal_sku,
MIN(SUBSTRING(product.canonical_sku FROM 5)) AS voucher_sku,
MIN(product.id) AS canonical_product_id
FROM product_catalog product
WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpv:%'
GROUP BY BTRIM(product.canonical_name)
)
UPDATE internal_sku_channels channel
SET config_json = channel.config_json || jsonb_build_object(
'voucherSku', voucher_config.voucher_sku
),
canonical_product_id = COALESCE(channel.canonical_product_id, voucher_config.canonical_product_id),
updated_at = NOW()
FROM voucher_config
WHERE channel.channel = 'third_party_voucher'
AND BTRIM(channel.internal_sku) = voucher_config.internal_sku;
-- 只要同一内部 SKU 还有非人工渠道,人工统一降为显式兜底。
UPDATE internal_sku_channels manual_channel
SET priority = GREATEST(manual_channel.priority, 90),
is_fallback = TRUE,
@@ -98,128 +329,17 @@ WHERE manual_channel.channel = 'manual'
AND other_channel.channel <> 'manual'
);
-- 自动发货旧 sku_code 规则能通过 aff:<sku> 商品稳定定位内部 SKU,直接迁移。
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT
BTRIM(product.canonical_name),
'affiliate_dash',
MIN(rule.priority),
FALSE,
TRUE,
MIN(product.id),
jsonb_build_object('legacySkuCode', MIN(rule.match_key)),
'legacy-rule',
NOW(),
NOW()
FROM sku_channel_rules rule
JOIN product_catalog product
ON product.canonical_sku = CONCAT('aff:', rule.match_key)
AND product.status = 'active'
WHERE rule.match_key_type = 'sku_code'
AND rule.channel = 'affiliate_dash'
AND rule.enabled = TRUE
AND BTRIM(COALESCE(product.canonical_name, '')) <> ''
GROUP BY BTRIM(product.canonical_name)
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
enabled = TRUE,
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
updated_at = NOW();
UPDATE sku_channel_rules rule
SET enabled = FALSE,
note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'),
updated_at = NOW()
WHERE rule.match_key_type = 'sku_code'
AND rule.channel = 'affiliate_dash'
AND rule.enabled = TRUE
AND EXISTS (
SELECT 1
FROM product_catalog product
WHERE product.canonical_sku = CONCAT('aff:', rule.match_key)
AND product.status = 'active'
AND BTRIM(COALESCE(product.canonical_name, '')) <> ''
);
-- 卡券旧规则按历史订单中唯一可解析的内部 SKU 迁移;歧义数据保持原规则不动。
WITH resolved_legacy AS (
SELECT
rule.id,
matched.internal_sku,
MIN(rule.priority) AS priority
FROM sku_channel_rules rule
CROSS JOIN LATERAL (
SELECT
MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku,
COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count
FROM order_items oi
JOIN orders source_order ON source_order.id = oi.order_id
WHERE oi.sku_code = rule.match_key
AND (rule.provider = '' OR source_order.provider = rule.provider)
AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id)
) matched
WHERE rule.match_key_type = 'sku_code'
AND rule.channel = 'third_party_voucher'
AND rule.enabled = TRUE
AND matched.internal_count = 1
AND BTRIM(COALESCE(matched.internal_sku, '')) <> ''
GROUP BY rule.id, matched.internal_sku
)
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
config_json, source, created_at, updated_at
)
SELECT
internal_sku,
'third_party_voucher',
priority,
FALSE,
TRUE,
'{}'::jsonb,
'legacy-rule',
NOW(),
NOW()
FROM resolved_legacy
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
enabled = TRUE,
updated_at = NOW();
WITH resolved_legacy AS (
SELECT rule.id
FROM sku_channel_rules rule
CROSS JOIN LATERAL (
SELECT
MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku,
COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count
FROM order_items oi
JOIN orders source_order ON source_order.id = oi.order_id
WHERE oi.sku_code = rule.match_key
AND (rule.provider = '' OR source_order.provider = rule.provider)
AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id)
) matched
WHERE rule.match_key_type = 'sku_code'
AND rule.channel = 'third_party_voucher'
AND rule.enabled = TRUE
AND matched.internal_count = 1
AND BTRIM(COALESCE(matched.internal_sku, '')) <> ''
)
UPDATE sku_channel_rules rule
SET enabled = FALSE,
note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'),
updated_at = NOW()
FROM resolved_legacy
WHERE rule.id = resolved_legacy.id;
ALTER TABLE internal_sku_channels
DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_priority_nonnegative;
ALTER TABLE internal_sku_channels
ADD CONSTRAINT chk_internal_sku_channels_priority_nonnegative CHECK (priority >= 0);
ALTER TABLE internal_sku_channels
DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_config_object;
ALTER TABLE internal_sku_channels
ADD CONSTRAINT chk_internal_sku_channels_config_object
CHECK (jsonb_typeof(config_json) = 'object');
CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_fallback
ON internal_sku_channels (internal_sku, priority, channel)
WHERE enabled = TRUE AND is_fallback = TRUE;
@@ -23,3 +23,9 @@ END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_internal_sku_channels_single_primary
ON internal_sku_channels (internal_sku)
WHERE enabled = TRUE AND is_fallback = FALSE;
ALTER TABLE internal_sku_channels
DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_nonempty_trimmed;
ALTER TABLE internal_sku_channels
ADD CONSTRAINT chk_internal_sku_channels_nonempty_trimmed
CHECK (internal_sku <> '' AND internal_sku = BTRIM(internal_sku));