fix(pipeline): 变更按业务日回填,异常自动收口,载体管道加游标

- 渠道判定/成本快照返回受影响业务日,任务按日重算事实与总账
- 人工配置或载体补齐后,manual_config_missing / carrier_missing 自动关闭
- carrier_dispatch 使用水位游标,避免每轮从 id=0 重扫
This commit is contained in:
yml2213
2026-10-31 20:41:43 +08:00
parent 42d5874411
commit a62f6e1ec8
5 changed files with 244 additions and 12 deletions
@@ -1,11 +1,13 @@
import type { JsonObject } from '../../types/json.js'
import { query } from '../../db/client.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import { resolveExceptionsForItemsByReason } from '../cost-center/cost-exception-service.js'
import { readWatermarkItemId } from '../watermark-store.js'
import { rebuildChannelSalesDailyFacts } from '../finance/sales-facts-channel-service.js'
import { syncFinanceLedgerFromFacts } from '../finance/finance-ledger-service.js'
import { normalizeOrderItemProducts } from '../cost-center/product-normalize-service.js'
import { syncChannelCostSnapshots } from '../cost-center/cost-snapshot-service.js'
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
import { rebuildChannelSalesDailyFacts } from '../finance/sales-facts-channel-service.js'
import { syncFinanceLedgerFromFacts } from '../finance/finance-ledger-service.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import {
recordPipelineError,
@@ -45,18 +47,46 @@ export async function runChannelAssignJob(job: JsonObject = {}) {
})
await touchWatermark('channel_assign', { lastItemId: result.nextCursor })
const refreshed = await refreshFactsForDates(result.affectedDates)
return {
status: 'ok',
message:
`渠道判定:扫描 ${result.scanned},已定 ${result.assigned},` +
`冲突 ${result.conflicted},仍待定 ${result.stillPending}`,
`冲突 ${result.conflicted},仍待定 ${result.stillPending},回填 ${refreshed} 天`,
...result,
refreshedDays: refreshed,
}
}
/**
* 渠道/成本变更后,按业务日回填销售事实与总账。
*
* 事实任务只回看最近几天;补配价格、迟到分类这类变更必须按受影响日期重算,
* 否则旧日期的毛利会一直停在缺失成本/未分类。
*/
async function refreshFactsForDates(dates: string[]): Promise<number> {
const unique = [...new Set(dates.filter(Boolean))].sort().slice(0, 31)
let done = 0
for (const date of unique) {
try {
await rebuildChannelSalesDailyFacts({ dateFrom: date, dateTo: date })
await syncFinanceLedgerFromFacts({ dateFrom: date, dateTo: date })
done += 1
} catch {
// 单日失败不影响管道本身,下一轮或人工重跑会再处理。
}
}
return done
}
export async function runCarrierDispatchJob(job: JsonObject = {}) {
const config = jobConfig(job)
const limit = clampInt(config.limit, 200, 1, 2000)
const cursor =
config.afterId != null
? Math.max(0, Math.trunc(Number(config.afterId)))
: await readWatermarkItemId('carrier_dispatch')
let maxId = 0
const manual = await query<{ id: number; sku_key: string; config_id: number }>(
`
@@ -66,10 +96,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
AND oi.id > $2
ORDER BY oi.id
LIMIT $1
`,
[limit],
[limit, cursor],
)
let attached = 0
@@ -105,10 +136,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ce.reason = 'manual_config_missing'
AND ce.status <> 'resolved'
)
AND oi.id > $2
ORDER BY oi.id
LIMIT $1
`,
[limit],
[limit, cursor],
)
for (const row of missingManual.rows) {
@@ -142,10 +174,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ce.reason = 'manual_cost_missing'
AND ce.status <> 'resolved'
)
AND oi.id > $2
ORDER BY oi.id
LIMIT $1
`,
[limit],
[limit, cursor],
)
for (const row of pendingManualCost.rows) {
@@ -180,10 +213,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ce.reason = 'carrier_missing'
AND ce.status <> 'resolved'
)
AND oi.id > $2
ORDER BY oi.id
LIMIT $1
`,
[limit],
[limit, cursor],
)
for (const row of missingCarrier.rows) {
@@ -195,18 +229,122 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
})
}
await touchWatermark('carrier_dispatch')
for (const row of [
...manual.rows,
...missingManual.rows,
...pendingManualCost.rows,
...missingCarrier.rows,
]) {
maxId = Math.max(maxId, Number(row.id))
}
const resolved = await resolveCarrierDispatchExceptions()
const nextCursor = maxId === 0 ? 0 : maxId
await touchWatermark('carrier_dispatch', { lastItemId: nextCursor })
return {
status: 'ok',
message:
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
`缺履约载体 ${missingCarrier.rows.length}`,
`缺履约载体 ${missingCarrier.rows.length},异常收口 ${resolved}`,
attached,
missingManual: missingManual.rows.length,
missingCarrier: missingCarrier.rows.length,
resolved,
}
}
/**
* 载体/配置补上后,把对应异常自动关掉(每轮最多处理 500 条)。
*/
async function resolveCarrierDispatchExceptions(): Promise<number> {
// 游标会跳过已扫过的订单项;配置是后来补的,这里兜底把配置挂上。
await query(
`
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
AND cfg.enabled
AND oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
AND EXISTS (
SELECT 1
FROM cost_exception_items i
JOIN cost_exceptions ce ON ce.id = i.exception_id
WHERE i.order_item_id = oi.id
AND ce.reason = 'manual_config_missing'
AND ce.status <> 'resolved'
)
`,
)
const [carrier, config, manualCost] = await Promise.all([
query<{ order_item_id: number }>(
`
SELECT DISTINCT i.order_item_id
FROM cost_exception_items i
JOIN cost_exceptions ce ON ce.id = i.exception_id
JOIN order_items oi ON oi.id = i.order_item_id
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 (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher')
)
)
LIMIT 500
`,
),
query<{ order_item_id: number }>(
`
SELECT DISTINCT i.order_item_id
FROM cost_exception_items i
JOIN cost_exceptions ce ON ce.id = i.exception_id
JOIN order_items oi ON oi.id = i.order_item_id
WHERE ce.reason = 'manual_config_missing'
AND ce.status <> 'resolved'
AND oi.fulfillment_config_id IS NOT NULL
LIMIT 500
`,
),
query<{ order_item_id: number }>(
`
SELECT DISTINCT i.order_item_id
FROM cost_exception_items i
JOIN cost_exceptions ce ON ce.id = i.exception_id
WHERE ce.reason = 'manual_cost_missing'
AND ce.status <> 'resolved'
AND EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = i.order_item_id
AND cs.superseded_at IS NULL
)
LIMIT 500
`,
),
])
let resolved = 0
resolved += await resolveExceptionsForItemsByReason(
carrier.rows.map((row) => Number(row.order_item_id)),
'carrier_missing',
)
resolved += await resolveExceptionsForItemsByReason(
config.rows.map((row) => Number(row.order_item_id)),
'manual_config_missing',
)
resolved += await resolveExceptionsForItemsByReason(
manualCost.rows.map((row) => Number(row.order_item_id)),
'manual_cost_missing',
)
return resolved
}
export async function runProductNormalizeJob(job: JsonObject = {}) {
const config = jobConfig(job)
const result = await normalizeOrderItemProducts({
@@ -230,10 +368,14 @@ export async function runCostSnapshotJob(job: JsonObject = {}) {
})
await touchWatermark('cost_snapshot', { lastItemId: result.nextCursor })
const refreshed = await refreshFactsForDates(result.affectedDates)
return {
status: 'ok',
message: `成本快照:扫描 ${result.scanned},生成 ${result.created},缺失 ${result.missing}`,
message:
`成本快照:扫描 ${result.scanned},生成 ${result.created},` +
`替换 ${result.superseded},缺失 ${result.missing},回填 ${refreshed} 天`,
...result,
refreshedDays: refreshed,
}
}