fix(finance): 收敛内部 SKU 履约路由与载体状态
统一内部 SKU 与渠道主/兜底解析,禁止并列主渠道。 运营指定渠道改为先创建真实载体,失败保持明确 pending。 历史缺载体、多载体冲突和自动发货兜底统一由管道修复。 迁移人工配置与旧 sku_code 规则,补充严格预检和集成测试。
This commit is contained in:
@@ -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 } })
|
||||
|
||||
Reference in New Issue
Block a user