feat(fulfillment): 固化当前尝试与历史自动化边界
This commit is contained in:
@@ -22,9 +22,16 @@ export const AFFILIATE_FAILURE_STATUSES = [
|
||||
const AFFILIATE_SUCCESS_STATUSES = ['redeemed', 'paid'] as const
|
||||
|
||||
export async function runAffiliateFallback(
|
||||
options: { limit?: number } = {},
|
||||
options: { limit?: number; cutoffCreatedAt?: string } = {},
|
||||
): Promise<{ scanned: number; switched: number }> {
|
||||
const limit = Math.min(1000, Math.max(1, Math.trunc(options.limit || 200)))
|
||||
const params: unknown[] = [AFFILIATE_FAILURE_STATUSES, AFFILIATE_SUCCESS_STATUSES, limit]
|
||||
const cutoffCreatedAt = String(options.cutoffCreatedAt || '').trim()
|
||||
let cutoffFilter = ''
|
||||
if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) {
|
||||
params.push(cutoffCreatedAt)
|
||||
cutoffFilter = `AND o.created_at >= $${params.length}::timestamptz`
|
||||
}
|
||||
const candidates = await query<{
|
||||
id: number
|
||||
order_item_id: number
|
||||
@@ -80,10 +87,11 @@ export async function runAffiliateFallback(
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status = ANY($2::text[])
|
||||
)
|
||||
${cutoffFilter}
|
||||
ORDER BY oi.id
|
||||
LIMIT $3
|
||||
`,
|
||||
[AFFILIATE_FAILURE_STATUSES, AFFILIATE_SUCCESS_STATUSES, limit],
|
||||
params,
|
||||
)
|
||||
|
||||
const switchedIds: number[] = []
|
||||
|
||||
@@ -52,6 +52,7 @@ async function loadCandidateItems(input: {
|
||||
assignedMissingCarrier?: boolean
|
||||
afterId?: number
|
||||
includeLocked?: boolean
|
||||
cutoffCreatedAt?: string
|
||||
}): Promise<CarrierCandidate[]> {
|
||||
const ids = (input.orderItemIds || [])
|
||||
.map(Number)
|
||||
@@ -65,6 +66,12 @@ async function loadCandidateItems(input: {
|
||||
params.push(Math.max(0, Math.trunc(Number(input.afterId))))
|
||||
idFilter = `AND oi.id > $${params.length}`
|
||||
}
|
||||
const cutoffCreatedAt = String(input.cutoffCreatedAt || '').trim()
|
||||
let cutoffFilter = ''
|
||||
if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) {
|
||||
params.push(cutoffCreatedAt)
|
||||
cutoffFilter = `AND oi.created_at >= $${params.length}::timestamptz`
|
||||
}
|
||||
const statusFilter = input.assignedMissingCarrier
|
||||
? `oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
@@ -123,6 +130,7 @@ async function loadCandidateItems(input: {
|
||||
) capability ON jsonb_array_length(capability.channels) > 0
|
||||
WHERE ${statusFilter}
|
||||
${idFilter}
|
||||
${cutoffFilter}
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
`,
|
||||
@@ -252,6 +260,7 @@ export async function reconcilePendingCarriers(
|
||||
afterId?: number
|
||||
includeLocked?: boolean
|
||||
retryInactiveCarrier?: boolean
|
||||
cutoffCreatedAt?: string
|
||||
} = {},
|
||||
): Promise<{
|
||||
scanned: number
|
||||
@@ -268,6 +277,7 @@ export async function reconcilePendingCarriers(
|
||||
assignedMissingCarrier: input.includeAssignedMissingCarrier === true,
|
||||
...(input.afterId != null ? { afterId: input.afterId } : {}),
|
||||
...(input.includeLocked ? { includeLocked: true } : {}),
|
||||
...(input.cutoffCreatedAt ? { cutoffCreatedAt: input.cutoffCreatedAt } : {}),
|
||||
...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}),
|
||||
})
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ export async function assignPendingOrderItemChannels(
|
||||
afterId?: number
|
||||
orderItemIds?: number[]
|
||||
includeLocked?: boolean
|
||||
cutoffCreatedAt?: string
|
||||
} = {},
|
||||
): Promise<AssignChannelsResult> {
|
||||
const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500)))
|
||||
@@ -288,6 +289,12 @@ export async function assignPendingOrderItemChannels(
|
||||
params.push(requestedIds)
|
||||
requestedFilter = `AND oi.id = ANY($${params.length}::bigint[])`
|
||||
}
|
||||
const cutoffCreatedAt = String(options.cutoffCreatedAt || '').trim()
|
||||
let cutoffFilter = ''
|
||||
if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) {
|
||||
params.push(cutoffCreatedAt)
|
||||
cutoffFilter = `AND o.created_at >= $${params.length}::timestamptz`
|
||||
}
|
||||
|
||||
for (let batch = 0; batch < maxBatches; batch += 1) {
|
||||
params[0] = cursor
|
||||
@@ -323,6 +330,7 @@ export async function assignPendingOrderItemChannels(
|
||||
${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}
|
||||
AND oi.id > $1
|
||||
${requestedFilter}
|
||||
${cutoffFilter}
|
||||
ORDER BY oi.id
|
||||
LIMIT $2
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
|
||||
|
||||
type FulfillmentAutomationConfig = {
|
||||
cutoff: string
|
||||
}
|
||||
|
||||
export async function getFulfillmentAutomationCutoff(): Promise<string> {
|
||||
const result = await query<{ config_json: FulfillmentAutomationConfig | null }>(
|
||||
`SELECT config_json
|
||||
FROM app_config_entries
|
||||
WHERE config_key = $1
|
||||
LIMIT 1`,
|
||||
[APP_CONFIG_KEYS.fulfillmentAutomationCutoff],
|
||||
)
|
||||
const cutoff = String(result.rows[0]?.config_json?.cutoff || '').trim()
|
||||
if (!cutoff || !Number.isFinite(Date.parse(cutoff))) return ''
|
||||
return cutoff
|
||||
}
|
||||
|
||||
export function fulfillmentCreatedAfterSql(
|
||||
params: unknown[],
|
||||
cutoff: string,
|
||||
alias = 'oi',
|
||||
): string {
|
||||
const normalized = String(cutoff || '').trim()
|
||||
if (!normalized || !Number.isFinite(Date.parse(normalized))) return ''
|
||||
params.push(normalized)
|
||||
return `AND ${alias}.created_at >= $${params.length}::timestamptz`
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const carrierReconciliation = readFileSync(
|
||||
)
|
||||
const channelAssign = readFileSync(new URL('./channel-assign-service.ts', import.meta.url), 'utf8')
|
||||
const taskRepo = readFileSync(new URL('../../repositories/task-repo.ts', import.meta.url), 'utf8')
|
||||
const currentAttemptMigration = readFileSync(
|
||||
new URL('../../db/migrations/138_fulfillment_current_attempt.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('自动发货历史非活跃任务默认阻止后台自动重开', () => {
|
||||
assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes'))
|
||||
@@ -38,6 +42,14 @@ test('整组补载体默认不包含锁定项,也不强制重开历史任务',
|
||||
|
||||
test('任务列表默认只展示每个订单项单元的最新尝试', () => {
|
||||
assert.ok(taskRepo.includes('currentOnly = false'))
|
||||
assert.ok(taskRepo.includes('SELECT latest.id'))
|
||||
assert.ok(taskRepo.includes('latest.unit_index = ft.unit_index'))
|
||||
assert.ok(taskRepo.includes('ft.is_current = TRUE'))
|
||||
})
|
||||
|
||||
test('创建新履约尝试时自动退休旧当前尝试', () => {
|
||||
assert.ok(currentAttemptMigration.includes('attempt_no INTEGER'))
|
||||
assert.ok(currentAttemptMigration.includes('is_current BOOLEAN'))
|
||||
assert.ok(currentAttemptMigration.includes('uq_fulfillment_tasks_current_unit'))
|
||||
assert.ok(taskRepo.includes('MAX(attempt_no)'))
|
||||
assert.ok(taskRepo.includes('SET is_current = FALSE'))
|
||||
assert.ok(taskRepo.includes('$26, TRUE'))
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ test(
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const {
|
||||
assignPendingOrderItemChannels,
|
||||
assignChannelByInternalSku,
|
||||
deletePendingChannelGroup,
|
||||
deleteChannelRules,
|
||||
@@ -35,6 +36,8 @@ test(
|
||||
const { deleteManualSkuClassification, saveManualFulfillmentConfig } =
|
||||
await import('../cost-center/manual-fulfillment-config-service.js')
|
||||
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
|
||||
const { getFulfillmentAutomationCutoff } =
|
||||
await import('./fulfillment-automation-config-service.js')
|
||||
const { reconcilePendingCarriers } = await import('./carrier-reconciliation-service.js')
|
||||
const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js')
|
||||
const {
|
||||
@@ -61,6 +64,7 @@ test(
|
||||
const missingVoucherSku = `集成测试无商品卡券-${suffix}`
|
||||
const reactivatedVoucherSku = `集成测试归档卡券-${suffix}`
|
||||
const retryPendingAffiliateSku = `集成测试失败待重试-${suffix}`
|
||||
const cutoffTestSku = `集成测试自动化边界-${suffix}`
|
||||
const retryWorkOrderRuleKey = `it-retry-${suffix}`
|
||||
const orderIds: number[] = []
|
||||
const orderItemIds: number[] = []
|
||||
@@ -79,6 +83,7 @@ test(
|
||||
missingVoucherSku,
|
||||
reactivatedVoucherSku,
|
||||
retryPendingAffiliateSku,
|
||||
cutoffTestSku,
|
||||
]
|
||||
const createdVoucherProductIds: number[] = []
|
||||
let autoManualProductId: number | null = null
|
||||
@@ -140,6 +145,40 @@ test(
|
||||
assert.equal(canonicalResolution.rows[0]?.platform_only, null)
|
||||
assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀')
|
||||
assert.equal(canonicalResolution.rows[0]?.case_variant, '黑色rock长袖t恤')
|
||||
assert.ok(Number.isFinite(Date.parse(await getFulfillmentAutomationCutoff())))
|
||||
|
||||
const cutoffNewItemId = await createPendingOrderItem(cutoffTestSku)
|
||||
const cutoffOldItemId = await createPendingOrderItem(cutoffTestSku)
|
||||
await query(
|
||||
`UPDATE orders
|
||||
SET created_at = '2019-01-01T00:00:00+08:00',
|
||||
updated_at = '2019-01-01T00:00:00+08:00'
|
||||
WHERE id = (SELECT order_id FROM order_items WHERE id = $1)`,
|
||||
[cutoffOldItemId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, config_json, source
|
||||
) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test')`,
|
||||
[cutoffTestSku],
|
||||
)
|
||||
const cutoffAssignment = await assignPendingOrderItemChannels({
|
||||
orderItemIds: [cutoffOldItemId, cutoffNewItemId],
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
cutoffCreatedAt: '2020-01-01T00:00:00+08:00',
|
||||
})
|
||||
assert.equal(cutoffAssignment.scanned, 1)
|
||||
assert.equal(cutoffAssignment.assigned, 1)
|
||||
const cutoffStates = await query<{ id: number; fulfillment_status: string }>(
|
||||
`SELECT id, fulfillment_status
|
||||
FROM order_items
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id DESC`,
|
||||
[[cutoffOldItemId, cutoffNewItemId]],
|
||||
)
|
||||
assert.equal(cutoffStates.rows[0]?.fulfillment_status, 'pending')
|
||||
assert.equal(cutoffStates.rows[1]?.fulfillment_status, 'assigned')
|
||||
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source)
|
||||
@@ -147,7 +186,13 @@ test(
|
||||
[` 集成测试 规范-${suffix} `],
|
||||
)
|
||||
const normalizedInsert = await query<{ internal_sku: string }>(
|
||||
`SELECT internal_sku FROM internal_sku_channels WHERE source = 'integration_test'`,
|
||||
`SELECT internal_sku
|
||||
FROM internal_sku_channels
|
||||
WHERE source = 'integration_test'
|
||||
AND canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($1)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[triggerSku],
|
||||
)
|
||||
assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku)
|
||||
|
||||
@@ -636,13 +681,21 @@ test(
|
||||
[retryTaskItemId],
|
||||
)
|
||||
const retryTaskOrderRow = retryTaskOrder.rows[0]!
|
||||
const insertRetryTask = (taskNo: string, status: string) =>
|
||||
const insertRetryTask = (
|
||||
taskNo: string,
|
||||
status: string,
|
||||
attemptNo: number,
|
||||
isCurrent: boolean,
|
||||
) =>
|
||||
query(
|
||||
`INSERT INTO fulfillment_tasks (
|
||||
order_id, order_item_id, unit_index, task_no, provider, platform,
|
||||
shop_id, shop_name, platform_order_id, profile_id, executor_key,
|
||||
task_status, created_at, updated_at
|
||||
) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', $10, NOW(), NOW())`,
|
||||
task_status, attempt_no, is_current, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash',
|
||||
$10, $11, $12, NOW(), NOW()
|
||||
)`,
|
||||
[
|
||||
retryTaskOrderRow.order_id,
|
||||
retryTaskItemId,
|
||||
@@ -654,12 +707,14 @@ test(
|
||||
retryTaskOrderRow.platform_order_id,
|
||||
fallbackProfileId,
|
||||
status,
|
||||
attemptNo,
|
||||
isCurrent,
|
||||
],
|
||||
)
|
||||
await insertRetryTask(`IT-AFF-CLOSED-${suffix}`, 'closed')
|
||||
await insertRetryTask(`IT-AFF-ACTIVE-${suffix}`, 'paid')
|
||||
await insertRetryTask(`IT-AFF-CLOSED-${suffix}`, 'closed', 1, false)
|
||||
await insertRetryTask(`IT-AFF-ACTIVE-${suffix}`, 'paid', 2, true)
|
||||
await assert.rejects(
|
||||
() => insertRetryTask(`IT-AFF-DUPLICATE-${suffix}`, 'claimed'),
|
||||
() => insertRetryTask(`IT-AFF-DUPLICATE-${suffix}`, 'claimed', 3, false),
|
||||
/duplicate key value violates unique constraint/,
|
||||
)
|
||||
const retryTaskRows = await query<{ task_status: string }>(
|
||||
|
||||
@@ -11,6 +11,18 @@ const auditScript = readFileSync(
|
||||
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const automationCutoffMigration = readFileSync(
|
||||
new URL('../../db/migrations/139_fulfillment_automation_cutoff.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const automationConfig = readFileSync(
|
||||
new URL('./fulfillment-automation-config-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schedulerJobs = readFileSync(
|
||||
new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('履约读模型按订单项拆分渠道、载体、尝试、异常和锁定原因', () => {
|
||||
assert.ok(migration.includes('CREATE OR REPLACE VIEW v_fulfillment_item_state'))
|
||||
@@ -50,3 +62,12 @@ test('履约审计脚本覆盖状态不一致、售后队列和重复尝试', ()
|
||||
assert.ok(auditScript.includes('近 24 小时重复尝试订单项'))
|
||||
assert.ok(auditScript.includes('--strict'))
|
||||
})
|
||||
|
||||
test('历史履约数据不进入自动任务,只处理自动化生效时间之后的新订单', () => {
|
||||
assert.ok(automationCutoffMigration.includes('fulfillment_automation_cutoff'))
|
||||
assert.ok(automationConfig.includes('getFulfillmentAutomationCutoff'))
|
||||
assert.ok(automationConfig.includes('fulfillmentCreatedAfterSql'))
|
||||
assert.ok(schedulerJobs.includes('cutoffCreatedAt'))
|
||||
assert.ok(schedulerJobs.includes('getFulfillmentAutomationCutoff'))
|
||||
assert.ok(schedulerJobs.includes('runAffiliateFallback'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user