feat(finance): 统一内部 SKU 驱动的履约与财务分流

- 新增 internal_sku_mappings / internal_sku_channels,平台字段统一映射到内部中文 SKU
- 下单入口按内部 SKU 渠道决策修正默认人工路由,自动发货补全 dash sku
- 财务 channel_assign 读取统一渠道表;卡券/人工能力兜底,自动发货/接单要求真实载体
- carrier reconciliation 自动补自动发货任务和接单工单
- pending 落明确原因:无内部 SKU / 无渠道能力 / 无履约载体
- 合并、重判、旧 sku_code 规则清理、部署后 preflight 核对脚本
- 待分类队列按内部 SKU 聚合并展示原因
This commit is contained in:
yml2213
2026-11-02 14:17:08 +08:00
parent 531a23d78b
commit 5fe33b0b46
18 changed files with 1020 additions and 15 deletions
@@ -0,0 +1,133 @@
/**
* 把旧的 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(
`
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(
`
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),
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())
@@ -147,6 +147,22 @@ async function mergeGroup(
[group.keeper_id],
)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, 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,
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
updated_at = NOW()
`,
[group.canonical_name, group.keeper_id],
)
await query(
`
INSERT INTO sku_channel_rules (
@@ -0,0 +1,99 @@
/**
* 履约/财务统一分流部署后核对(只读)。
*
* 用法:
* 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 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(oi.manual_sku_key, '')) <> ''
AND NOT EXISTS (
SELECT 1 FROM internal_sku_channels c
WHERE c.enabled AND btrim(c.internal_sku) = btrim(oi.manual_sku_key)
)`,
)
).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))::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
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 ((checks.pendingWithoutChannel as CountRow).total > 0)
failed.push('存在有内部 SKU 但无渠道能力的 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())
@@ -33,11 +33,20 @@ async function loadCandidates(limit: number): Promise<Candidate[]> {
JOIN orders o ON o.id = oi.order_id
JOIN LATERAL (
SELECT c.channel
FROM product_channel_capabilities c
WHERE c.product_id = oi.canonical_product_id
AND c.enabled
FROM internal_sku_channels c
WHERE c.enabled
AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher')
ORDER BY c.priority ASC
AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name))
ORDER BY
c.priority ASC,
CASE c.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
LIMIT 1
) cap ON TRUE
WHERE oi.fulfillment_status = 'assigned'