fix(finance): 重写履约路由迁移与统一渠道配置
重写 120/121 迁移,统一内部 SKU、渠道 id、config_json 和旧规则转换。 后台渠道配置改读统一渠道表,卡券与人工配置不再写旧规则。 增加安全渠道切换,并排除已关闭、失败或过期的历史任务载体。 按生产 119 基线重跑迁移、完整履约财务链路和严格预检。
This commit is contained in:
@@ -16,11 +16,11 @@ async function main() {
|
||||
`
|
||||
SELECT
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS with_work_order,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS with_affiliate_task,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS with_manual_task,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS with_work_order,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS with_affiliate_task,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS with_manual_task,
|
||||
count(*) FILTER (
|
||||
WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
|
||||
)::int AS no_carrier
|
||||
FROM order_items oi
|
||||
@@ -41,9 +41,9 @@ async function main() {
|
||||
SELECT
|
||||
COALESCE(NULLIF(oi.fulfillment_channel, ''), '(pending)') AS channel,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS manual_tasks,
|
||||
count(*) FILTER (
|
||||
WHERE oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL
|
||||
)::int AS config_missing,
|
||||
@@ -53,10 +53,11 @@ async function main() {
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
)
|
||||
) OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
)
|
||||
)::int AS no_carrier
|
||||
FROM order_items oi
|
||||
@@ -137,25 +138,29 @@ async function main() {
|
||||
carrier_count: number
|
||||
}>(
|
||||
`SELECT oi.id AS order_item_id, oi.fulfillment_channel AS channel,
|
||||
(EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int
|
||||
(EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int AS carrier_count
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int
|
||||
AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int > 1
|
||||
ORDER BY oi.id
|
||||
LIMIT 200`,
|
||||
@@ -172,7 +177,7 @@ async function main() {
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE oi.fulfillment_status = 'pending')::int AS pending,
|
||||
count(*) FILTER (
|
||||
WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
OR EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
|
||||
)::int AS with_carrier
|
||||
FROM order_items oi
|
||||
|
||||
@@ -92,6 +92,11 @@ const failures: string[] = []
|
||||
// 用「已发布基线」而不是 HEAD 做比较,是因为以 HEAD 为基线时,本次提交新增的
|
||||
// 迁移也会被算成历史迁移,合并提交后必然自相矛盾。
|
||||
const MIGRATION_BASELINE_REF = resolveMigrationBaselineRef()
|
||||
// 生产当前已发布到 119;120+ 仍允许在部署前重写。部署 120 后应将此值提升到 120。
|
||||
const PUBLISHED_MIGRATION_MAX_VERSION = Math.max(
|
||||
0,
|
||||
Math.trunc(Number(process.env.PUBLISHED_MIGRATION_MAX_VERSION) || 119),
|
||||
)
|
||||
|
||||
function resolveMigrationBaselineRef(): string | null {
|
||||
const candidates = [process.env.MIGRATION_BASELINE_REF, 'main', 'origin/main'].filter(
|
||||
@@ -132,7 +137,11 @@ if (!MIGRATION_BASELINE_REF) {
|
||||
const changedHistoricalMigrations = diffOutput
|
||||
.split('\n')
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
|
||||
.filter(([status, file]) => {
|
||||
if (!status || !file?.endsWith('.sql') || status === 'A') return false
|
||||
const version = Number(path.basename(file).slice(0, 3))
|
||||
return Number.isFinite(version) && version <= PUBLISHED_MIGRATION_MAX_VERSION
|
||||
})
|
||||
.map(([status, file]) => `${status} ${file}`)
|
||||
if (changedHistoricalMigrations.length > 0) {
|
||||
failures.push(
|
||||
|
||||
@@ -77,17 +77,6 @@ async function main() {
|
||||
converted.push({ id: rule.id, matchKey: rule.match_key, internalSku, channel: rule.channel })
|
||||
if (!apply) continue
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel, priority,
|
||||
source, enabled, note, created_at, updated_at
|
||||
) VALUES ('', '', 'internal_sku', $1, $2, $3, $4, TRUE,
|
||||
'由旧 sku_code 规则迁移', NOW(), NOW())
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
|
||||
`,
|
||||
[internalSku, rule.channel, rule.priority, rule.source || 'operator'],
|
||||
)
|
||||
await query(
|
||||
`UPDATE internal_sku_channels
|
||||
SET is_fallback = TRUE,
|
||||
|
||||
@@ -176,19 +176,6 @@ async function mergeGroup(
|
||||
[group.canonical_name, group.keeper_id],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel, priority,
|
||||
source, enabled, note, created_at, updated_at
|
||||
)
|
||||
VALUES ('', '', 'internal_sku', $1, 'affiliate_dash', 10, 'affiliate', TRUE,
|
||||
'合并重复商品后按内部SKU生成', NOW(), NOW())
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
|
||||
`,
|
||||
[group.canonical_name],
|
||||
)
|
||||
|
||||
// 只删除已经没有引用的重复商品,仍被引用就归档。
|
||||
for (const duplicateId of group.duplicate_ids) {
|
||||
const refs = await query<{ total: number }>(
|
||||
|
||||
@@ -75,10 +75,11 @@ async function main() {
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
)`,
|
||||
[includeDemo],
|
||||
)
|
||||
@@ -91,14 +92,16 @@ async function main() {
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
|
||||
AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int
|
||||
AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int > 1`,
|
||||
[includeDemo],
|
||||
)
|
||||
@@ -155,9 +158,9 @@ async function main() {
|
||||
}>(
|
||||
`SELECT COALESCE(fulfillment_channel, '(pending)') AS channel,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS manual_tasks
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
|
||||
@@ -60,6 +60,7 @@ async function loadCandidates(limit: number): Promise<Candidate[]> {
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
@@ -70,7 +71,7 @@ async function loadCandidates(limit: number): Promise<Candidate[]> {
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM work_orders wo
|
||||
WHERE wo.order_item_id = oi.id
|
||||
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
|
||||
@@ -32,13 +32,19 @@ async function cleanup() {
|
||||
'模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM product_channel_capabilities WHERE product_id IN (SELECT id FROM product_catalog WHERE canonical_sku = 'aff:SIM_AFF_1')`,
|
||||
`DELETE FROM product_channel_capabilities
|
||||
WHERE product_id IN (
|
||||
SELECT id FROM product_catalog
|
||||
WHERE canonical_sku IN ('aff:SIM_AFF_1', 'man:模拟人工商品')
|
||||
)`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM channel_product_mappings
|
||||
WHERE canonical_product_id IN (
|
||||
SELECT id FROM product_catalog
|
||||
WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B')
|
||||
WHERE canonical_sku IN (
|
||||
'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B'
|
||||
)
|
||||
)`,
|
||||
)
|
||||
await query(
|
||||
@@ -48,10 +54,15 @@ async function cleanup() {
|
||||
WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B')
|
||||
)`,
|
||||
)
|
||||
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = '模拟人工商品'`)
|
||||
await query(
|
||||
`DELETE FROM manual_fulfillment_configs
|
||||
WHERE sku_key IN ('模拟人工商品', '模拟自动发货商品')`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM product_catalog
|
||||
WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B')`,
|
||||
WHERE canonical_sku IN (
|
||||
'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B'
|
||||
)`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,19 +124,56 @@ async function main() {
|
||||
VALUES ($1,'affiliate_dash',10,FALSE,TRUE,'demo'), ($1,'manual',20,TRUE,TRUE,'demo')`,
|
||||
[affProductId],
|
||||
)
|
||||
const manualProduct = await query<{ id: number }>(
|
||||
`INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES (
|
||||
'man:模拟人工商品', '模拟人工商品', '人工', '件', 'active',
|
||||
'{}'::jsonb, NOW(), NOW()
|
||||
) RETURNING id`,
|
||||
)
|
||||
const manualProductId = Number(manualProduct.rows[0]!.id)
|
||||
await query(
|
||||
`INSERT INTO product_channel_capabilities (
|
||||
product_id, channel, priority, is_fallback, enabled, source
|
||||
) VALUES ($1, 'manual', 10, FALSE, TRUE, 'demo')`,
|
||||
[manualProductId],
|
||||
)
|
||||
const manualCostItem = await query<{ id: number }>(
|
||||
`SELECT id FROM cost_items WHERE code = 'sys_manual_service'`,
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO cost_versions (
|
||||
cost_item_id, scope_type, canonical_product_id, amount_fen, rate_bps,
|
||||
effective_from, status, change_reason, created_at, updated_at
|
||||
) VALUES
|
||||
($1, 'product', $2, 100, 0, '2026-10-01T00:00:00+08:00',
|
||||
'active', '自动发货失败转人工成本', NOW(), NOW()),
|
||||
($1, 'product', $3, 100, 0, '2026-10-01T00:00:00+08:00',
|
||||
'active', '本地演示人工成本', NOW(), NOW())`,
|
||||
[manualCostItem.rows[0]!.id, affProductId, manualProductId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, source, created_at, updated_at
|
||||
canonical_product_id, config_json, source, created_at, updated_at
|
||||
) VALUES
|
||||
('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1, 'demo', NOW(), NOW()),
|
||||
('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1, 'demo', NOW(), NOW())`,
|
||||
('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1,
|
||||
'{"affiliateSku":"SIM_AFF_1"}'::jsonb, 'demo', NOW(), NOW()),
|
||||
('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1,
|
||||
'{}'::jsonb, 'demo', NOW(), NOW())`,
|
||||
[affProductId],
|
||||
)
|
||||
// 人工演示配置
|
||||
await query(
|
||||
`INSERT INTO manual_fulfillment_configs (sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at)
|
||||
VALUES ('模拟人工商品','nanhang',100,'fixed',TRUE,'本地演示',NOW(),NOW())`,
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, canonical_product_id, platform_key, credit_quantity,
|
||||
cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES
|
||||
('模拟自动发货商品', $1, 'nanhang', 100, 'fixed', TRUE, '自动发货兜底', NOW(), NOW()),
|
||||
('模拟人工商品', $2, 'nanhang', 100, 'fixed', TRUE, '本地演示', NOW(), NOW())`,
|
||||
[affProductId, manualProductId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
|
||||
Reference in New Issue
Block a user