feat(fulfillment): 固化当前尝试与历史自动化边界

This commit is contained in:
yml2213
2026-11-05 20:25:48 +08:00
parent ed450fac79
commit ca214854bb
14 changed files with 340 additions and 33 deletions
@@ -3,6 +3,10 @@ import { query } from '../../db/client.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js'
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
import {
fulfillmentCreatedAfterSql,
getFulfillmentAutomationCutoff,
} from '../fulfillment/fulfillment-automation-config-service.js'
import { processDueOrderServiceAutoPublishJobs } from '../worker-platform/order-service-auto-publish-service.js'
import {
resolveExceptionsForItemsByReason,
@@ -52,12 +56,14 @@ function clampInt(value: unknown, fallback: number, min: number, max: number): n
export async function runChannelAssignJob(job: JsonObject = {}) {
const config = jobConfig(job)
const cutoffCreatedAt = await getFulfillmentAutomationCutoff()
const result = await assignPendingOrderItemChannels({
batchSize: clampInt(config.batchSize, 500, 1, 5000),
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
...(config.afterId != null
? { afterId: clampInt(config.afterId, 0, 0, Number.MAX_SAFE_INTEGER) }
: {}),
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
})
await touchWatermark('channel_assign', { lastItemId: result.nextCursor })
@@ -85,6 +91,7 @@ export async function runChannelAssignJob(job: JsonObject = {}) {
export async function runCarrierDispatchJob(job: JsonObject = {}) {
const config = jobConfig(job)
const cutoffCreatedAt = await getFulfillmentAutomationCutoff()
const limit = clampInt(config.limit, 200, 1, 2000)
const reconcileCursor =
config.carrierReconcileAfterId != null
@@ -93,13 +100,19 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
const reconciled = await reconcilePendingCarriers({
limit: Math.min(limit, 200),
afterId: reconcileCursor,
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
})
await touchWatermark('carrier_reconcile', { lastItemId: reconciled.nextCursor })
const repairedAssigned = await reconcilePendingCarriers({
limit: Math.min(limit, 200),
includeAssignedMissingCarrier: true,
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
})
await assignPendingOrderItemChannels({ batchSize: 500, maxBatches: 1 }).catch(() => undefined)
await assignPendingOrderItemChannels({
batchSize: 500,
maxBatches: 1,
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
}).catch(() => undefined)
const cursor =
config.afterId != null
? Math.max(0, Math.trunc(Number(config.afterId)))
@@ -109,6 +122,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
'SELECT canonicalize_internal_sku(sku_key) AS sku_key FROM manual_fulfillment_configs WHERE enabled',
)
const configKeys = configKeysResult.rows.map((row) => row.sku_key)
const manualParams: unknown[] = [limit, cursor, configKeys]
const manualCutoff = fulfillmentCreatedAfterSql(manualParams, cutoffCreatedAt)
const manual = await query<{ id: number; sku_key: string; config_id: number }>(
`
@@ -126,10 +141,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND oi.fulfillment_config_id IS NULL
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[])
AND oi.id > $2
${manualCutoff}
ORDER BY oi.id
LIMIT $1
`,
[limit, cursor, configKeys],
manualParams,
)
let attached = 0
@@ -146,6 +162,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
}
// 人工渠道但没有配置:进「人工 SKU 未配置」队列。
const missingManualParams: unknown[] = [limit, cursor, configKeys]
const missingManualCutoff = fulfillmentCreatedAfterSql(missingManualParams, cutoffCreatedAt)
const missingManual = await query<{ id: number; sku_key: string }>(
`
SELECT oi.id,
@@ -164,10 +182,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ce.status <> 'resolved'
)
AND oi.id > $2
${missingManualCutoff}
ORDER BY oi.id
LIMIT $1
`,
[limit, cursor, configKeys],
missingManualParams,
)
const exceptionEntries: AggregatedExceptionEntry[] = []
@@ -181,6 +200,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
}
// 已配置人工 SKU、但还没确认履约的单:进入「人工成本未记录」队列。
const pendingManualCostParams: unknown[] = [limit, cursor]
const pendingManualCostCutoff = fulfillmentCreatedAfterSql(
pendingManualCostParams,
cutoffCreatedAt,
)
const pendingManualCost = await query<{ id: number; sku_key: string }>(
`
SELECT oi.id,
@@ -204,10 +228,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ce.status <> 'resolved'
)
AND oi.id > $2
${pendingManualCostCutoff}
ORDER BY oi.id
LIMIT $1
`,
[limit, cursor],
pendingManualCostParams,
)
for (const row of pendingManualCost.rows) {
@@ -220,6 +245,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
}
// 自动发货 / 接单平台被运营指定后却没有载体:不能静默卡住,进问题队列。
const missingCarrierParams: unknown[] = [limit, cursor]
const missingCarrierCutoff = fulfillmentCreatedAfterSql(missingCarrierParams, cutoffCreatedAt)
const missingCarrierRaw = await query<{
id: number
channel: string
@@ -241,10 +268,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
)
AND oi.id > $2
${missingCarrierCutoff}
ORDER BY oi.id
LIMIT $1
`,
[limit, cursor],
missingCarrierParams,
)
const queuedCarrier = await query<{ order_item_id: number }>(
@@ -273,6 +301,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
}
// 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。
const conflictingCarrierParams: unknown[] = [limit]
const conflictingCarrierCutoff = fulfillmentCreatedAfterSql(
conflictingCarrierParams,
cutoffCreatedAt,
)
const conflictingCarriers = await query<{
id: number
channel: string
@@ -299,10 +332,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
)
)::int > 1
${conflictingCarrierCutoff}
ORDER BY oi.id
LIMIT $1
`,
[limit],
conflictingCarrierParams,
)
for (const row of conflictingCarriers.rows) {
exceptionEntries.push({
@@ -331,8 +365,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
maxId = Math.max(maxId, Number(row.id))
}
const resolved = await resolveCarrierDispatchExceptions()
const fallback = await runAffiliateFallback({ limit: 200 }).catch(() => ({
const resolved = await resolveCarrierDispatchExceptions(cutoffCreatedAt)
const fallback = await runAffiliateFallback({
limit: 200,
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
}).catch(() => ({
scanned: 0,
switched: 0,
}))
@@ -360,8 +397,10 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
/**
* 载体/配置补上后,把对应异常自动关掉(每轮最多处理 500 条)。
*/
async function resolveCarrierDispatchExceptions(): Promise<number> {
async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promise<number> {
// 游标会跳过已扫过的订单项;配置是后来补的,这里兜底把配置挂上。
const attachParams: unknown[] = []
const attachCutoff = fulfillmentCreatedAfterSql(attachParams, cutoffCreatedAt)
await query(
`
UPDATE order_items oi
@@ -382,9 +421,17 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
AND ce.reason = 'manual_config_missing'
AND ce.status <> 'resolved'
)
${attachCutoff}
`,
attachParams,
)
const carrierParams: unknown[] = []
const carrierCutoff = fulfillmentCreatedAfterSql(carrierParams, cutoffCreatedAt)
const configParams: unknown[] = []
const configCutoff = fulfillmentCreatedAfterSql(configParams, cutoffCreatedAt)
const manualCostParams: unknown[] = []
const manualCostCutoff = fulfillmentCreatedAfterSql(manualCostParams, cutoffCreatedAt)
const [carrier, config, manualCost] = await Promise.all([
query<{ order_item_id: number }>(
`
@@ -404,8 +451,10 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
))
)
${carrierCutoff}
LIMIT 500
`,
carrierParams,
),
query<{ order_item_id: number }>(
`
@@ -416,14 +465,17 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
WHERE ce.reason = 'manual_config_missing'
AND ce.status <> 'resolved'
AND oi.fulfillment_config_id IS NOT NULL
${configCutoff}
LIMIT 500
`,
configParams,
),
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_cost_missing'
AND ce.status <> 'resolved'
AND EXISTS (
@@ -432,8 +484,10 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
AND cs.order_item_id = i.order_item_id
AND cs.superseded_at IS NULL AND reversed_at IS NULL
)
${manualCostCutoff}
LIMIT 500
`,
manualCostParams,
),
])