diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index 45109562..cb4f4aac 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -37,6 +37,7 @@ import { resolveWorkOrderOperationalStatus, } from './mappers.js' import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js' +import { tryMatchConsignmentWorkOrderBeforePublish } from './consignment-match-engine-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' import { USER_PAYMENT_AMOUNT_EDITABLE_STATUSES, @@ -363,6 +364,42 @@ export async function publishAdminWorkOrder( }) } + // 自动匹配:发布到大厅前先试一次内部库存;命中后工单直接 in_progress,不进大厅。 + const matchedByConsignment = await tryMatchConsignmentWorkOrderBeforePublish( + Number(workOrder.id), + ).catch(() => false) + if (matchedByConsignment) { + const currentOrder = await getRequiredWorkOrder(workOrder.id) + let voucherConsume: VoucherConsumeScheduleResult + try { + voucherConsume = await scheduleVoucherConsumeForWorkOrder( + currentOrder, + 'work_order_published', + ) + } catch (error) { + voucherConsume = { + ok: false, + voucherCount: 0, + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 1, + voucherCodes: [], + errorMessage: error instanceof Error ? error.message : '安排自动核销失败', + queuedCount: 0, + jobIds: [], + dueAt: '', + status: 'failed', + } + } + publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) }) + return { + order: mapWorkOrderAdmin(currentOrder), + voucherConsume, + hallVisible: false, + matchedByConsignment: true, + } + } + const now = nowIso() const published = await publishUnassignedWorkOrder({ diff --git a/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts b/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts new file mode 100644 index 00000000..56d5e7e2 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const ENGINE = new URL('./consignment-match-engine-service.ts', import.meta.url) +const PUBLISH = new URL('./admin-work-order-management-service.ts', import.meta.url) +const PUBLIC = new URL('./order-service-public-service.ts', import.meta.url) + +test('发布前即时匹配:只对 auto 生效,且允许从 unassigned 起始', async () => { + const source = await readFile(ENGINE, 'utf8') + const start = source.indexOf('export async function tryMatchConsignmentWorkOrderBeforePublish') + assert.ok(start >= 0, '找不到 tryMatchConsignmentWorkOrderBeforePublish') + const end = source.indexOf('export async function enterConsignmentInternalMatching', start) + const body = source.slice(start, end > start ? end : start + 5000) + assert.ok(body.includes("profile.config.matching.mode !== 'auto'"), '只处理 auto 模式') + assert.ok( + body.includes('startStatuses: [WORK_ORDER_STATUS.UNASSIGNED]'), + '必须允许从待分配直接匹配,否则 apply 的状态校验会拒绝', + ) + assert.ok(body.includes("phase: 'before_publish'"), '失败日志要能区分发布前匹配') +}) + +test('进入内部匹配只对 manual 生效:auto 不再占用等待窗口', async () => { + const source = await readFile(ENGINE, 'utf8') + const start = source.indexOf('export async function enterConsignmentInternalMatching') + assert.ok(start >= 0, '找不到 enterConsignmentInternalMatching') + const body = source.slice(start, start + 2000) + assert.ok(body.includes("profile.config.matching.mode !== 'manual'")) + assert.ok(!body.includes("mode === 'off'"), '旧的 off 判断必须被 manual 判断替换') +}) + +test('发布入口先试匹配:命中直接 in_progress,不进大厅', async () => { + const source = await readFile(PUBLISH, 'utf8') + const start = source.indexOf('export async function publishAdminWorkOrder') + assert.ok(start >= 0, '找不到 publishAdminWorkOrder') + const body = source.slice(start, start + 6000) + const tryIndex = body.indexOf('tryMatchConsignmentWorkOrderBeforePublish') + const publishIndex = body.indexOf('publishUnassignedWorkOrder') + assert.ok(tryIndex >= 0, '发布前必须调 tryMatchConsignmentWorkOrderBeforePublish') + assert.ok(publishIndex > tryIndex, '试匹配必须在发布大厅之前') + assert.ok(body.includes('matchedByConsignment: true')) + assert.ok(body.includes('hallVisible: false')) +}) + +test('补资料链路先试匹配,未命中才进入 manual 内部匹配', async () => { + const source = await readFile(PUBLIC, 'utf8') + const tryIndex = source.indexOf('tryMatchConsignmentWorkOrderBeforePublish(') + const enterIndex = source.indexOf('enterConsignmentInternalMatching(') + assert.ok(tryIndex >= 0, '补资料后必须调用发布前试匹配') + assert.ok(enterIndex > tryIndex, '试匹配必须在进入内部匹配之前') +}) + +test('内部匹配超时:fallbackToHall=false 时重新匹配,最多 3 次', async () => { + const source = await readFile(ENGINE, 'utf8') + assert.ok(source.includes('export const MAX_INTERNAL_MATCH_ATTEMPTS = 3')) + const start = source.indexOf('export async function expireConsignmentInternalMatching') + assert.ok(start >= 0, '找不到 expireConsignmentInternalMatching') + const body = source.slice(start, start + 3000) + assert.ok(body.includes('fallbackToHall'), '必须读取超时回大厅开关') + assert.ok(body.includes('MAX_INTERNAL_MATCH_ATTEMPTS'), '必须有重试次数上限') + assert.ok(body.includes('startConsignmentRematch'), '关闭回大厅时要重新匹配') + assert.ok(body.includes("status = 'open'"), '超过次数或失败后仍要回大厅兜底') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index d01b8b2e..58ac5269 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -339,6 +339,136 @@ export async function resolveProfileForWorkOrder( return resolveConsignmentProfileForCategory(workOrder.category_id) } +/** 内部匹配超时后最多重新匹配的次数(fallbackToHall=false 时生效)。 */ +export const MAX_INTERNAL_MATCH_ATTEMPTS = 3 + +/** + * 发布前即时匹配(仅 matching.mode='auto'): + * 工单仍在 unassigned 时直接尝试占库,命中后由 apply 事务改成 in_progress, + * 调用方据此跳过大厅发布;未命中返回 false,由调用方继续走原发布流程。 + * + * 与 30 秒轮询的区别:不等 internal_matching 窗口,发布动作发生时立刻试一次, + * 失败直接进大厅;写入的匹配日志 phase=before_publish,便于区分来源。 + */ +export async function tryMatchConsignmentWorkOrderBeforePublish( + workOrderId: number, +): Promise { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false + const profile = await resolveProfileForWorkOrder(workOrder) + if (!profile || profile.config.matching.mode !== 'auto') return false + + const startedAt = Date.now() + const logBase = { + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no || '', + profileId: profile.profileId, + categoryId: profile.categoryId, + triggerType: 'auto' as const, + actorType: 'system', + actorId: 'consignment_match', + } + + try { + const inventories = await listActiveConsignmentInventoriesForMatching() + const candidates = pickCandidateInventories(workOrder, profile, inventories, { + allowPartial: profile.config.matching.exactOnly === false, + preferLowReward: profile.config.matching.preferLowReward !== false, + orderSpecOverride: await resolveOrderSpecOverride(workOrder, profile), + }) + if (candidates.length === 0) { + await recordConsignmentMatchLog({ + ...logBase, + result: 'skipped', + reasonCode: 'no_candidate', + reasonText: '发布前没有符合条件的在库库存', + candidateCount: inventories.length, + durationMs: Date.now() - startedAt, + payload: { inventoryCount: inventories.length, phase: 'before_publish' }, + }) + return false + } + + const context: ConsignmentMatchContext = { + matchType: 'auto', + // 发布前工单还没进 internal_matching,必须显式允许从 unassigned 起步, + // 否则 applyWholeOrderMatch / applySharingMatch 的默认状态校验会直接拒绝。 + startStatuses: [WORK_ORDER_STATUS.UNASSIGNED], + partial: candidates.some(({ evaluation }) => !evaluation.exact), + specConflicts: candidates.flatMap(({ evaluation }) => [ + ...evaluation.conflicts, + ...evaluation.unknowns, + ]), + } + + if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { + const created = await applySharingMatch({ workOrder, profile }, candidates, context) + await recordConsignmentMatchLog({ + ...logBase, + result: created > 0 ? 'matched' : 'failed', + reasonCode: created > 0 ? 'auto_sharing_matched_before_publish' : 'sharing_not_applied', + reasonText: created > 0 ? '' : '发布前拼单匹配未创建任何份额', + candidateCount: candidates.length, + matchedCount: created, + durationMs: Date.now() - startedAt, + payload: { + phase: 'before_publish', + partial: context.partial, + specConflicts: context.specConflicts, + inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)), + }, + }) + return created > 0 + } + + const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const friendGift = isFriendGiftAcceptanceMode(workOrder.acceptance_mode) + const chosen = candidates.find((candidate) => { + const available = Number(candidate.inventory.remaining_quantity ?? 1) + return available >= requiredQuantity && (!friendGift || available === requiredQuantity) + }) + if (!chosen) { + await recordConsignmentMatchLog({ + ...logBase, + result: 'skipped', + reasonCode: 'quantity_insufficient', + reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`, + candidateCount: candidates.length, + durationMs: Date.now() - startedAt, + payload: { phase: 'before_publish', requiredQuantity }, + }) + return false + } + + const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, context) + await recordConsignmentMatchLog({ + ...logBase, + result: applied ? 'matched' : 'failed', + reasonCode: applied ? 'auto_matched_before_publish' : 'apply_not_applied', + reasonText: applied ? '' : '发布前整单匹配未落库(库存可能已被占用)', + candidateCount: candidates.length, + matchedCount: applied ? 1 : 0, + durationMs: Date.now() - startedAt, + payload: { + phase: 'before_publish', + partial: context.partial, + inventoryId: Number(chosen.inventory.id), + }, + }) + return applied + } catch (error) { + await recordConsignmentMatchLog({ + ...logBase, + result: 'failed', + reasonCode: 'exception', + reasonText: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startedAt, + payload: { phase: 'before_publish' }, + }).catch(() => undefined) + return false + } +} + /** 进入「内部匹配中」:命中分类寄卖配置时由资料提交/自动发布链路调用。 */ export async function enterConsignmentInternalMatching( workOrderId: number, @@ -348,8 +478,9 @@ export async function enterConsignmentInternalMatching( if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false const profile = await resolveProfileForWorkOrder(workOrder) if (!profile) return false - // 关闭匹配:新单直接进大厅,不进入内部匹配队列。 - if (profile.config.matching.mode === 'off') return false + // auto 模式改为「发布时即时匹配、失败直接进大厅」,不再占用内部匹配等待窗口; + // 只有 manual 需要把工单留在 internal_matching 等运营人工挑库存。 + if (profile.config.matching.mode !== 'manual') return false const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch) // 0 分钟 = 不等待,直接发大厅。此时不能进入 internal_matching: // addMinutesIso(0) 返回 null,工单会变成「内部匹配中且无截止时间」, @@ -1252,24 +1383,55 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number } return { scanned: orders.rows.length, matched } } -/** 内部匹配超时:发回大厅(保留已占库存的拼单份额,剩余份数继续抢单)。 */ +/** + * 内部匹配超时: + * - fallbackToHall=true(默认):发回大厅; + * - fallbackToHall=false:重新匹配(排除原卖家/库存),最多 MAX_INTERNAL_MATCH_ATTEMPTS 次; + * 超过次数或重新匹配失败仍回大厅,避免工单永久悬空。 + * 拼单已占库存的份额不受影响,剩余份数继续抢单。 + */ export async function expireConsignmentInternalMatching(options: { now?: string } = {}) { const now = options.now || nowIso() const expired = await query( ` - UPDATE work_orders - SET status = 'open', - published_at = COALESCE(published_at, $1), - hall_visible_at = NULL, - internal_match_deadline_at = NULL, - updated_at = $1 + SELECT * FROM work_orders WHERE status = 'internal_matching' AND (internal_match_deadline_at IS NULL OR internal_match_deadline_at <= $1) - RETURNING * `, [now], ) + let expiredCount = 0 + let rematched = 0 for (const workOrder of expired.rows) { + const profile = await resolveProfileForWorkOrder(workOrder) + const fallbackToHall = profile?.config.internalMatch.fallbackToHall !== false + const attempts = Number(workOrder.internal_match_attempts || 0) + if (!fallbackToHall && attempts < MAX_INTERNAL_MATCH_ATTEMPTS) { + const started = await startConsignmentRematch(Number(workOrder.id), {}, now).catch( + () => false, + ) + if (started) { + rematched += 1 + continue + } + // 重新匹配失败时 startConsignmentRematch 内部已把工单放回大厅, + // 下面的 UPDATE 带 status='internal_matching' 条件,不会重复命中。 + } + const updated = await query<{ id: number }>( + ` + UPDATE work_orders + SET status = 'open', + published_at = COALESCE(published_at, $1), + hall_visible_at = NULL, + internal_match_deadline_at = NULL, + updated_at = $1 + WHERE id = $2 AND status = 'internal_matching' + RETURNING id + `, + [now, workOrder.id], + ) + if (!updated.rows[0]) continue + expiredCount += 1 await query( ` INSERT INTO work_order_events ( @@ -1277,10 +1439,14 @@ export async function expireConsignmentInternalMatching(options: { now?: string payload_json, created_at ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3) `, - [workOrder.id, JSON.stringify({ expiredAt: now }), now], + [ + workOrder.id, + JSON.stringify({ expiredAt: now, attempts, fallbackToHall, rematched: false }), + now, + ], ) } - return { expired: expired.rows.length } + return { expired: expiredCount, rematched } } /** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */ diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index ae4fbd21..d822f5bb 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -17,7 +17,10 @@ import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' -import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js' +import { + enterConsignmentInternalMatching, + tryMatchConsignmentWorkOrderBeforePublish, +} from './consignment-match-engine-service.js' import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js' import { ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, @@ -387,8 +390,14 @@ export async function submitOrderServiceMaterial( } publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) - // 命中分类寄卖配置时先进入内部匹配,不发大厅;超时由调度任务发回大厅。 - await enterConsignmentInternalMatching(Number(result.order.id)).catch(() => false) + // 发布前先试一次自动匹配:命中直接 in_progress,不进大厅; + // 未命中再走原逻辑(manual 进内部匹配等待,off/auto 留给发布链路发大厅)。 + const matchedBeforePublish = await tryMatchConsignmentWorkOrderBeforePublish( + Number(result.order.id), + ).catch(() => false) + if (!matchedBeforePublish) { + await enterConsignmentInternalMatching(Number(result.order.id)).catch(() => false) + } if (result.autoPublishEnabled) { void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined) diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 35500f35..badb5549 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -1756,8 +1756,13 @@ export default function ConsignmentConfigPanel() { + + + diff --git a/apps/frontend/tests/consignment-auto-match-publish.test.ts b/apps/frontend/tests/consignment-auto-match-publish.test.ts new file mode 100644 index 00000000..873daf83 --- /dev/null +++ b/apps/frontend/tests/consignment-auto-match-publish.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +function readPanelSource() { + return readFileSync( + fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentConfigPanel.tsx', import.meta.url)), + 'utf8', + ).replace(/\r\n/g, '\n') +} + +test('自动匹配:手动等待与超时策略在 auto 下禁用并给出说明', () => { + const source = readPanelSource() + assert.ok(source.includes('label="手动匹配等待(分钟)"'), '等待时长文案必须明确只作用于手动匹配') + assert.ok(source.includes('label="超时后回大厅"'), '必须提供超时回大厅开关') + assert.ok( + source.includes('自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'), + 'auto 模式必须说明不使用等待窗口', + ) + assert.ok( + source.includes('关闭后超时重新匹配(最多 3 次),仍未命中回大厅'), + '关闭回大厅时必须说明重试上限', + ) + assert.ok( + (source.match(/disabled=\{matchingMode === 'auto'\}/g) || []).length >= 2, + '等待时长与超时策略在 auto 下都必须禁用', + ) + assert.ok(!source.includes('label="内部匹配等待(分钟)"'), '旧文案必须移除') +}) diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts index e02b70b4..6df2ab52 100644 --- a/apps/frontend/tests/consignment-matching-config.test.ts +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -35,7 +35,7 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/ '开启单人寄售上限', '匹配模式', '关闭匹配', - '内部匹配等待(分钟)', + '手动匹配等待(分钟)', '代练超时(小时)', '重新匹配并退还押金', '开启降价提示',