diff --git a/apps/backend/src/db/migrations/113_finance_channel_cutover.sql b/apps/backend/src/db/migrations/113_finance_channel_cutover.sql index 096211ae..cef64f11 100644 --- a/apps/backend/src/db/migrations/113_finance_channel_cutover.sql +++ b/apps/backend/src/db/migrations/113_finance_channel_cutover.sql @@ -338,10 +338,12 @@ ALTER TABLE cost_exceptions ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ; -- 存量行(断代脚本会整体清空)给一个可用的 scope_key,避免 NOT NULL 失败。 +-- 旧行可能同 reason + subject_id 但 subject_type 不同,末尾拼 id 保证唯一, +-- 否则下面的 uq_cost_exceptions_scope_open 会因重复键失败。 UPDATE cost_exceptions SET scope_key = COALESCE( NULLIF(scope_key, ''), - reason || '|' || COALESCE(order_item_id::text, subject_id::text) + reason || '|' || COALESCE(order_item_id::text, subject_id::text) || '|legacy:' || id ), first_seen_at = COALESCE(first_seen_at, created_at), last_seen_at = COALESCE(last_seen_at, created_at) @@ -405,8 +407,11 @@ COMMENT ON TABLE finance_watermarks IS -- 先沿用旧列名承载渠道值,本迁移末尾统一改名为 channel; -- 这里先放宽约束,允许卡券、未分类与归档状态。 -- 断代前的销售事实仍是旧口径,可能残留 legacy_platform;新约束不允许该值。 --- 这些事实随后由 finance:cutover-v2 整体清空,这里先归入 archived(未知值归入 unclassified), --- 保证约束能加上;否则生产基线 112 会因存量行直接失败。 +-- 这些事实随后由 finance:cutover-v2 整体清空,这里先归入 archived(未知值归入 unclassified)。 +-- 必须先删除旧约束再改值:旧约束只允许四值,写 archived 会被它自己拦下。 +ALTER TABLE sales_daily_facts + DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode; + UPDATE sales_daily_facts SET fulfillment_mode = CASE WHEN fulfillment_mode = 'legacy_platform' THEN 'archived' @@ -417,9 +422,6 @@ WHERE fulfillment_mode NOT IN ( 'manual', 'unclassified', 'archived' ); -ALTER TABLE sales_daily_facts - DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode; - ALTER TABLE sales_daily_facts ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode CHECK ( diff --git a/apps/backend/src/services/finance/finance-cutover-migration.test.ts b/apps/backend/src/services/finance/finance-cutover-migration.test.ts index 9645234f..b9f11b8d 100644 --- a/apps/backend/src/services/finance/finance-cutover-migration.test.ts +++ b/apps/backend/src/services/finance/finance-cutover-migration.test.ts @@ -8,10 +8,14 @@ const migration = readFileSync( 'utf8', ) -test('113 迁移必须先归一销售事实旧口径,再收紧渠道约束', () => { +test('113 迁移必须先删旧约束、再归一销售事实,最后加新约束', () => { + const dropIndex = migration.indexOf( + 'DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode', + ) const updateIndex = migration.indexOf('UPDATE sales_daily_facts') const constraintIndex = migration.indexOf('ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode') - assert.ok(updateIndex > 0, '缺少 sales_daily_facts 旧口径归一') + assert.ok(dropIndex > 0, '缺少旧渠道约束的删除') + assert.ok(updateIndex > dropIndex, '必须先删旧约束再归一;否则 archived 会被旧约束拒绝') assert.ok(constraintIndex > updateIndex, '必须先归一数据,再加新约束') assert.ok( migration.includes("WHEN fulfillment_mode = 'legacy_platform' THEN 'archived'"), @@ -32,3 +36,10 @@ test('113 迁移必须先归一销售事实旧口径,再收紧渠道约束', ( assert.ok(migration.includes(`'${channel}'`), `新约束必须允许 ${channel}`) } }) + +test('113 迁移的异常表旧行 scope_key 必须保证唯一', () => { + assert.ok( + migration.includes("'|legacy:' || id"), + '旧异常行 scope_key 必须拼 id,避免同 reason + subject_id 撞唯一索引', + ) +})