fix(finance): 收敛内部 SKU 履约路由与载体状态

统一内部 SKU 与渠道主/兜底解析,禁止并列主渠道。

运营指定渠道改为先创建真实载体,失败保持明确 pending。

历史缺载体、多载体冲突和自动发货兜底统一由管道修复。

迁移人工配置与旧 sku_code 规则,补充严格预检和集成测试。
This commit is contained in:
yml2213
2026-11-02 15:02:49 +08:00
parent 5fe33b0b46
commit 2f87445ed9
33 changed files with 2199 additions and 446 deletions
@@ -86,9 +86,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
const config = jobConfig(job)
const limit = clampInt(config.limit, 200, 1, 2000)
const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) })
if (reconciled.affiliateTasksCreated > 0) {
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }).catch(() => undefined)
}
const repairedAssigned = await reconcilePendingCarriers({
limit: Math.min(limit, 200),
includeAssignedMissingCarrier: true,
})
await assignPendingOrderItemChannels({ batchSize: 500, maxBatches: 1 }).catch(() => undefined)
const cursor =
config.afterId != null
? Math.max(0, Math.trunc(Number(config.afterId)))
@@ -101,13 +103,17 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
const manual = await query<{ id: number; sku_key: string; config_id: number }>(
`
SELECT oi.id, oi.manual_sku_key AS sku_key, cfg.id AS config_id
SELECT oi.id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key,
cfg.id AS config_id
FROM order_items oi
JOIN manual_fulfillment_configs cfg ON cfg.sku_key = oi.manual_sku_key AND cfg.enabled
JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
AND cfg.enabled
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
AND oi.manual_sku_key = ANY($3::text[])
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[])
AND oi.id > $2
ORDER BY oi.id
LIMIT $1
@@ -131,12 +137,13 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
// 人工渠道但没有配置:进「人工 SKU 未配置」队列。
const missingManual = await query<{ id: number; sku_key: string }>(
`
SELECT oi.id, oi.manual_sku_key AS sku_key
SELECT oi.id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
AND NOT (oi.manual_sku_key = ANY($3::text[]))
AND NOT (resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[]))
AND NOT EXISTS (
SELECT 1
FROM cost_exception_items i
@@ -165,7 +172,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
// 已配置人工 SKU、但还没确认履约的单:进入「人工成本未记录」队列。
const pendingManualCost = await query<{ id: number; sku_key: string }>(
`
SELECT oi.id, oi.manual_sku_key AS sku_key
SELECT oi.id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
@@ -212,7 +220,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND NOT EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher')
AND ft.executor_key = 'affiliate_dash'
)
AND oi.id > $2
ORDER BY oi.id
@@ -245,6 +253,39 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
detail: { channel: row.channel, skuCode: row.sku_code },
})
}
// 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。
const conflictingCarriers = await query<{ id: number; channel: string; sku_code: string }>(
`
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code
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 + (
EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
)
)::int + (
EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
)
)::int > 1
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
for (const row of conflictingCarriers.rows) {
exceptionEntries.push({
reason: 'routing_conflict',
scopeKey: `routing_conflict|${row.channel}|${row.sku_code}|carrier`,
orderItemId: Number(row.id),
detail: { channel: row.channel, skuCode: row.sku_code, issue: 'multiple_carriers' },
})
}
if (exceptionEntries.length > 0) {
await upsertAggregatedExceptionsBatch(exceptionEntries)
}
@@ -254,6 +295,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
...missingManual.rows,
...pendingManualCost.rows,
...missingCarrier.rows,
...conflictingCarriers.rows,
]) {
maxId = Math.max(maxId, Number(row.id))
}
@@ -269,13 +311,17 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
status: 'ok',
message:
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
`缺履约载体 ${missingCarrier.rows.length},异常收口 ${resolved},` +
`自动转人工 ${fallback.switched}`,
`缺履约载体 ${missingCarrier.rows.length},多载体冲突 ${conflictingCarriers.rows.length},异常收口 ${resolved},` +
`自动转人工 ${fallback.switched},链路补载体 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated + repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` +
`兜底 ${reconciled.fallbacksSelected + repairedAssigned.fallbacksSelected}`,
attached,
missingManual: missingManual.rows.length,
missingCarrier: missingCarrier.rows.length,
conflictingCarriers: conflictingCarriers.rows.length,
resolved,
affiliateFallback: fallback.switched,
carrierReconciliation: reconciled,
assignedCarrierRepair: repairedAssigned,
}
}
@@ -289,7 +335,7 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
UPDATE order_items oi
SET fulfillment_config_id = cfg.id, updated_at = NOW()
FROM manual_fulfillment_configs cfg
WHERE cfg.sku_key = oi.manual_sku_key
WHERE cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
AND cfg.enabled
AND oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
@@ -315,12 +361,13 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
WHERE ce.reason = 'carrier_missing'
AND ce.status <> 'resolved'
AND (
EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
OR EXISTS (
(oi.fulfillment_channel = 'work_order'
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))
OR (oi.fulfillment_channel = 'affiliate_dash' AND EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher')
)
AND ft.executor_key = 'affiliate_dash'
))
)
LIMIT 500
`,
@@ -442,8 +489,9 @@ export async function runLedgerPostJob(job: JsonObject = {}) {
/** 顺序执行全部管道,供管理端「一键重算」使用。 */
export async function runFinancePipelineOnce(input: { days?: number } = {}) {
const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } })
// 先保证载体,再让财务镜像真实渠道;避免规则先把订单写成 assigned 却永远没有派单。
const carrier = await runCarrierDispatchJob({ config: { limit: 500 } })
const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } })
const product = await runProductNormalizeJob({ config: { batchSize: 1000, maxBatches: 50 } })
const cost = await runCostSnapshotJob({ config: { batchSize: 1000, maxBatches: 50 } })
const facts = await runSalesFactsJob({ config: { days: input.days ?? 3 } })