重构:硬切履约路由唯一真源

This commit is contained in:
yml2213
2026-11-08 19:44:27 +08:00
parent f6e193e7f6
commit 6bff1971dd
31 changed files with 1029 additions and 1022 deletions
@@ -90,22 +90,6 @@ async function main() {
`,
)
const rules = await query<{
match_key_type: string
channel: string
scope: string
total: number
}>(
`
SELECT match_key_type, channel,
CASE WHEN provider = '' AND shop_id = '' THEN 'global' ELSE 'scoped' END AS scope,
count(*)::int AS total
FROM sku_channel_rules
GROUP BY 1, 2, 3
ORDER BY 1, 2, 3
`,
)
const duplicateProducts = await query<{ duplicate_names: number; duplicate_rows: number }>(
`
SELECT count(*)::int AS duplicate_names, COALESCE(sum(total), 0)::int AS duplicate_rows
@@ -119,43 +103,29 @@ async function main() {
`,
)
const capabilities = await query<{ channel: string; total: number }>(
`SELECT channel, count(*)::int AS total FROM product_channel_capabilities WHERE enabled GROUP BY 1 ORDER BY 2 DESC`,
)
const internalChannelConflicts = await query<{ internal_sku: string; total: number }>(
`SELECT internal_sku, 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`,
)
const internalChannels = await query<{
const routes = await query<{
channel: string
route_role: string
source: string
total: number
}>(
`SELECT channel, source, count(*)::int AS total
FROM internal_sku_channels
`SELECT channel, route_role, source, count(*)::int AS total
FROM sku_channel_routes
WHERE enabled = TRUE
GROUP BY channel, source
ORDER BY channel, source`,
GROUP BY channel, route_role, source
ORDER BY channel, route_role, source`,
)
const canonicalChannelDuplicates = await query<{
internal_sku: string
channel: string
const internalRouteConflicts = await query<{
internal_sku_id: number
total: number
}>(
`SELECT canonicalize_internal_sku(internal_sku) AS internal_sku,
channel, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
GROUP BY canonicalize_internal_sku(internal_sku), channel
`SELECT internal_sku_id, count(*)::int AS total
FROM sku_channel_routes
WHERE enabled = TRUE AND route_role = 'primary'
GROUP BY internal_sku_id
HAVING count(*) > 1
ORDER BY count(*) DESC, internal_sku, channel`,
ORDER BY count(*) DESC, internal_sku_id`,
)
const assignedCarrierConflicts = await query<{
@@ -220,12 +190,9 @@ async function main() {
pending: pending.rows[0],
channelCarrier: channelCarrier.rows,
internalSku: internalSku.rows[0],
rules: rules.rows,
duplicateProducts: duplicateProducts.rows[0],
capabilities: capabilities.rows,
internalChannels: internalChannels.rows,
internalChannelConflicts: internalChannelConflicts.rows,
canonicalChannelDuplicates: canonicalChannelDuplicates.rows,
routes: routes.rows,
internalRouteConflicts: internalRouteConflicts.rows,
assignedCarrierConflicts: assignedCarrierConflicts.rows,
byProvider: byProvider.rows,
},
@@ -18,6 +18,7 @@ async function main() {
orphanManualProducts,
multiplePrimaryRoutes,
missingInternalSku,
unroutedProducts,
invalidSourceKinds,
] = await Promise.all([
query<{ source_kind: string; primary_channel: string | null; total: number }>(
@@ -46,7 +47,9 @@ async function main() {
SELECT
CASE
WHEN primary_route.channel = 'manual' THEN 'primary_manual'
WHEN manual_route.route_role = 'fallback' THEN 'manual_fallback'
WHEN primary_route.channel IS NOT NULL
AND manual_route.route_role = 'fallback' THEN 'manual_fallback'
WHEN manual_route.route_role = 'fallback' THEN 'fallback_without_primary'
ELSE 'missing_manual_route'
END AS route_state,
COUNT(*)::int AS total
@@ -123,6 +126,19 @@ async function main() {
ORDER BY 1
`,
),
query<{
product_id: number
canonical_sku: string
canonical_name: string
source_kind: string
}>(
`
SELECT id AS product_id, canonical_sku, canonical_name, source_kind
FROM v_product_current
WHERE status = 'active' AND active_primary_channel IS NULL
ORDER BY source_kind, id
`,
),
query<{ source_kind: string; total: number }>(
`
SELECT source_kind, COUNT(*)::int AS total
@@ -146,6 +162,11 @@ async function main() {
multiplePrimaryRoutes: multiplePrimaryRoutes.rows.length,
missingInternalSku: missingInternalSku.rows.reduce((sum, row) => sum + Number(row.total), 0),
invalidSourceKinds: invalidSourceKinds.rows.reduce((sum, row) => sum + Number(row.total), 0),
invalidManualConfigRoutes: manualConfigRoutes.rows
.filter(
(row) => row.route_state !== 'primary_manual' && row.route_state !== 'manual_fallback',
)
.reduce((sum, row) => sum + Number(row.total), 0),
}
console.info('[product-routing-audit] 商品来源 × 当前渠道')
@@ -169,6 +190,9 @@ async function main() {
console.info('[product-routing-audit] 非法商品来源')
console.table(invalidSourceKinds.rows)
console.info('[product-routing-audit] 未路由活动商品(待人工分类)')
console.table(unroutedProducts.rows)
console.info('[product-routing-audit] 关键异常', critical)
if (strict && Object.values(critical).some((count) => count > 0)) {
process.exitCode = 1
@@ -1,134 +0,0 @@
/**
* 把旧的 sku_code 渠道规则转换为 internal_sku 全局规则,并停用旧规则。
*
* 用法:
* npx tsx scripts/cleanup-legacy-sku-code-rules.ts
* npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply
*/
import { closeDb, query } from '../src/db/client.js'
type LegacyRule = {
id: number
provider: string
shop_id: string
match_key: string
channel: string
priority: number
source: string
}
async function resolveInternalSku(rule: LegacyRule): Promise<string> {
if (rule.channel === 'affiliate_dash') {
const product = await query<{ canonical_name: string }>(
`SELECT canonical_name FROM product_catalog
WHERE canonical_sku = $1 AND status = 'active' AND btrim(canonical_name) <> ''
LIMIT 1`,
[`aff:${rule.match_key}`],
)
if (product.rows[0]?.canonical_name) return String(product.rows[0].canonical_name).trim()
}
const params: unknown[] = [rule.match_key]
let scope = ''
if (rule.provider) {
params.push(rule.provider)
scope += ` AND o.provider = $${params.length}`
}
if (rule.shop_id) {
params.push(rule.shop_id)
scope += ` AND o.shop_id = $${params.length}`
}
const items = await query<{ internal_sku: string }>(
`
SELECT DISTINCT btrim(oi.manual_sku_key) AS internal_sku
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.sku_code = $1
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
${scope}
LIMIT 2
`,
params,
)
const values = items.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)
return values.length === 1 ? values[0]! : ''
}
async function main() {
const apply = process.argv.includes('--apply')
const rules = await query<LegacyRule>(
`
SELECT id, provider, shop_id, match_key, channel, priority, source
FROM sku_channel_rules
WHERE match_key_type = 'sku_code' AND enabled = TRUE
ORDER BY id
`,
)
const converted: Array<{ id: number; matchKey: string; internalSku: string; channel: string }> =
[]
const unresolved: LegacyRule[] = []
for (const rule of rules.rows) {
const internalSku = await resolveInternalSku(rule)
if (!internalSku) {
unresolved.push(rule)
continue
}
converted.push({ id: rule.id, matchKey: rule.match_key, internalSku, channel: rule.channel })
if (!apply) continue
await query(
`UPDATE internal_sku_channels
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE btrim(internal_sku) = $1
AND channel <> $2
AND enabled = TRUE
AND is_fallback = FALSE`,
[internalSku, rule.channel],
)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled, source
) VALUES ($1, $2, $3, FALSE, TRUE, 'legacy-rule')
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
is_fallback = FALSE,
enabled = TRUE,
updated_at = NOW()
`,
[internalSku, rule.channel, rule.priority],
)
await query(
`UPDATE sku_channel_rules
SET enabled = FALSE,
note = CONCAT(COALESCE(note, ''), ' [已迁移 internal_sku:', $2::text, ']'),
updated_at = NOW()
WHERE id = $1`,
[rule.id, internalSku],
)
}
console.log(
JSON.stringify(
{
apply,
legacyRules: rules.rows.length,
convertible: converted.length,
converted,
unresolved,
},
null,
2,
),
)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => closeDb())
+1 -1
View File
@@ -13,7 +13,7 @@ import { closeDb, query } from '../src/db/client.js'
* 保留:orders / order_items / work_orders / fulfillment_tasks(审计只读)
* product_catalog / channel_product_mappings / cost_items /
* cost_versions / work_product_rules / manual_fulfillment_configs /
* sku_channel_rules(配置)
* sku_channel_routes(配置)
*
* 前置:
* 1. 迁移 112/113 已执行;
@@ -70,6 +70,10 @@ async function mergeGroup(
'UPDATE manual_fulfillment_configs SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1',
[duplicateId, group.keeper_id],
)
await client.query(
'UPDATE sku_channel_routes SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1',
[duplicateId, group.keeper_id],
)
// 映射:先删掉会与主商品冲突的重复映射,再迁移其余。
const conflict = await client.query(
@@ -136,44 +140,42 @@ async function mergeGroup(
}
await query(
`
INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source)
VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, 'merged'), ($1, 'manual', 20, TRUE, TRUE, 'merged')
ON CONFLICT (product_id, channel) DO UPDATE
SET priority = LEAST(product_channel_capabilities.priority, EXCLUDED.priority),
is_fallback = product_channel_capabilities.is_fallback OR EXCLUDED.is_fallback,
updated_at = NOW()
`,
`UPDATE sku_channel_routes
SET route_role = 'fallback',
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE internal_sku_id = (
SELECT internal_sku_id FROM product_catalog WHERE id = $1
)
AND channel <> 'affiliate_dash'
AND enabled = TRUE
AND route_role = 'primary'`,
[group.keeper_id],
)
await query(
`UPDATE internal_sku_channels
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE btrim(internal_sku) = $1
AND channel <> 'affiliate_dash'
AND enabled = TRUE
AND is_fallback = FALSE`,
[group.canonical_name],
)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
INSERT INTO sku_channel_routes (
internal_sku_id, channel, route_role, priority, enabled, canonical_product_id, source
)
VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, $2, 'merged'),
($1, 'manual', 20, TRUE, TRUE, $2, 'merged')
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
is_fallback = internal_sku_channels.is_fallback OR EXCLUDED.is_fallback,
SELECT product.internal_sku_id, merged.channel, merged.route_role,
merged.priority, TRUE, product.id, 'merged'
FROM product_catalog product
CROSS JOIN (
VALUES
('affiliate_dash', 'primary', 10),
('manual', 'fallback', 20)
) merged(channel, route_role, priority)
WHERE product.id = $1
AND product.internal_sku_id IS NOT NULL
ON CONFLICT (internal_sku_id, channel) DO UPDATE SET
priority = LEAST(sku_channel_routes.priority, EXCLUDED.priority),
route_role = EXCLUDED.route_role,
enabled = TRUE,
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id),
updated_at = NOW()
`,
[group.canonical_name, group.keeper_id],
[group.keeper_id],
)
// 只删除已经没有引用的重复商品,仍被引用就归档。
@@ -1,201 +0,0 @@
/**
* 合并规范化后重复的内部 SKU 渠道配置。
*
* 默认只读;确认后加 --apply。
* 用法:npx tsx scripts/normalize-internal-sku-configs.ts [--apply]
*/
import { closeDb, query, withTransaction } from '../src/db/client.js'
import { canonicalizeInternalSku } from '../src/services/fulfillment/internal-sku-resolver.js'
type ChannelRow = {
id: number
internal_sku: string
channel: string
priority: number
is_fallback: boolean
enabled: boolean
canonical_product_id: number | null
config_json: Record<string, unknown>
source: string
note: string
created_by_admin_user_id: number | null
}
type DuplicateGroup = {
internalSku: string
channel: string
rows: ChannelRow[]
}
type MappingRow = {
id: number
provider: string
shop_id: string
match_type: string
match_value: string
internal_sku: string
priority: number
enabled: boolean
source: string
note: string
}
async function main() {
const apply = process.argv.includes('--apply')
const result = await query<ChannelRow>(
`SELECT id, internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id
FROM internal_sku_channels
ORDER BY canonicalize_internal_sku(internal_sku), channel, is_fallback, priority, id`,
)
const groups = new Map<string, DuplicateGroup>()
for (const row of result.rows) {
const internalSku = canonicalizeInternalSku(row.internal_sku)
const key = `${internalSku}|${row.channel}`
const group = groups.get(key) || { internalSku, channel: row.channel, rows: [] }
group.rows.push({ ...row, id: Number(row.id), priority: Number(row.priority) })
groups.set(key, group)
}
const duplicates = [...groups.values()].filter((group) => group.rows.length > 1)
const mappingRows = await query<MappingRow>(
`SELECT id, provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note
FROM internal_sku_mappings
ORDER BY provider, shop_id, match_type, canonicalize_internal_sku(match_value), enabled DESC, priority, id`,
)
const duplicateMappings = new Map<string, MappingRow[]>()
for (const row of mappingRows.rows) {
const key = [
row.provider,
row.shop_id,
row.match_type,
canonicalizeInternalSku(row.match_value),
].join('|')
const list = duplicateMappings.get(key) || []
list.push({ ...row, id: Number(row.id), priority: Number(row.priority) })
duplicateMappings.set(key, list)
}
const mappingDuplicates = [...duplicateMappings.values()].filter((rows) => rows.length > 1)
if (apply) {
for (const group of duplicates) {
await withTransaction(async (client) => {
const rows = (
await client.query<ChannelRow>(
`SELECT id, internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id
FROM internal_sku_channels
WHERE id = ANY($1::bigint[])
ORDER BY is_fallback, priority, id
FOR UPDATE`,
[group.rows.map((row) => row.id)],
)
).rows.map((row) => ({ ...row, id: Number(row.id), priority: Number(row.priority) }))
if (rows.length <= 1) return
const keeper = rows[0]!
const primaryRows = rows.filter((row) => !row.is_fallback)
const configJson = rows.reduce<Record<string, unknown>>(
(merged, row) => ({ ...merged, ...(row.config_json || {}) }),
{},
)
const source = rows.find((row) => row.source === 'operator')?.source || keeper.source
const note = [
...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean)),
].join(' / ')
await client.query(`DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, [
rows.slice(1).map((row) => row.id),
])
await client.query(
`UPDATE internal_sku_channels
SET internal_sku = $2,
priority = $3,
is_fallback = $4,
enabled = $5,
canonical_product_id = $6,
config_json = $7::jsonb,
source = $8,
note = $9,
created_by_admin_user_id = $10,
updated_at = NOW()
WHERE id = $1`,
[
keeper.id,
group.internalSku,
Math.min(...(primaryRows.length > 0 ? primaryRows : rows).map((row) => row.priority)),
primaryRows.length === 0,
rows.some((row) => row.enabled),
rows.find((row) => row.canonical_product_id != null)?.canonical_product_id || null,
JSON.stringify(configJson),
source,
note,
rows.find((row) => row.created_by_admin_user_id != null)?.created_by_admin_user_id ||
null,
],
)
})
}
for (const rows of mappingDuplicates) {
await withTransaction(async (client) => {
const keeper = rows[0]!
await client.query(`DELETE FROM internal_sku_mappings WHERE id = ANY($1::bigint[])`, [
rows.slice(1).map((row) => row.id),
])
await client.query(
`UPDATE internal_sku_mappings
SET match_value = $2, internal_sku = $3, priority = $4,
enabled = $5, source = $6, note = $7, updated_at = NOW()
WHERE id = $1`,
[
keeper.id,
canonicalizeInternalSku(keeper.match_value),
canonicalizeInternalSku(keeper.internal_sku),
Math.min(...rows.map((row) => row.priority)),
rows.some((row) => row.enabled),
keeper.source,
[...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean))].join(
' / ',
),
],
)
})
}
}
const duplicateManualConfigs = await query<{ label: string; total: number }>(
`SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total
FROM manual_fulfillment_configs
GROUP BY canonicalize_internal_sku(sku_key)
HAVING count(*) > 1
ORDER BY count(*) DESC, label
LIMIT 20`,
)
console.log(
JSON.stringify(
{
apply,
duplicateGroups: duplicates.length,
duplicateRows: duplicates.reduce((total, group) => total + group.rows.length, 0),
duplicateMappingGroups: mappingDuplicates.length,
duplicateMappingRows: mappingDuplicates.reduce((total, rows) => total + rows.length, 0),
sample: duplicates.slice(0, 20).map((group) => ({
internalSku: group.internalSku,
channel: group.channel,
ids: group.rows.map((row) => row.id),
})),
duplicateManualConfigs: duplicateManualConfigs.rows,
},
null,
2,
),
)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => closeDb())
@@ -1,345 +0,0 @@
/**
* 履约/财务统一分流部署后核对(只读)。
*
* 用法:
* 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.uppercaseInternalChannels = (
await query<CountRow>(
`SELECT 'uppercase_internal_sku' AS label, count(*)::int AS total
FROM internal_sku_channels
WHERE internal_sku ~ '[A-Z]'`,
)
).rows[0]
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.canonicalChannelDuplicates = (
await query<CountRow>(
`SELECT canonicalize_internal_sku(internal_sku) AS label, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
GROUP BY canonicalize_internal_sku(internal_sku), channel
HAVING count(*) > 1
ORDER BY count(*) DESC, label`,
)
).rows
checks.incompleteCarrierConfigs = (
await query<CountRow>(
`SELECT channel AS label, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
AND (
(
channel = 'work_order'
AND (
COALESCE(config_json->>'workRuleKey', '') = ''
OR NOT EXISTS (
SELECT 1 FROM work_product_rules rule
WHERE rule.rule_key = config_json->>'workRuleKey'
AND rule.enabled = TRUE
)
)
)
OR (
channel = 'manual'
AND NOT EXISTS (
SELECT 1 FROM manual_fulfillment_configs cfg
WHERE cfg.enabled = TRUE
AND canonicalize_internal_sku(cfg.sku_key)
= canonicalize_internal_sku(internal_sku_channels.internal_sku)
)
)
)
GROUP BY channel ORDER BY count(*) DESC`,
)
).rows
checks.ambiguousCarrierConfigs = (
await query<CountRow>(
`SELECT canonicalize_internal_sku(canonical_name) || '/' || channel AS label,
count(DISTINCT canonical_sku)::int AS total
FROM (
SELECT canonical_name, canonical_sku,
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'
END AS channel
FROM product_catalog
WHERE status = 'active'
AND (canonical_sku LIKE 'aff:%' OR canonical_sku LIKE 'tpl:%'
OR canonical_sku LIKE 'wo:%' OR canonical_sku LIKE 'tpv:%')
) carrier
WHERE channel IS NOT NULL
GROUP BY canonicalize_internal_sku(canonical_name), channel
HAVING count(DISTINCT canonical_sku) > 1
ORDER BY count(DISTINCT canonical_sku) DESC, label`,
)
).rows
checks.ambiguousManualConfigs = (
await query<CountRow>(
`SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total
FROM manual_fulfillment_configs
WHERE enabled = TRUE
GROUP BY canonicalize_internal_sku(sku_key)
HAVING count(*) > 1
ORDER BY count(*) DESC, label`,
)
).rows
checks.ambiguousInternalSkuMappings = (
await query<CountRow>(
`SELECT provider || '/' || shop_id || '/' || match_type || '/' ||
canonicalize_internal_sku(match_value) AS label,
count(*)::int AS total
FROM internal_sku_mappings
WHERE enabled = TRUE
GROUP BY provider, shop_id, match_type, canonicalize_internal_sku(match_value)
HAVING count(*) > 1
ORDER BY count(*) DESC, label
LIMIT 200`,
)
).rows
checks.pendingWithoutChineseInternalSku = (
await query<CountRow>(
`SELECT 'pending_without_chinese_internal_sku' 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 COALESCE(resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
), '') = ''`,
[includeDemo],
)
).rows[0]
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.uppercaseInternalChannels as CountRow).total > 0)
failed.push('内部 SKU 渠道配置仍含未规范化的大写英文')
if ((checks.canonicalChannelDuplicates as CountRow[]).length > 0)
failed.push('存在规范化后重复的内部 SKU 渠道配置')
if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0)
failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道')
if ((checks.ambiguousCarrierConfigs as CountRow[]).length > 0)
failed.push('存在同一中文内部 SKU 的多个载体配置')
if ((checks.ambiguousManualConfigs as CountRow[]).length > 0)
failed.push('存在规范化后重复的人工履约配置')
if ((checks.ambiguousInternalSkuMappings as CountRow[]).length > 0)
failed.push('存在规范化后重复的内部 SKU 映射')
if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0)
failed.push('存在无法归一到中文内部 SKU 的 pending 订单项')
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())
@@ -83,10 +83,12 @@ main()
async function assertSchema() {
const ready = await query<{ ready: boolean }>(
`SELECT to_regclass('public.sku_channel_rules') IS NOT NULL AS ready`,
`SELECT
to_regclass('public.internal_skus') IS NOT NULL
AND to_regclass('public.sku_channel_routes') IS NOT NULL AS ready`,
)
if (!ready.rows[0]?.ready) {
throw new Error('缺少 112 迁移产物;请先执行 npm run db:migrate')
throw new Error('缺少统一 SKU 路由迁移产物;请先执行 npm run db:migrate')
}
}
@@ -32,23 +32,25 @@ async function loadCandidates(limit: number): Promise<Candidate[]> {
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN LATERAL (
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled
AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher')
AND btrim(c.internal_sku) = resolve_internal_sku(
SELECT route.channel
FROM sku_channel_routes route
JOIN internal_skus sku ON sku.id = route.internal_sku_id
WHERE route.enabled
AND route.route_role = 'primary'
AND route.channel IN ('affiliate_dash', 'work_order', 'manual', 'third_party_voucher')
AND sku.internal_sku_key = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
ORDER BY
c.priority ASC,
CASE c.channel
route.priority ASC,
CASE route.channel
WHEN 'affiliate_dash' THEN 1
WHEN 'work_order' THEN 2
WHEN 'third_party_voucher' THEN 3
WHEN 'manual' THEN 4
ELSE 9
END,
c.channel ASC
route.channel ASC
LIMIT 1
) cap ON TRUE
WHERE oi.fulfillment_status = 'assigned'
+47 -44
View File
@@ -18,26 +18,19 @@ async function cleanup() {
)
await query(`DELETE FROM sales_daily_facts WHERE shop_id = ANY($1)`, [SHOPS])
await query(
`DELETE FROM internal_sku_channels
WHERE internal_sku IN ('模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`,
)
await query(
`DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`,
[SHOPS],
'ALTER TABLE fulfillment_state_events DISABLE TRIGGER trg_fulfillment_state_events_append_only',
)
try {
await query(
`DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`,
[SHOPS],
)
} finally {
await query(
'ALTER TABLE fulfillment_state_events ENABLE TRIGGER trg_fulfillment_state_events_append_only',
)
}
await query(`DELETE FROM orders WHERE shop_id = ANY($1)`, [SHOPS])
await query(
`DELETE FROM sku_channel_rules
WHERE match_key IN ('SIM_AFF_1', 'SIMV001', 'SIMM001',
'模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`,
)
await query(
`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 (
@@ -51,7 +44,9 @@ async function cleanup() {
`DELETE FROM cost_versions
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(
@@ -64,6 +59,14 @@ async function cleanup() {
'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B'
)`,
)
await query(
`DELETE FROM internal_skus
WHERE internal_sku_key IN (
canonicalize_internal_sku('模拟自动发货商品'),
canonicalize_internal_sku('模拟三方卡券'),
canonicalize_internal_sku('模拟人工商品')
)`,
)
}
async function createOrder(shop: string, orderNo: string, amountFen = 1000) {
@@ -119,11 +122,6 @@ async function main() {
VALUES ($1,'product',$2,500,0,'2026-10-01T00:00:00+08:00','active','本地演示采购价',NOW(),NOW())`,
[affCostItem.rows[0]!.id, affProductId],
)
await query(
`INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source)
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,
@@ -134,12 +132,6 @@ async function main() {
) 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'`,
)
@@ -154,17 +146,6 @@ async function main() {
'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, config_json, source, created_at, updated_at
) VALUES
('模拟自动发货商品', '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 (
@@ -176,9 +157,31 @@ async function main() {
[affProductId, manualProductId],
)
await query(
`INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled, source, created_at, updated_at
) VALUES ('模拟人工商品', 'manual', 10, FALSE, TRUE, 'demo', NOW(), NOW())`,
`
INSERT INTO sku_channel_routes (
internal_sku_id, channel, route_role, priority, enabled,
canonical_product_id, carrier_config_json, source, created_at, updated_at
)
SELECT sku_id, channel, route_role, priority, TRUE, product_id, config_json, 'demo', NOW(), NOW()
FROM (
VALUES
(ensure_canonical_internal_sku('模拟自动发货商品'), 'affiliate_dash', 'primary', 10,
$1::bigint, '{"affiliateSku":"SIM_AFF_1"}'::jsonb),
(ensure_canonical_internal_sku('模拟自动发货商品'), 'manual', 'fallback', 90,
$1::bigint, '{}'::jsonb),
(ensure_canonical_internal_sku('模拟人工商品'), 'manual', 'primary', 10,
$2::bigint, '{}'::jsonb)
) demo(sku_id, channel, route_role, priority, product_id, config_json)
ON CONFLICT (internal_sku_id, channel) DO UPDATE SET
route_role = EXCLUDED.route_role,
priority = EXCLUDED.priority,
enabled = TRUE,
canonical_product_id = EXCLUDED.canonical_product_id,
carrier_config_json = EXCLUDED.carrier_config_json,
source = EXCLUDED.source,
updated_at = NOW()
`,
[affProductId, manualProductId],
)
// 1. 三方卡券:待分类,指定后自动建商品