重构:合并数据库迁移为三个领域文件

This commit is contained in:
yml2213
2026-11-08 20:07:18 +08:00
parent fcf8941972
commit d76e8265f8
23 changed files with 1475 additions and 1436 deletions
File diff suppressed because it is too large Load Diff
@@ -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;
$$;
@@ -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 商业订单;
@@ -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();
@@ -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';
@@ -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:%'
);
@@ -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;
$$;
@@ -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';
@@ -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 商品只归档不删除';
@@ -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 的当前路由';
@@ -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 的履约影子';
@@ -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
'冻结的历史路由输入;无触发器、无运行时读取、无运行时写入';
@@ -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;
@@ -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
'禁止应用修改履约事件;数据库外键级联删除不受影响';
@@ -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;
$$;
@@ -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;
@@ -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')"),
@@ -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')
@@ -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')
@@ -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'),
)