feat(fulfillment): 渠道联动与管道兜底

- 人工配置保存/删除同步渠道规则,人工确认写 channel/status
- 按 SKU 指定渠道默认同时落规则
- 管道补充:载体缺失、人工成本未记录、事实与总账按水位追赶
This commit is contained in:
yml2213
2026-10-31 19:52:22 +08:00
parent 4272515956
commit 0ce3c8fa97
6 changed files with 189 additions and 12 deletions
@@ -110,12 +110,74 @@ 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
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
)
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
for (const row of pendingManualCost.rows) {
await upsertAggregatedException({
reason: 'manual_cost_missing',
scopeKey: `manual_cost_missing|-|-|${row.sku_key}|manual`,
orderItemId: row.id,
detail: { skuKey: row.sku_key },
})
}
// 自动发货 / 接单平台被运营指定后却没有载体:不能静默卡住,进问题队列。
const missingCarrier = 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 oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
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 NOT EXISTS (
SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id
)
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
for (const row of missingCarrier.rows) {
await upsertAggregatedException({
reason: 'carrier_missing',
scopeKey: `carrier_missing|${row.channel}|${row.sku_code}`,
orderItemId: row.id,
detail: { channel: row.channel, skuCode: row.sku_code },
})
}
await touchWatermark('carrier_dispatch')
return {
status: 'ok',
message: `人工载体挂载 ${attached},缺配置 ${missingManual.rows.length}`,
message:
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
`缺履约载体 ${missingCarrier.rows.length}`,
attached,
missingManual: missingManual.rows.length,
missingCarrier: missingCarrier.rows.length,
}
}
@@ -153,7 +215,7 @@ export async function runSalesFactsJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
const dateTo = formatShanghaiDate(new Date())
const dateFrom = shiftDate(dateTo, -(days - 1))
const dateFrom = await resolveCatchUpStart('sales_facts', days, dateTo)
const result = await rebuildChannelSalesDailyFacts({ dateFrom, dateTo })
await touchWatermark('sales_facts', { lastStatDate: result.dateTo })
@@ -168,7 +230,7 @@ export async function runLedgerPostJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
const dateTo = formatShanghaiDate(new Date())
const dateFrom = shiftDate(dateTo, -(days - 1))
const dateFrom = await resolveCatchUpStart('ledger_post', days, dateTo)
const result = await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
await touchWatermark('ledger_post', { lastStatDate: dateTo })
@@ -215,3 +277,25 @@ export async function runSingleFinancePipeline(
throw error
}
}
/**
* 计算本轮起始日期:默认回看 N 天;若水位更早(例如停机数天),
* 从水位那天补起,最多补 90 天,避免一次扫描过长。
*/
async function resolveCatchUpStart(
pipeline: 'sales_facts' | 'ledger_post',
days: number,
dateTo: string,
): Promise<string> {
const windowStart = shiftDate(dateTo, -(days - 1))
const result = await query<{ last_stat_date: string | null }>(
`SELECT last_stat_date::text FROM finance_watermarks WHERE pipeline = $1`,
[pipeline],
)
const lastDate = result.rows[0]?.last_stat_date || ''
if (!lastDate) return windowStart
const earliest = shiftDate(dateTo, -89)
const candidate = lastDate < windowStart ? lastDate : windowStart
return candidate < earliest ? earliest : candidate
}