Merge branch 'main' into dev_match

This commit is contained in:
2026-11-07 10:41:19 +08:00
15 changed files with 265 additions and 49 deletions
@@ -34,7 +34,10 @@ import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
import { reconcilePendingCarriers } from './carrier-reconciliation-service.js'
import { TASK_STATUS } from '../../domain/task-status.js'
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
import {
scheduleVoucherConsumeFallbackForOrderItems,
scheduleVoucherConsumeForOrderItems,
} from './voucher-consume-queue-service.js'
import {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurations,
@@ -359,6 +362,7 @@ export async function assignPendingOrderItemChannels(
channel: FulfillmentChannel
source: FulfillmentSource
}> = []
const unmatchedVoucherItemIds: number[] = []
const channelsByInternalSku = await loadInternalSkuChannelsForKeys(
rows.rows.map((item) => item.internal_sku),
@@ -410,6 +414,9 @@ export async function assignPendingOrderItemChannels(
eventType: 'routing_pending',
reason: pending.reason,
})
if (pending.reason === 'no_channel_capability') {
unmatchedVoucherItemIds.push(Number(item.id))
}
continue
}
assignments.push({ id: item.id, channel: decision.channel, source: decision.source })
@@ -447,6 +454,18 @@ export async function assignPendingOrderItemChannels(
},
)
}
if (unmatchedVoucherItemIds.length > 0) {
await scheduleVoucherConsumeFallbackForOrderItems(
unmatchedVoucherItemIds,
'order_received',
).catch((error) => {
logWarn('[channel-assign]', '未命中渠道后安排兜底核销失败', {
orderItemIds: unmatchedVoucherItemIds,
error: error instanceof Error ? error.message : String(error),
})
})
}
}
result.nextCursor = reachedEnd ? 0 : cursor
@@ -7,6 +7,14 @@ const migration = readFileSync(
new URL('../../db/migrations/142_voucher_consume_job_codes.sql', import.meta.url),
'utf8',
)
const fallbackMigration = readFileSync(
new URL('../../db/migrations/146_voucher_consume_fallback_channel.sql', import.meta.url),
'utf8',
)
const channelAssignSource = readFileSync(
new URL('./channel-assign-service.ts', import.meta.url),
'utf8',
)
test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => {
assert.ok(source.includes('keyword?: string'))
@@ -39,3 +47,19 @@ test('核销任务保存并展示核销码,执行时刷新候选券码', () =>
assert.ok(source.includes('updateVoucherConsumeJobCodes'))
assert.ok(source.includes('voucher_codes = $1::text[]'))
})
test('未命中履约渠道时按平台策略安排兜底核销,不修改订单渠道', () => {
assert.ok(source.includes('scheduleVoucherConsumeFallbackForOrderItems'))
assert.ok(source.includes("channel: 'unmatched'"))
assert.ok(source.includes("oi.fulfillment_pending_reason = 'no_channel_capability'"))
assert.ok(source.includes('未命中任何履约渠道时,按平台兜底策略安排核销'))
assert.ok(fallbackMigration.includes("'unmatched'"))
assert.ok(
channelAssignSource.includes('scheduleVoucherConsumeFallbackForOrderItems'),
'自动判渠未命中后必须进入兜底核销调度',
)
assert.ok(
channelAssignSource.includes("pending.reason === 'no_channel_capability'"),
'只允许未命中渠道进入兜底,不能覆盖无载体等其它问题',
)
})
@@ -30,6 +30,11 @@ test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒
mode: 'after_order_received',
delaySeconds: 5,
})
assert.deepEqual(policy.platforms.kuaishou_industry.channels.unmatched, {
enabled: false,
mode: 'after_order_received',
delaySeconds: 5,
})
})
test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => {
@@ -163,3 +168,24 @@ test('新版明确关闭第三方卡券时继续尊重用户配置', () => {
delaySeconds: 0,
})
})
test('旧配置自动补齐未命中渠道兜底,并支持手动开启', () => {
const legacy = normalizeVoucherConsumePolicy({ version: 2, enabled: true })
assert.equal(legacy.platforms.douyin_mashida.channels.unmatched.enabled, false)
const enabled = structuredClone(legacy)
enabled.platforms.douyin_mashida.channels.unmatched = {
enabled: true,
mode: 'after_order_received',
delaySeconds: 12,
}
assert.deepEqual(
resolveVoucherConsumePolicy({
provider: 'douyin_mashida',
channel: 'unmatched',
event: 'order_received',
policy: enabled,
}),
{ enabled: true, delaySeconds: 12, mode: 'after_order_received' },
)
})
@@ -11,6 +11,7 @@ export const VOUCHER_CONSUME_CHANNELS = [
'manual',
'third_party_voucher',
'on_account',
'unmatched',
] as const
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
@@ -156,6 +157,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
mode: 'manual',
delaySeconds: 0,
},
unmatched: {
enabled: false,
mode: 'after_order_received',
delaySeconds: 5,
},
},
}
}
@@ -199,6 +205,10 @@ function normalizePlatformPolicy(
'manual',
'after_order_received',
]),
unmatched: normalizeChannelPolicy(rawChannels.unmatched, fallback.channels.unmatched, [
'manual',
'after_order_received',
]),
},
}
}
@@ -99,6 +99,19 @@ type VoucherOrderItemTargetRow = {
platform: string
platform_order_id: string
fulfillment_channel: string
fulfillment_status: string
fulfillment_pending_reason: string
}
type VoucherConsumeTargetInsert = {
provider: VoucherConsumeProvider
channel: VoucherConsumeChannel
trigger_event: VoucherConsumeEvent
target_key: string
platform_order_id: string
order_id: number
order_item_id: number
due_at: string
}
const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800]
@@ -194,6 +207,8 @@ export async function scheduleVoucherConsumeForOrderItems(
oi.id,
oi.order_id,
oi.fulfillment_channel,
oi.fulfillment_status,
oi.fulfillment_pending_reason,
o.provider,
o.platform,
o.platform_order_id
@@ -205,16 +220,7 @@ export async function scheduleVoucherConsumeForOrderItems(
[ids],
)
const policy = getVoucherConsumePolicy()
const targets: Array<{
provider: VoucherConsumeProvider
channel: VoucherConsumeChannel
trigger_event: VoucherConsumeEvent
target_key: string
platform_order_id: string
order_id: number
order_item_id: number
due_at: string
}> = []
const targets: VoucherConsumeTargetInsert[] = []
for (const row of rows.rows) {
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
@@ -241,6 +247,76 @@ export async function scheduleVoucherConsumeForOrderItems(
})
}
return queueVoucherConsumeTargets(targets, event)
}
/**
* 未命中任何履约渠道时,按平台兜底策略安排核销。
*
* 兜底只产生核销任务,不修改订单项渠道或履约状态,后续仍由问题队列处理履约。
*/
export async function scheduleVoucherConsumeFallbackForOrderItems(
orderItemIds: number[],
event: Extract<VoucherConsumeEvent, 'order_received'> = 'order_received',
): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> {
const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
if (ids.length === 0) {
return { targetCount: 0, queuedCount: 0, jobIds: [] }
}
const rows = await query<VoucherOrderItemTargetRow>(
`
SELECT
oi.id,
oi.order_id,
oi.fulfillment_channel,
oi.fulfillment_status,
oi.fulfillment_pending_reason,
o.provider,
o.platform,
o.platform_order_id
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = ANY($1::bigint[])
AND oi.fulfillment_status = 'pending'
AND oi.fulfillment_pending_reason = 'no_channel_capability'
`,
[ids],
)
const policy = getVoucherConsumePolicy()
const targets: VoucherConsumeTargetInsert[] = []
for (const row of rows.rows) {
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
if (!provider) continue
const resolved = resolveVoucherConsumePolicy({
provider,
channel: 'unmatched',
event,
policy,
})
if (!resolved.enabled) continue
targets.push({
provider,
channel: 'unmatched',
trigger_event: event,
target_key: buildVoucherConsumeTargetKey(row.id, `order_item:${row.id}`),
platform_order_id: String(row.platform_order_id || '').trim(),
order_id: Number(row.order_id),
order_item_id: Number(row.id),
due_at: new Date(Date.now() + resolved.delaySeconds * 1000).toISOString(),
})
}
return queueVoucherConsumeTargets(targets, event)
}
async function queueVoucherConsumeTargets(
targets: VoucherConsumeTargetInsert[],
event: VoucherConsumeEvent,
): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> {
if (targets.length === 0) {
return { targetCount: 0, queuedCount: 0, jobIds: [] }
}
@@ -292,10 +368,11 @@ export async function scheduleVoucherConsumeForOrderItems(
[JSON.stringify(targets)],
)
logIntegration('[voucher-consume/queue]', '订单渠道确定后批量安排核销', {
logIntegration('[voucher-consume/queue]', '安排电子凭证核销任务', {
event,
targetCount: targets.length,
queuedCount: inserted.rowCount || 0,
channels: [...new Set(targets.map((target) => target.channel))],
jobIds: inserted.rows.map((row) => Number(row.id)),
})
@@ -943,7 +1020,8 @@ function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null {
channel === 'work_order' ||
channel === 'manual' ||
channel === 'third_party_voucher' ||
channel === 'on_account'
channel === 'on_account' ||
channel === 'unmatched'
) {
return channel
}
@@ -1968,15 +1968,22 @@ test('好友赠送待打手提交资料筛选排除待抢单订单', () => {
assert.match(whereClause, /wo\.status IN \('in_progress', 'problem'\)/)
})
test('工单筛选支持内部寄售匹配与打手好友赠送隔离,且赠送冷却中排除已验收已取消工单', () => {
test('工单筛选支持内部寄售来源叠加人员条件,并保持好友赠送与冷却筛选', () => {
const internalQuery = buildWorkOrderWhere({ acceptanceMode: 'internal_consignment' })
assert.match(
internalQuery.whereClause,
/wo\.material_json->'consignment'->>'inventoryId' IS NOT NULL/,
)
assert.doesNotMatch(internalQuery.whereClause, /wo\.assigned_worker_id IS NULL/)
const workerScopedInternalQuery = buildWorkOrderWhere({
acceptanceMode: 'internal_consignment',
workerSharingId: 25,
})
assert.match(workerScopedInternalQuery.joins, /worker_visible\.work_order_id = wo\.id/)
assert.match(
internalQuery.whereClause,
/wo\.assigned_worker_id IS NULL OR wo\.assigned_worker_id = 0/,
workerScopedInternalQuery.whereClause,
/wo\.material_json->'consignment'->>'inventoryId' IS NOT NULL/,
)
const friendGiftQuery = buildWorkOrderWhere({ acceptanceMode: 'friend_gift' })