Files
order_site/apps/backend/scripts/preflight-fulfillment-routing.ts
T
yml2213 6bf28489e0 fix(finance): 重写履约路由迁移与统一渠道配置
重写 120/121 迁移,统一内部 SKU、渠道 id、config_json 和旧规则转换。

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

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

按生产 119 基线重跑迁移、完整履约财务链路和严格预检。
2026-11-02 15:29:13 +08:00

220 lines
8.7 KiB
TypeScript

/**
* 履约/财务统一分流部署后核对(只读)。
*
* 用法:
* npx tsx scripts/preflight-fulfillment-routing.ts
* npx tsx scripts/preflight-fulfillment-routing.ts --strict
*/
import { closeDb, query } from '../src/db/client.js'
type CountRow = { label: string; total: number }
async function main() {
const strict = process.argv.includes('--strict')
const includeDemo = process.argv.includes('--include-demo')
const checks: Record<string, unknown> = {}
checks.migrations = (
await query<{ name: string }>(`SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5`)
).rows.map((row) => row.name)
checks.pendingReasons = (
await query<CountRow>(
`SELECT COALESCE(fulfillment_pending_reason, '(null)') AS label, count(*)::int AS total
FROM order_items WHERE fulfillment_status = 'pending'
GROUP BY 1 ORDER BY 2 DESC`,
)
).rows
checks.renameCardChannels = (
await query<CountRow>(
`SELECT COALESCE(fulfillment_channel, '(pending)') AS label, count(*)::int AS total
FROM order_items
WHERE sku_name = '11月改名卡'
GROUP BY 1 ORDER BY 2 DESC`,
)
).rows
checks.rules = (
await query<CountRow>(
`SELECT match_key_type || '/' || channel || '/' ||
CASE WHEN enabled THEN 'on' ELSE 'off' END AS label, count(*)::int AS total
FROM sku_channel_rules GROUP BY 1 ORDER BY 2 DESC`,
)
).rows
checks.internalChannels = (
await query<CountRow>(
`SELECT channel AS label, count(*)::int AS total
FROM internal_sku_channels WHERE enabled GROUP BY 1 ORDER BY 2 DESC`,
)
).rows
checks.pendingWithoutChannel = (
await query<CountRow>(
`SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total
FROM order_items oi
WHERE oi.fulfillment_status = 'pending'
AND btrim(COALESCE(resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
), '')) <> ''
AND NOT EXISTS (
SELECT 1 FROM internal_sku_channels c
WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
)`,
)
).rows[0]
checks.assignedWithoutCarrier = (
await query<CountRow>(
`SELECT 'assigned_without_carrier' AS label, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
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 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],
)
).rows[0]
checks.assignedWithMultipleCarriers = (
await query<CountRow>(
`SELECT 'assigned_with_multiple_carriers' AS label, count(*)::int AS total
FROM order_items oi
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 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],
)
).rows[0]
checks.internalChannelConflicts = (
await query<CountRow>(
`SELECT internal_sku AS label, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE AND is_fallback = FALSE
GROUP BY internal_sku
HAVING count(*) > 1
ORDER BY count(*) DESC, internal_sku`,
)
).rows
checks.enabledLegacyRules = (
await query<CountRow>(
`SELECT match_key_type || '/' || channel AS label, count(*)::int AS total
FROM sku_channel_rules
WHERE enabled = TRUE AND match_key_type = 'sku_code'
GROUP BY 1 ORDER BY 2 DESC`,
)
).rows
checks.demoPending = (
await query<CountRow>(
`SELECT 'demo_pending' AS label, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending' AND o.shop_id LIKE 'sim-%'`,
)
).rows[0]
checks.pendingWithoutReason = (
await query<CountRow>(
`SELECT 'pending_without_reason' AS label, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
AND oi.fulfillment_pending_reason IS NULL`,
[includeDemo],
)
).rows[0]
checks.channelCarrier = (
await query<{
channel: string
total: number
work_orders: number
affiliate_tasks: number
manual_tasks: number
}>(
`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 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`,
)
).rows
const failed: string[] = []
const rename = checks.renameCardChannels as CountRow[]
if (rename.some((row) => row.label === 'pending')) failed.push('11月改名卡仍有 pending')
if (!includeDemo && (checks.demoPending as CountRow).total > 0) {
checks.excludedDemoPending = (checks.demoPending as CountRow).total
}
if ((checks.pendingWithoutChannel as CountRow).total > 0 && !includeDemo) {
// 本地演示店铺故意保留 pending;严格模式默认只校验非演示数据。
const productionPending = await query<CountRow>(
`SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
AND o.shop_id NOT LIKE 'sim-%'
AND btrim(COALESCE(resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
), '')) <> ''
AND NOT EXISTS (
SELECT 1 FROM internal_sku_channels c
WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
)`,
)
if (productionPending.rows[0]!.total > 0)
failed.push('存在有内部 SKU 但无渠道能力的非演示 pending')
} else if ((checks.pendingWithoutChannel as CountRow).total > 0) {
failed.push('存在有内部 SKU 但无渠道能力的 pending')
}
if ((checks.assignedWithoutCarrier as CountRow).total > 0)
failed.push('存在已分配自动发货/接单渠道但没有真实载体的订单项')
if ((checks.assignedWithMultipleCarriers as CountRow).total > 0)
failed.push('存在同时挂载多个真实履约载体的已分配订单项')
if ((checks.internalChannelConflicts as CountRow[]).length > 0)
failed.push('存在多个非兜底主渠道')
if ((checks.enabledLegacyRules as CountRow[]).length > 0)
failed.push('仍有启用的 sku_code 兼容规则,未完全收敛到内部 SKU')
if ((checks.pendingWithoutReason as CountRow).total > 0)
failed.push('存在未写入 pending 原因的订单项,请先执行履约/财务管道')
console.log(JSON.stringify({ strict, failed, ...checks }, null, 2))
if (strict && failed.length > 0) process.exitCode = 2
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => closeDb())