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/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 24973f27..5441b526 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -1533,12 +1533,9 @@ export function buildWorkOrderWhere({ if (acceptanceMode) { const rawMode = String(acceptanceMode).trim() if (rawMode === 'internal_consignment') { - filters.push(`( - wo.material_json->'consignment'->>'inventoryId' IS NOT NULL - AND (wo.material_json->'consignment'->>'fulfillmentType' IS NULL OR wo.material_json->'consignment'->>'fulfillmentType' != 'worker_deposit') - AND (wo.material_json->'consignment'->>'workerId' IS NULL OR wo.material_json->'consignment'->>'workerId' = '0') - AND (wo.assigned_worker_id IS NULL OR wo.assigned_worker_id = 0) - )`) + // 「寄售来源」与是否已经匹配打手是两个正交条件。 + // 这里不能内置“必须未指派”,否则再接 workerId 筛选会恒为空。 + filters.push(`wo.material_json->'consignment'->>'inventoryId' IS NOT NULL`) } else if (rawMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT) { filters.push(`( wo.acceptance_mode = 'friend_gift' 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/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 220e7eb1..5320a022 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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' }) 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/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index a13db607..88ebd793 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -2399,10 +2399,12 @@ export default function WorkOrdersPanel() { setPage(1) }} /> - - +
+ + +
本地 Mock 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/src/styles/worker.css b/apps/frontend/src/styles/worker.css index e9e9913c..b5c0cce0 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -139,49 +139,55 @@ min-width: 0; } -/* 单行筛选栏:搜索框、筛选条件、清空按钮同一行且不换行;空间不足时行内横向滚动 */ +/* 筛选条件按内容分配宽度;空间不足时整组换行,避免单个下拉挤占整行。 */ .worker-orders-filter-bar { display: flex; align-items: center; - flex-wrap: nowrap; + flex-wrap: wrap; gap: 8px; width: 100%; - overflow-x: auto; - scrollbar-width: thin; - padding-bottom: 2px; } .worker-orders-search-input { - flex: 1.4 1 240px; - min-width: 180px; - max-width: 300px; + flex: 1 1 260px; + min-width: 220px; + max-width: 340px; } .worker-orders-filter-select { - flex: 1 1 110px; - min-width: 88px; + flex: 0 1 165px; + width: 165px; + min-width: 145px; + max-width: 185px; } .worker-orders-filter-select--wide { - flex: 1.6 1 140px; - min-width: 112px; + flex: 0 1 220px; + width: 220px; + min-width: 190px; + max-width: 240px; } -/* 状态:多选标签可截断,宽度让给时间区间 */ +/* 状态是多选标签,保留独立宽度但不再随剩余空间无限拉伸。 */ .worker-orders-filter-select--compact { - flex: 0.9 1 96px; - min-width: 80px; + flex: 0 1 170px; + width: 170px; + min-width: 150px; + max-width: 190px; } /* 时间区间:留足两个完整日期(YYYY-MM-DD)与「开始日期/结束日期」占位文案的宽度 */ .worker-orders-date-range { - flex: 0 0 230px; - width: 230px; - min-width: 230px; + flex: 0 0 220px; + width: 220px; + min-width: 220px; } -.worker-orders-filter-bar > .ant-btn { +.worker-orders-filter-actions { + display: flex; + align-items: center; flex: 0 0 auto; + gap: 8px; } .worker-orders-mock { diff --git a/apps/frontend/src/utils/consignment-display.ts b/apps/frontend/src/utils/consignment-display.ts index e78180cc..68821294 100644 --- a/apps/frontend/src/utils/consignment-display.ts +++ b/apps/frontend/src/utils/consignment-display.ts @@ -34,11 +34,10 @@ export type ConsignmentWorkerDisplayInfo = { } /** - * 内部现货履约单:由客服/内部仓代交付,工单上没有接单打手。 + * 尚未匹配打手的内部现货履约单:由客服/内部仓代交付,工单上没有接单打手。 * - * 口径必须与后端「内部寄售匹配」筛选(work-order-query-repo 的 internal_consignment 分支)一致: - * `material.consignment.inventoryId` 有值、`fulfillmentType ≠ worker_deposit`、 - * `consignment.workerId` 缺失或为 0(后端按字符串 '0' 也视为无打手)、且工单未指派打手。 + * 这是操作能力判断,不用于「内部寄售」来源筛选;来源筛选允许匹配前后, + * 因此可以和接单人员条件叠加。 */ export function isInternalConsignmentOrder(row: { material?: unknown; worker?: unknown }): boolean { const consignment = asRecord(asRecord(row.material).consignment) 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'")) +}) diff --git a/apps/frontend/tests/work-order-filter-layout.test.ts b/apps/frontend/tests/work-order-filter-layout.test.ts new file mode 100644 index 00000000..afae9e75 --- /dev/null +++ b/apps/frontend/tests/work-order-filter-layout.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('接单工单筛选按内容分配宽度,店铺下拉不能吞掉查询区', async () => { + const [panel, css] = await Promise.all([ + readFile(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url), 'utf8'), + readFile(new URL('../src/styles/worker.css', import.meta.url), 'utf8'), + ]) + + assert.ok(panel.includes('className="worker-orders-filter-actions"')) + assert.ok(css.includes('flex-wrap: wrap')) + assert.ok(css.includes('.worker-orders-filter-select--wide')) + assert.ok(css.includes('width: 220px')) + assert.ok(css.includes('max-width: 240px')) + assert.ok(css.includes('.worker-orders-filter-actions')) + assert.ok(!css.includes('overflow-x: auto;\n scrollbar-width: thin;')) +})