feat(fulfillment): 固化当前尝试与历史自动化边界
This commit is contained in:
@@ -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,
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user