diff --git a/apps/backend/src/db/migrations/146_voucher_consume_fallback_channel.sql b/apps/backend/src/db/migrations/146_voucher_consume_fallback_channel.sql new file mode 100644 index 00000000..4622bc35 --- /dev/null +++ b/apps/backend/src/db/migrations/146_voucher_consume_fallback_channel.sql @@ -0,0 +1,17 @@ +-- 146_voucher_consume_fallback_channel.sql +-- +-- 未命中任何履约渠道时,允许按平台兜底策略安排电子凭证核销。 +-- unmatched 只表示核销任务来源,不会写回 order_items.fulfillment_channel。 + +ALTER TABLE voucher_consume_jobs + DROP CONSTRAINT IF EXISTS voucher_consume_jobs_channel_check; + +ALTER TABLE voucher_consume_jobs + ADD CONSTRAINT voucher_consume_jobs_channel_check + CHECK (channel IN ( + 'affiliate_dash', 'work_order', 'manual', + 'third_party_voucher', 'on_account', 'unmatched' + )); + +COMMENT ON COLUMN voucher_consume_jobs.channel IS + '触发核销时的履约渠道;unmatched 表示订单未命中任何履约渠道,由平台兜底策略触发'; diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 1452e9ae..7491c542 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -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 diff --git a/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts index ba175b44..f5338c18 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts @@ -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'"), + '只允许未命中渠道进入兜底,不能覆盖无载体等其它问题', + ) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts index 63a9706e..893b64fb 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts @@ -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' }, + ) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts index 591c0562..569d9f6e 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts @@ -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', + ]), }, } } diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts index 26305404..59a2681b 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -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 = '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( + ` + 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 } diff --git a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx index 9ced49fe..3e2bbe01 100644 --- a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx @@ -42,6 +42,7 @@ const CHANNEL_LABEL: Record = { manual: '人工客服', on_account: '统一结算', third_party_voucher: '第三方卡券', + unmatched: '未命中渠道兜底', } const MODE_OPTIONS: Record< @@ -75,6 +76,10 @@ const MODE_OPTIONS: Record< { value: 'after_order_received', label: '收到订单后' }, { value: 'manual', label: '人工核销' }, ], + unmatched: [ + { value: 'after_order_received', label: '未命中渠道后' }, + { value: 'manual', label: '人工核销' }, + ], } type PolicyRow = { @@ -103,6 +108,7 @@ const CHANNEL_FILTER_OPTIONS: Array<{ value: VoucherConsumeChannel; label: strin { value: 'manual', label: CHANNEL_LABEL.manual }, { value: 'third_party_voucher', label: CHANNEL_LABEL.third_party_voucher }, { value: 'on_account', label: CHANNEL_LABEL.on_account }, + { value: 'unmatched', label: CHANNEL_LABEL.unmatched }, ] const JOB_STATUS_FILTER_OPTIONS: Array<{ diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index 6bc37c8a..539aea52 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -167,6 +167,7 @@ export type VoucherConsumeChannel = | 'manual' | 'on_account' | 'third_party_voucher' + | 'unmatched' export type VoucherConsumeMode = | 'manual' | 'after_order_received' diff --git a/apps/frontend/tests/voucher-consume-job-list-ui.test.ts b/apps/frontend/tests/voucher-consume-job-list-ui.test.ts index cc0cbf80..e19c7c29 100644 --- a/apps/frontend/tests/voucher-consume-job-list-ui.test.ts +++ b/apps/frontend/tests/voucher-consume-job-list-ui.test.ts @@ -21,3 +21,9 @@ test('最近核销任务使用服务端分页', () => { assert.ok(panel.includes('setPageSize(nextPageSize)')) assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}')) }) + +test('自动核销策略提供未命中履约渠道的平台兜底配置', () => { + assert.ok(panel.includes("unmatched: '未命中渠道兜底'")) + assert.ok(panel.includes("label: '未命中渠道后'")) + assert.ok(panel.includes("value: 'unmatched'")) +})