Merge branch 'main' into dev

This commit is contained in:
2026-11-03 10:33:10 +08:00
121 changed files with 5265 additions and 1629 deletions
@@ -0,0 +1,345 @@
-- 120_fulfillment_routing_invariants.sql
--
-- 生产迁移 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,
p_sku_name TEXT,
p_sku_code TEXT
) RETURNS TEXT
LANGUAGE SQL
IMMUTABLE
PARALLEL SAFE
AS $$
SELECT COALESCE(
CASE
WHEN BTRIM(COALESCE(p_manual_sku_key, '')) ~ '[一-龥]'
THEN BTRIM(p_manual_sku_key)
END,
CASE
WHEN BTRIM(COALESCE(p_sku_name, '')) ~ '[一-龥]'
THEN BTRIM(p_sku_name)
END,
NULLIF(BTRIM(COALESCE(p_manual_sku_key, '')), ''),
NULLIF(BTRIM(COALESCE(p_sku_name, '')), ''),
NULLIF(BTRIM(COALESCE(p_sku_code, '')), '')
)
$$;
COMMENT ON FUNCTION resolve_internal_sku(TEXT, TEXT, TEXT) IS
'订单项内部 SKU 唯一 SQL 口径:中文 manual_sku_key/sku_name 优先,再按历史生成列顺序回退';
-- 人工配置就是 manual 渠道声明。存在其他渠道时先按 90 优先级兜底。
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT
BTRIM(cfg.sku_key),
'manual',
CASE WHEN EXISTS (
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
WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key)
AND c.enabled = TRUE
AND c.channel <> 'manual'
) THEN TRUE ELSE FALSE END,
cfg.enabled,
cfg.canonical_product_id,
jsonb_build_object(
'manualConfigId', cfg.id,
'platformKey', cfg.platform_key,
'creditQuantity', cfg.credit_quantity,
'costMode', cfg.cost_mode
),
'manual_config',
NOW(),
NOW()
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,
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_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,
updated_at = NOW()
WHERE manual_channel.channel = 'manual'
AND manual_channel.enabled = TRUE
AND NOT (manual_channel.source = 'operator' AND manual_channel.priority = 10)
AND EXISTS (
SELECT 1
FROM internal_sku_channels other_channel
WHERE BTRIM(other_channel.internal_sku) = BTRIM(manual_channel.internal_sku)
AND other_channel.enabled = TRUE
AND other_channel.channel <> 'manual'
);
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;
@@ -0,0 +1,31 @@
-- 121_internal_sku_single_primary.sql
--
-- 一个内部 SKU 只能有一个主渠道;其他渠道必须显式标记 is_fallback。
-- 迁移 120 已把人工并列渠道收敛为兜底;这里在任何剩余歧义上显式阻止部署。
DO $$
DECLARE
conflict_sku TEXT;
BEGIN
SELECT internal_sku INTO conflict_sku
FROM internal_sku_channels
WHERE enabled = TRUE AND is_fallback = FALSE
GROUP BY internal_sku
HAVING count(*) > 1
ORDER BY internal_sku
LIMIT 1;
IF conflict_sku IS NOT NULL THEN
RAISE EXCEPTION '内部 SKU % 存在多个主渠道,请先将其余渠道标记为 is_fallback', conflict_sku;
END IF;
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));
@@ -0,0 +1,55 @@
-- 接单模板商品改为事件驱动投影:新模板保存时自动建/更新商品。
-- 本迁移补齐现有模板商品,并统一状态、名称、分类和价格属性。
WITH template_projection AS (
SELECT
LEFT('tpl:' || BTRIM(wpr.rule_key), 64) AS canonical_sku,
wpr.product_name AS canonical_name,
COALESCE(wc.name, '') AS category,
CASE
WHEN wpr.product_name ~ '钻石|颗' THEN '颗'
WHEN wpr.product_name ~ '枪|把' THEN '把'
WHEN wpr.product_name ~ '套' THEN '套'
WHEN wpr.product_name ~ '密钥|秘钥' THEN '个'
ELSE '件'
END AS unit_name,
'active' AS status,
jsonb_build_object(
'source', 'work_product_rule',
'ruleId', wpr.id,
'ruleKey', wpr.rule_key,
'categoryId', wpr.category_id,
'templateEnabled', wpr.enabled,
'pricingMode',
CASE
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN 'unit'
ELSE 'fixed'
END,
'rewardAmountFen', wpr.reward_amount,
'unitPriceFen', wpr.unit_price_fen,
'sharingEnabled', wpr.sharing_enabled,
'sharingTotalQuantity', wpr.sharing_total_quantity,
'sharingUnitReward', wpr.sharing_unit_reward,
'sharingAutoFromOrder', wpr.sharing_auto_from_order
) AS attributes_json
FROM work_product_rules wpr
LEFT JOIN work_categories wc ON wc.id = wpr.category_id
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
)
SELECT
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, NOW(), NOW()
FROM template_projection
ON CONFLICT (canonical_sku) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name,
category = EXCLUDED.category,
unit_name = EXCLUDED.unit_name,
attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json,
updated_at = NOW()
WHERE product_catalog.canonical_sku LIKE 'tpl:%';
COMMENT ON TABLE product_catalog IS
'标准商品目录;tpl: 商品由接单模板事件自动投影,价格只来自成本中心';
@@ -0,0 +1,54 @@
-- 接单模板显式保存计价方式,金额字段只由商品与成本中心写投影。
-- 历史数据按旧运行口径回填:拼单自动按件优先识别,其次识别“单价>0 且固定金额=0”。
ALTER TABLE work_product_rules
ADD COLUMN IF NOT EXISTS pricing_mode TEXT;
UPDATE work_product_rules
SET pricing_mode = CASE
WHEN sharing_enabled = TRUE
AND sharing_auto_from_order = TRUE
AND unit_price_fen > 0 THEN 'unit'
WHEN unit_price_fen > 0 AND reward_amount = 0 THEN 'unit'
ELSE 'fixed'
END
WHERE pricing_mode IS NULL
OR pricing_mode NOT IN ('fixed', 'unit');
ALTER TABLE work_product_rules
ALTER COLUMN pricing_mode SET DEFAULT 'fixed',
ALTER COLUMN pricing_mode SET NOT NULL;
-- 自动拼单只能依赖每件金额;固定金额模板不能保留“随订单自动算拼单”的旧标志。
UPDATE work_product_rules
SET sharing_auto_from_order = FALSE
WHERE pricing_mode = 'fixed'
AND sharing_auto_from_order = TRUE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_work_product_rules_pricing_mode'
AND conrelid = 'work_product_rules'::regclass
) THEN
ALTER TABLE work_product_rules
ADD CONSTRAINT chk_work_product_rules_pricing_mode
CHECK (pricing_mode IN ('fixed', 'unit'));
END IF;
END $$;
COMMENT ON COLUMN work_product_rules.pricing_mode IS
'接单计价方式:fixed 固定整单金额 / unit 按 SKU 数量乘每件金额';
-- 同步模板商品属性中的计价方式,避免继续从两个金额字段反推。
UPDATE product_catalog p
SET attributes_json = COALESCE(p.attributes_json, '{}'::jsonb) || jsonb_build_object(
'pricingMode', wpr.pricing_mode,
'rewardAmountFen', wpr.reward_amount,
'unitPriceFen', wpr.unit_price_fen
),
updated_at = NOW()
FROM work_product_rules wpr
WHERE p.canonical_sku = LEFT('tpl:' || BTRIM(wpr.rule_key), 64);