diff --git a/apps/backend/src/db/migrations/160_consignment_auto_match_setting.sql b/apps/backend/src/db/migrations/160_consignment_auto_match_setting.sql new file mode 100644 index 00000000..c751940f --- /dev/null +++ b/apps/backend/src/db/migrations/160_consignment_auto_match_setting.sql @@ -0,0 +1,91 @@ +-- 160_consignment_auto_match_setting.sql +-- +-- 匹配设置从「off / manual / auto 三态」收敛为「是否开启自动匹配」: +-- 1. 旧 mode=auto 视为开启,其余视为关闭; +-- 2. 新增匹配订单生效时间 orderEffectiveFrom,存量配置回填为迁移执行时间; +-- 3. 取消内部匹配等待:历史 internal_matching 工单统一回到待抢单 open。 +-- +-- 全部语句幂等;不删除旧字段,方便回滚与旧客户端读取。 + +-- =========================================================================== +-- 1. 配置草稿:补 autoMatchEnabled / orderEffectiveFrom +-- =========================================================================== +UPDATE consignment_profiles p +SET config_json = jsonb_set( + COALESCE(p.config_json, '{}'::jsonb), + '{matching}', + COALESCE(p.config_json -> 'matching', '{}'::jsonb) + || jsonb_build_object( + 'autoMatchEnabled', + CASE + WHEN p.config_json #> '{matching,autoMatchEnabled}' IS NOT NULL + THEN p.config_json #> '{matching,autoMatchEnabled}' + WHEN p.config_json #>> '{matching,mode}' = 'auto' THEN 'true'::jsonb + ELSE 'false'::jsonb + END, + 'orderEffectiveFrom', + CASE + WHEN COALESCE(p.config_json #>> '{matching,orderEffectiveFrom}', '') <> '' + THEN to_jsonb(p.config_json #>> '{matching,orderEffectiveFrom}') + ELSE to_jsonb(NOW()) + END + ), + true +) +WHERE jsonb_typeof(COALESCE(p.config_json, '{}'::jsonb)) = 'object'; + +-- =========================================================================== +-- 2. 已发布版本:同一口径回填,保证线上读取立即一致 +-- =========================================================================== +UPDATE consignment_profile_versions v +SET config_json = jsonb_set( + COALESCE(v.config_json, '{}'::jsonb), + '{matching}', + COALESCE(v.config_json -> 'matching', '{}'::jsonb) + || jsonb_build_object( + 'autoMatchEnabled', + CASE + WHEN v.config_json #> '{matching,autoMatchEnabled}' IS NOT NULL + THEN v.config_json #> '{matching,autoMatchEnabled}' + WHEN v.config_json #>> '{matching,mode}' = 'auto' THEN 'true'::jsonb + ELSE 'false'::jsonb + END, + 'orderEffectiveFrom', + CASE + WHEN COALESCE(v.config_json #>> '{matching,orderEffectiveFrom}', '') <> '' + THEN to_jsonb(v.config_json #>> '{matching,orderEffectiveFrom}') + ELSE to_jsonb(NOW()) + END + ), + true +) +WHERE jsonb_typeof(COALESCE(v.config_json, '{}'::jsonb)) = 'object'; + +-- =========================================================================== +-- 3. 取消内部匹配等待:历史 internal_matching 工单回到待抢单 +-- =========================================================================== +WITH migrated AS ( + UPDATE work_orders + SET status = 'open', + published_at = COALESCE(published_at, NOW()), + hall_queued_at = COALESCE(hall_queued_at, NOW()), + internal_match_deadline_at = NULL, + hall_visible_at = NULL, + updated_at = NOW() + WHERE status = 'internal_matching' + RETURNING id +) +INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, + payload_json, created_at +) +SELECT + id, + 'system', + 'consignment_migration', + 'consignment_internal_matching_migrated', + 'internal_matching', + 'open', + '{"reason":"auto_match_setting_migration"}'::jsonb, + NOW() +FROM migrated; diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index a6c1ec41..61ec1573 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -669,11 +669,8 @@ export async function settleOverdueWorkOrder(input: { } } - const targetStatus = isRematch - ? 'internal_matching' - : input.policy === 'reopen' - ? 'open' - : 'cancelled' + // 重匹配不再进入内部匹配池:超时结算后先回到待抢单,由超时服务立即试一次自动匹配。 + const targetStatus = isRematch || input.policy === 'reopen' ? 'open' : 'cancelled' await client.query( ` @@ -686,8 +683,7 @@ export async function settleOverdueWorkOrder(input: { ${CLEAR_DELIVERY_RESIDUE_SQL}, published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, - -- 重新匹配由 startConsignmentRematch 补正式截止时间;这里先写 1 分钟兜底, - -- 即使后续补写失败,超时任务也能把工单发回大厅,不会永久悬空。 + -- 重匹配也先回到 open,由超时服务立即尝试自动匹配;未命中就是待抢单。 internal_match_deadline_at = CASE WHEN $1 = 'internal_matching' THEN ($2::timestamptz + INTERVAL '1 minute') ELSE internal_match_deadline_at END, diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index 6a238436..e23a257e 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -37,7 +37,6 @@ import { enqueueOrderServiceAutoPublish, processDueOrderServiceAutoPublishJobs, } from './order-service-auto-publish-service.js' -import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -204,9 +203,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined) } } - if (materialComplete && !autoPublishQueued) { - await enterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false) - } + // 自动匹配统一在真正发布到大厅时执行;这里不再进入内部匹配等待。 return { order: mapWorkOrderAdmin(workOrder), autoPublishQueued } } diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index 0fb128f7..d43e2842 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -6,6 +6,7 @@ import { cancelWorkOrderSharing, cancelWorkOrderSharingPart, countWorkOrderPendingSharingSubmissions, + getWorkOrderById, reduceWorkOrderShareQuantity, updateWorkOrder, } from '../../repositories/worker-platform/index.js' @@ -28,6 +29,7 @@ import { normalizeOptionalId, resolveWorkOrderOperationalStatus, } from './mappers.js' +import { tryAutoMatchBeforeHall } from './consignment-match-engine-service.js' import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' import { @@ -178,15 +180,21 @@ export async function cancelAdminWorkOrderShare( errorCode: 'work_order_sharing_share_cancel_status_invalid', }) } + let finalOrder = result.order + if (finalOrder.status === WORK_ORDER_STATUS.OPEN) { + // 最后一份份额被撤销后母单回到待抢单:立即尝试一次自动匹配。 + const matched = await tryAutoMatchBeforeHall(Number(workOrderId)).catch(() => false) + if (matched) finalOrder = (await getWorkOrderById(Number(workOrderId))) || finalOrder + } const workerId = Number(result.share.worker_id || 0) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrderId), - workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order), - hallChanged: true, + workerIds: await resolveWorkOrderRealtimeWorkerIds(finalOrder), + hallChanged: finalOrder.status === WORK_ORDER_STATUS.OPEN, }) if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) return { - order: mapWorkOrderAdmin(result.order), + order: mapWorkOrderAdmin(finalOrder), share: mapWorkOrderShare(result.share), releasedDepositAmount: result.releasedDepositAmount, } @@ -246,13 +254,18 @@ export async function reduceAdminWorkOrderShareQuantity( }) } + let finalOrder = result.order + if (finalOrder.status === WORK_ORDER_STATUS.OPEN) { + const matched = await tryAutoMatchBeforeHall(Number(workOrderId)).catch(() => false) + if (matched) finalOrder = (await getWorkOrderById(Number(workOrderId))) || finalOrder + } publishWorkOrderRealtimeChange({ workOrderId: Number(workOrderId), - workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order), - hallChanged: true, + workerIds: await resolveWorkOrderRealtimeWorkerIds(finalOrder), + hallChanged: finalOrder.status === WORK_ORDER_STATUS.OPEN, }) return { - order: mapWorkOrderAdmin(result.order), + order: mapWorkOrderAdmin(finalOrder), share: mapWorkOrderShare(result.share), } } @@ -316,16 +329,22 @@ export async function cancelAdminWorkOrderSharing( errorCode: 'work_order_sharing_cancel_conflict', }) } - const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order) + let finalOrder = result.order + if (action === 'return_to_hall') { + // 拼单整单退回大厅后立即尝试一次自动匹配;命中则直接回到进行中。 + const matched = await tryAutoMatchBeforeHall(Number(workOrderId)).catch(() => false) + if (matched) finalOrder = (await getWorkOrderById(Number(workOrderId))) || finalOrder + } + const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(finalOrder) const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])] publishWorkOrderRealtimeChange({ workOrderId: Number(workOrderId), workerIds, - hallChanged: action === 'return_to_hall', + hallChanged: action === 'return_to_hall' && finalOrder.status === WORK_ORDER_STATUS.OPEN, }) for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId) return { - order: mapWorkOrderAdmin(result.order), + order: mapWorkOrderAdmin(finalOrder), action, cancelledShareCount: result.cancelledShareCount, releasedDepositAmount: result.releasedDepositAmount, @@ -404,16 +423,21 @@ export async function cancelAdminWorkOrderSharingPart( errorCode: 'work_order_sharing_part_cancel_conflict', }) } - const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order) + let finalOrder = result.order + if (finalOrder.status === WORK_ORDER_STATUS.OPEN) { + const matched = await tryAutoMatchBeforeHall(Number(workOrderId)).catch(() => false) + if (matched) finalOrder = (await getWorkOrderById(Number(workOrderId))) || finalOrder + } + const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(finalOrder) const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])] publishWorkOrderRealtimeChange({ workOrderId: Number(workOrderId), workerIds, - hallChanged: true, + hallChanged: finalOrder.status === WORK_ORDER_STATUS.OPEN, }) for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId) return { - order: mapWorkOrderAdmin(result.order), + order: mapWorkOrderAdmin(finalOrder), cancelledShareCount: result.cancelledShareIds.length, cancelledQuantity: result.cancelledQuantity, releasedDepositAmount: result.releasedDepositAmount, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts index 84a530dc..196f68bc 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts @@ -12,6 +12,7 @@ import { publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' import { mapWorkOrderAdmin } from './mappers.js' +import { tryAutoMatchBeforeHall } from './consignment-match-engine-service.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' export async function unassignAdminWorkOrder(workOrderId: number | string, actorName = '') { @@ -109,12 +110,20 @@ export async function cancelAdminWorkOrder( errorCode: 'work_order_cancel_conflict', }) } + let finalOrder = updated + if (action === 'return_to_hall') { + // 退回大厅后立即按配置尝试一次自动匹配;命中则直接回到进行中,打手端不会看到大厅闪现。 + const matched = await tryAutoMatchBeforeHall(Number(workOrder.id), { + startStatuses: [WORK_ORDER_STATUS.OPEN], + }).catch(() => false) + if (matched) finalOrder = (await getRequiredWorkOrder(workOrder.id)) || finalOrder + } const workerId = Number(workOrder.assigned_worker_id) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds: workerId ? [workerId] : [], - hallChanged: action === 'return_to_hall', + hallChanged: action === 'return_to_hall' && finalOrder.status === WORK_ORDER_STATUS.OPEN, }) if (workerId) publishWorkerWalletRealtimeChange(workerId) - return { order: mapWorkOrderAdmin(updated), action } + return { order: mapWorkOrderAdmin(finalOrder), action } } 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 cb4f4aac..a9191881 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,7 +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 { tryAutoMatchBeforeHall } from './consignment-match-engine-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' import { USER_PAYMENT_AMOUNT_EDITABLE_STATUSES, @@ -341,8 +341,19 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string }) } - publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true }) - return { order: mapWorkOrderAdmin(updated) } + let finalOrder = updated + const matchedByConsignment = await tryAutoMatchBeforeHall(Number(workOrder.id), { + startStatuses: [WORK_ORDER_STATUS.OPEN], + }).catch(() => false) + if (matchedByConsignment) { + finalOrder = (await getRequiredWorkOrder(workOrder.id)) || finalOrder + } + + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrder.id), + hallChanged: finalOrder.status === WORK_ORDER_STATUS.OPEN, + }) + return { order: mapWorkOrderAdmin(finalOrder) } } export async function publishAdminWorkOrder( @@ -365,9 +376,7 @@ export async function publishAdminWorkOrder( } // 自动匹配:发布到大厅前先试一次内部库存;命中后工单直接 in_progress,不进大厅。 - const matchedByConsignment = await tryMatchConsignmentWorkOrderBeforePublish( - Number(workOrder.id), - ).catch(() => false) + const matchedByConsignment = await tryAutoMatchBeforeHall(Number(workOrder.id)).catch(() => false) if (matchedByConsignment) { const currentOrder = await getRequiredWorkOrder(workOrder.id) let voucherConsume: VoucherConsumeScheduleResult diff --git a/apps/backend/src/services/worker-platform/admin-worker-cancel-service.ts b/apps/backend/src/services/worker-platform/admin-worker-cancel-service.ts index 0c767872..62974325 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-cancel-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-cancel-service.ts @@ -1,4 +1,5 @@ import { + getWorkOrderById, listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin, reviewWorkerCancelRequest, } from '../../repositories/worker-platform/index.js' @@ -11,6 +12,7 @@ import { publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' import { mapWorkOrderAdmin, mapWorkerCancelRequest } from './mappers.js' +import { tryAutoMatchBeforeHall } from './consignment-match-engine-service.js' export async function listAdminWorkerCancelRequests(query: JsonObject = {}) { const status = String(query.status || 'pending').trim() @@ -63,16 +65,24 @@ export async function reviewAdminWorkerCancelRequest( Number(result.request.id), approved ? 'approved' : 'rejected', ) + let finalOrder = result.order + if (approved) { + // 打手退单通过后工单回到待抢单;立即按配置尝试一次自动匹配,命中则不暴露大厅。 + const matched = await tryAutoMatchBeforeHall(Number(result.order.id)).catch(() => false) + if (matched) { + finalOrder = (await getWorkOrderById(Number(result.order.id))) || finalOrder + } + } const workerId = Number(result.request.worker_id) publishWorkOrderRealtimeChange({ - workOrderId: Number(result.order.id), + workOrderId: Number(finalOrder.id), workerIds: workerId > 0 ? [workerId] : [], - hallChanged: approved, + hallChanged: approved && finalOrder.status === 'open', }) if (approved && workerId > 0) publishWorkerWalletRealtimeChange(workerId) return { request: mapWorkerCancelRequest(result.request), - order: mapWorkOrderAdmin(result.order), + order: mapWorkOrderAdmin(finalOrder), approved, releasedDepositAmount: result.releasedDepositAmount, levelChanged: result.levelChanged, 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 index 100c43cb..eddbebf7 100644 --- 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 @@ -5,28 +5,34 @@ 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) +const AUTO_PUBLISH = new URL('./order-service-auto-publish-service.ts', import.meta.url) +const TIMEOUT = new URL('./worker-order-timeout-service.ts', import.meta.url) -test('发布前即时匹配:只对 auto 生效,且允许从 unassigned 起始', async () => { +test('进入大厅前自动匹配:只认 autoMatchEnabled 与生效时间,允许 unassigned/open 起始', 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'"), '失败日志要能区分发布前匹配') + const start = source.indexOf('export async function tryAutoMatchBeforeHall') + assert.ok(start >= 0, '找不到 tryAutoMatchBeforeHall') + const body = source.slice(start, start + 6000) + assert.ok(body.includes('profile.config.matching.autoMatchEnabled'), '必须检查自动匹配开关') + assert.ok(body.includes('orderEffectiveFrom'), '必须按匹配生效时间过滤订单') + assert.ok(body.includes('startStatuses'), '必须把起始状态传给落库事务') + assert.ok(body.includes("phase: 'before_hall'"), '日志要能区分进入大厅前匹配') + assert.ok(!body.includes('matching.mode'), '不得再读取旧的三态 mode') }) -test('进入内部匹配只对 manual 生效:auto 不再占用等待窗口', async () => { +test('不再保留内部匹配等待、批量自动匹配与定时任务', 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 判断替换') + for (const removed of [ + 'enterConsignmentInternalMatching', + 'reenterConsignmentInternalMatching', + 'expireConsignmentInternalMatching', + 'autoMatchConsignmentWorkOrders', + 'startConsignmentMatchScheduler', + 'stopConsignmentMatchScheduler', + 'resolveInternalMatchDelayMinutes', + ]) { + assert.ok(!source.includes(removed), `${removed} 必须删除`) + } }) test('发布入口先试匹配:命中直接 in_progress,不进大厅', async () => { @@ -34,30 +40,29 @@ test('发布入口先试匹配:命中直接 in_progress,不进大厅', async 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 tryIndex = body.indexOf('tryAutoMatchBeforeHall') const publishIndex = body.indexOf('publishUnassignedWorkOrder') - assert.ok(tryIndex >= 0, '发布前必须调 tryMatchConsignmentWorkOrderBeforePublish') + assert.ok(tryIndex >= 0, '发布前必须调 tryAutoMatchBeforeHall') assert.ok(publishIndex > tryIndex, '试匹配必须在发布大厅之前') assert.ok(body.includes('matchedByConsignment: true')) assert.ok(body.includes('hallVisible: false')) }) -test('补资料链路先试匹配,未命中才进入 manual 内部匹配', async () => { +test('资料提交链路不再提前匹配或进入内部等待,统一由发布入口处理', 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, '试匹配必须在进入内部匹配之前') + assert.ok(!source.includes('tryAutoMatchBeforeHall('), '资料提交阶段不得提前占库') + assert.ok(!source.includes('enterConsignmentInternalMatching('), '不得再进入内部匹配等待') }) -test('内部匹配超时一律回大厅,不再支持超时重匹配', async () => { - const source = await readFile(ENGINE, 'utf8') - assert.ok(!source.includes('MAX_INTERNAL_MATCH_ATTEMPTS'), '重匹配次数上限必须删除') - const start = source.indexOf('export async function expireConsignmentInternalMatching') - assert.ok(start >= 0, '找不到 expireConsignmentInternalMatching') - const end = source.indexOf('export async function', start + 1) - const body = source.slice(start, end > start ? end : start + 2000) - assert.ok(!body.includes('fallbackToHall'), '到期逻辑不得再读取回大厅开关') - assert.ok(!body.includes('startConsignmentRematch'), '到期逻辑不得再调用重匹配') - assert.ok(body.includes("status = 'open'"), '到期必须回大厅') +test('订单服务自动发布直接走 publishAdminWorkOrder(内部已含匹配)', async () => { + const source = await readFile(AUTO_PUBLISH, 'utf8') + assert.ok(source.includes('publishAdminWorkOrder('), '自动发布必须复用统一发布入口') + assert.ok(!source.includes('enterConsignmentInternalMatching'), '不得再进入内部匹配等待') +}) + +test('超时重匹配:立即尝试一次,失败回大厅', async () => { + const source = await readFile(TIMEOUT, 'utf8') + assert.ok(source.includes('startConsignmentRematch'), '重匹配策略必须保留') + assert.ok(source.includes('fallbackConsignmentWorkOrderToHall'), '失败必须显式回大厅') + assert.ok(!source.includes('reenterConsignmentInternalMatching'), '不得再回内部匹配池') }) diff --git a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts index 21020ab6..b2219995 100644 --- a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts @@ -81,7 +81,7 @@ test('超时重新匹配与匹配日志:链路必须接线到调度与后台 assert.ok(timeoutSrc.includes('startConsignmentRematch('), '超时服务必须触发重新匹配') assert.ok(timeoutSrc.includes('isRematchTimeoutPolicy(policy)'), '必须识别重新匹配策略') assert.ok( - engineSrc.includes('rematch_excluded_worker_id = $3') && + engineSrc.includes('SET rematch_excluded_worker_id = $1') && engineSrc.includes('rematch_excluded_worker_id = NULL'), '重新匹配必须写入并清空排除字段', ) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts index 98aa958d..e0637a28 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts @@ -2,39 +2,48 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import test from 'node:test' -import { resolveInternalMatchDelayMinutes } from './consignment-match-engine-service.js' +import { normalizeConsignmentProfileConfig } from './consignment-profile-service.js' -test('内部匹配等待时长:0/负数/缺失都归一为 0(直接发大厅)', () => { - assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: 0 }), 0) - assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: -5 }), 0) - assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '0' }), 0) - assert.equal(resolveInternalMatchDelayMinutes({}), 0) - assert.equal(resolveInternalMatchDelayMinutes(null), 0) - assert.equal(resolveInternalMatchDelayMinutes(undefined), 0) - assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: 30 }), 30) - assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '12.6' }), 13) +test('匹配开关:旧 mode=auto 归一为开启,manual/off 归一为关闭', () => { + assert.equal( + normalizeConsignmentProfileConfig({ matching: { mode: 'auto' } }).matching.autoMatchEnabled, + true, + ) + assert.equal( + normalizeConsignmentProfileConfig({ matching: { mode: 'manual' } }).matching.autoMatchEnabled, + false, + ) + assert.equal( + normalizeConsignmentProfileConfig({ matching: { mode: 'off' } }).matching.autoMatchEnabled, + false, + ) + const config = normalizeConsignmentProfileConfig({}) + assert.equal(config.matching.autoMatchEnabled, false, '默认不自动匹配') + assert.ok( + Number.isFinite(Date.parse(config.matching.orderEffectiveFrom)), + '匹配生效时间必须有合法默认值', + ) }) -test('内部匹配入口:0 分钟必须直接返回,禁止进入 internal_matching', async () => { +test('匹配引擎不再包含内部等待与定时任务', async () => { const source = await readFile( new URL('./consignment-match-engine-service.ts', import.meta.url), 'utf8', ) - const start = source.indexOf('export async function enterConsignmentInternalMatching') - const end = source.indexOf('export async function reenterConsignmentInternalMatching', start) - assert.ok(start >= 0 && end > start, '找不到 enterConsignmentInternalMatching') - const body = source.slice(start, end) - + for (const removed of [ + 'resolveInternalMatchDelayMinutes', + 'enterConsignmentInternalMatching', + 'reenterConsignmentInternalMatching', + 'autoMatchConsignmentWorkOrders', + 'startConsignmentMatchScheduler', + ]) { + assert.ok(!source.includes(removed), `${removed} 必须删除`) + } assert.ok( - body.includes( - 'const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)', - ), - '进入内部匹配必须使用统一的等待时长解析', - ) - assert.ok( - body.includes('if (delayMinutes <= 0) return false'), - '0 分钟必须直接返回,禁止进入 internal_matching', + source.includes('profile.config.matching.autoMatchEnabled'), + '自动匹配必须读取新的布尔开关', ) + assert.ok(source.includes('orderEffectiveFrom'), '自动匹配必须读取订单生效时间') }) test('匹配改写报酬:首次固化原值,所有统一释放路径都必须恢复', async () => { diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts index 971fc986..ea08108b 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts @@ -22,11 +22,13 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败 const body = source.slice(start, end) assert.ok( - body.includes('startStatuses: [') && - body.includes('WORK_ORDER_STATUS.INTERNAL_MATCHING') && - body.includes('WORK_ORDER_STATUS.UNASSIGNED') && - body.includes('WORK_ORDER_STATUS.OPEN'), - '手动匹配必须声明允许的起始状态,由 apply 事务内部流转', + body.includes('startStatuses: [WORK_ORDER_STATUS.OPEN]'), + '手动匹配只允许对待抢单 open 生效,由 apply 事务内部流转', + ) + assert.ok( + !body.includes('WORK_ORDER_STATUS.INTERNAL_MATCHING') && + !body.includes('WORK_ORDER_STATUS.UNASSIGNED'), + '手动匹配不得再接受内部匹配中/待分配状态', ) assert.ok( !body.includes("UPDATE work_orders SET status = 'internal_matching'"), @@ -57,7 +59,7 @@ test('apply 事务内部:允许起始状态、整单检查有效份额、拼 const sharingStart = source.indexOf('async function applySharingMatch') const sharingEnd = source.indexOf( - 'export async function autoMatchConsignmentWorkOrders', + 'export async function listConsignmentMatchCandidatesForWorkOrder', sharingStart, ) assert.ok(sharingStart >= 0 && sharingEnd > sharingStart, '找不到 applySharingMatch') @@ -81,38 +83,40 @@ test('apply 事务内部:允许起始状态、整单检查有效份额、拼 ) }) -test('超时任务兜底:无截止时间的 internal_matching 也能发回大厅', async () => { +test('内部匹配等待已取消:入口函数删除,存量由迁移统一转 open', async () => { const source = await readFile(ENGINE, 'utf8') - const start = source.indexOf('export async function expireConsignmentInternalMatching') - const end = source.indexOf( - 'export async function listConsignmentMatchCandidatesForWorkOrder', - start, - ) - assert.ok(start >= 0 && end > start, '找不到 expireConsignmentInternalMatching') - const body = source.slice(start, end) assert.ok( - body.includes('internal_match_deadline_at IS NULL OR internal_match_deadline_at <= $1'), - '超时任务必须兜底 deadline 为空的内部匹配工单', + !source.includes('export async function expireConsignmentInternalMatching'), + '内部匹配超时函数必须删除', + ) + assert.ok( + !source.includes('export async function enterConsignmentInternalMatching'), + '内部匹配入口必须删除', + ) + const sql = await readFile( + new URL('../../db/migrations/160_consignment_auto_match_setting.sql', import.meta.url), + 'utf8', + ) + assert.ok( + sql.includes("WHERE status = 'internal_matching'") && sql.includes("status = 'open'"), + '迁移必须把历史 internal_matching 工单转回待抢单', ) }) -test('重新匹配:0 分钟遵循配置、失败显式回大厅、写 rematch 日志', async () => { +test('重新匹配:立即尝试一次自动匹配,失败显式回大厅', async () => { const [engine, timeout] = await Promise.all([readFile(ENGINE, 'utf8'), readFile(TIMEOUT, 'utf8')]) const start = engine.indexOf('export async function startConsignmentRematch') - const end = engine.indexOf('export function', start + 1) - const body = engine.slice( - start, - end > start ? end : engine.indexOf('/** 自动匹配允许 partial', start), - ) - assert.ok(start >= 0, '找不到 startConsignmentRematch') + const end = engine.indexOf('export function hasPositiveSpecSignal', start) + assert.ok(start >= 0 && end > start, '找不到 startConsignmentRematch') + const body = engine.slice(start, end) assert.ok( - body.includes('resolveInternalMatchDelayMinutes(profile.config.internalMatch)') && - body.includes('if (delayMinutes <= 0)'), - '重新匹配必须与首次进入同口径:0 分钟直接发大厅', + body.includes('profile?.config.matching.autoMatchEnabled'), + '重新匹配必须看自动匹配开关', ) assert.ok( - body.includes('fallbackConsignmentWorkOrderToHall(workOrderId, now)'), - '重新匹配找不到配置/关闭匹配/0 分钟时必须显式回大厅', + body.includes('tryAutoMatchBeforeHall(workOrderId') && + body.includes('startStatuses: [WORK_ORDER_STATUS.OPEN]'), + '重新匹配必须在 open 状态下立即试一次自动匹配', ) assert.ok(body.includes("triggerType: 'rematch'"), '重新匹配必须写 rematch 类型匹配日志') assert.ok( @@ -123,8 +127,10 @@ test('重新匹配:0 分钟遵循配置、失败显式回大厅、写 rematch const issue = await readFile(ISSUE_REPO, 'utf8') assert.ok( - issue.includes("WHEN $1 = 'internal_matching' THEN ($2::timestamptz + INTERVAL '1 minute')"), - '超时处置写 internal_matching 时必须先写兜底截止时间', + issue.includes( + "const targetStatus = isRematch || input.policy === 'reopen' ? 'open' : 'cancelled'", + ), + '超时重匹配结算后必须先回到 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 28c1e589..c20f6009 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 @@ -50,19 +50,6 @@ type MatchCandidate = { profile: ConsignmentProfileSnapshot | null } -/** 自动匹配的「无候选」跳过日志按工单做 10 分钟节流,避免 30 秒调度刷爆日志表。 */ -const matchSkipLogThrottle = new Map() -const MATCH_SKIP_LOG_WINDOW_MS = 10 * 60 * 1000 - -function shouldLogMatchSkip(key: string): boolean { - const now = Date.now() - const last = matchSkipLogThrottle.get(key) || 0 - if (now - last < MATCH_SKIP_LOG_WINDOW_MS) return false - matchSkipLogThrottle.set(key, now) - if (matchSkipLogThrottle.size > 5000) matchSkipLogThrottle.clear() - return true -} - function parseMaterial(value: unknown): Record { const parsed = safeParseJson(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) @@ -76,19 +63,6 @@ function addMinutesIso(minutes: number, now: string): string | null { return new Date(new Date(now).getTime() + normalized * 60 * 1000).toISOString() } -/** - * 内部匹配等待时长(分钟)。0 表示不等待、直接发大厅。 - * - * 注意:0 绝不能进入 internal_matching —— addMinutesIso(0) 返回 null, - * 工单会变成「内部匹配中且无截止时间」,而超时任务只处理 deadline 非空的工单, - * 最终就是永久悬空(既不在大厅也不超时)。 - */ -export function resolveInternalMatchDelayMinutes( - config: { delayMinutes?: unknown } | null | undefined, -): number { - return Math.max(0, Math.round(Number(config?.delayMinutes || 0))) -} - function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number { return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0))) } @@ -388,20 +362,25 @@ export async function resolveProfileForWorkOrder( } /** - * 发布前即时匹配(仅 matching.mode='auto'): - * 工单仍在 unassigned 时直接尝试占库,命中后由 apply 事务改成 in_progress, - * 调用方据此跳过大厅发布;未命中返回 false,由调用方继续走原发布流程。 + * 订单进入待抢单(大厅)前的一次性自动匹配。 * - * 与 30 秒轮询的区别:不等 internal_matching 窗口,发布动作发生时立刻试一次, - * 失败直接进大厅;写入的匹配日志 phase=before_publish,便于区分来源。 + * 首次发布(unassigned)和退回大厅(open)都走这里;只有开启 autoMatchEnabled + * 且订单创建时间不早于 orderEffectiveFrom 才尝试。未命中返回 false,调用方继续发布/保持大厅。 + * 不再有后台定时补配。 */ -export async function tryMatchConsignmentWorkOrderBeforePublish( +export async function tryAutoMatchBeforeHall( workOrderId: number, + options: { startStatuses?: string[] } = {}, ): Promise { const workOrder = await getWorkOrderById(workOrderId) - if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false + if (!workOrder) return false + const startStatuses = options.startStatuses ?? [ + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.OPEN, + ] + if (!startStatuses.includes(workOrder.status)) return false const profile = await resolveProfileForWorkOrder(workOrder) - if (!profile || profile.config.matching.mode !== 'auto') return false + if (!profile || !profile.config.matching.autoMatchEnabled) return false const startedAt = Date.now() const logBase = { @@ -409,7 +388,7 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( workOrderNo: workOrder.work_order_no || '', profileId: profile.profileId, profileVersionId: profile.versionId, - matchingMode: profile.config.matching.mode, + matchingMode: 'auto', exactOnly: profile.config.matching.exactOnly, categoryId: profile.categoryId, triggerType: 'auto' as const, @@ -417,6 +396,27 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( actorId: 'consignment_match', } + const effectiveFromMs = Date.parse(profile.config.matching.orderEffectiveFrom) + const createdAtMs = Date.parse(String(workOrder.created_at || '')) + if ( + Number.isFinite(effectiveFromMs) && + (!Number.isFinite(createdAtMs) || createdAtMs < effectiveFromMs) + ) { + await recordConsignmentMatchLog({ + ...logBase, + result: 'skipped', + reasonCode: 'before_effective_from', + reasonText: '订单创建时间早于匹配生效时间', + durationMs: Date.now() - startedAt, + payload: { + phase: 'before_hall', + orderEffectiveFrom: profile.config.matching.orderEffectiveFrom, + orderCreatedAt: workOrder.created_at || null, + }, + }) + return false + } + try { const inventories = await listActiveConsignmentInventoriesForMatching() const candidates = pickCandidateInventories(workOrder, profile, inventories, { @@ -432,7 +432,7 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( reasonText: '发布前没有符合条件的在库库存', candidateCount: inventories.length, durationMs: Date.now() - startedAt, - payload: { inventoryCount: inventories.length, phase: 'before_publish' }, + payload: { inventoryCount: inventories.length, phase: 'before_hall' }, }) return false } @@ -441,7 +441,7 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( matchType: 'auto', // 发布前工单还没进 internal_matching,必须显式允许从 unassigned 起步, // 否则 applyWholeOrderMatch / applySharingMatch 的默认状态校验会直接拒绝。 - startStatuses: [WORK_ORDER_STATUS.UNASSIGNED], + startStatuses, partial: candidates.some(({ evaluation }) => !evaluation.exact), specConflicts: candidates.flatMap(({ evaluation }) => [ ...evaluation.conflicts, @@ -456,7 +456,7 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( result: sharing.createdCount > 0 ? 'matched' : 'failed', reasonCode: sharing.createdCount > 0 - ? 'auto_sharing_matched_before_publish' + ? 'auto_sharing_matched_before_hall' : sharing.reason === 'insufficient' ? 'sharing_quantity_insufficient' : 'sharing_not_applied', @@ -470,7 +470,7 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( matchedCount: sharing.createdCount, durationMs: Date.now() - startedAt, payload: { - phase: 'before_publish', + phase: 'before_hall', partial: context.partial, specConflicts: context.specConflicts, requiredQuantity: sharing.requiredQuantity, @@ -495,7 +495,7 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`, candidateCount: candidates.length, durationMs: Date.now() - startedAt, - payload: { phase: 'before_publish', requiredQuantity }, + payload: { phase: 'before_hall', requiredQuantity }, }) return false } @@ -504,13 +504,13 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( await recordConsignmentMatchLog({ ...logBase, result: applied ? 'matched' : 'failed', - reasonCode: applied ? 'auto_matched_before_publish' : 'apply_not_applied', + reasonCode: applied ? 'auto_matched_before_hall' : 'apply_not_applied', reasonText: applied ? '' : '发布前整单匹配未落库(库存可能已被占用)', candidateCount: candidates.length, matchedCount: applied ? 1 : 0, durationMs: Date.now() - startedAt, payload: { - phase: 'before_publish', + phase: 'before_hall', partial: context.partial, inventoryId: Number(chosen.inventory.id), }, @@ -523,111 +523,12 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( reasonCode: 'exception', reasonText: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startedAt, - payload: { phase: 'before_publish' }, + payload: { phase: 'before_hall' }, }).catch(() => undefined) return false } } -/** 进入「内部匹配中」:命中分类寄卖配置时由资料提交/自动发布链路调用。 */ -export async function enterConsignmentInternalMatching( - workOrderId: number, - now = nowIso(), -): Promise { - const workOrder = await getWorkOrderById(workOrderId) - if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false - const profile = await resolveProfileForWorkOrder(workOrder) - if (!profile) 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,工单会变成「内部匹配中且无截止时间」, - // 超时任务只处理 deadline 非空的工单,结果就是永久悬空。 - if (delayMinutes <= 0) return false - const deadline = addMinutesIso(delayMinutes, now) - const result = await query( - ` - UPDATE work_orders - SET status = 'internal_matching', - consignment_profile_id = $1, - internal_match_deadline_at = $2, - hall_visible_at = $2, - updated_at = $3 - WHERE id = $4 AND status = $5 - RETURNING * - `, - [profile.profileId, deadline, now, workOrderId, WORK_ORDER_STATUS.UNASSIGNED], - ) - if (!result.rows[0]) return false - await query( - ` - INSERT INTO work_order_events ( - work_order_id, actor_type, actor_id, event_type, from_status, to_status, - payload_json, created_at - ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_entered', $2, 'internal_matching', $3::jsonb, $4) - `, - [ - workOrderId, - WORK_ORDER_STATUS.UNASSIGNED, - JSON.stringify({ profileId: profile.profileId, delayMinutes, deadline }), - now, - ], - ) - return true -} - -export async function reenterConsignmentInternalMatching( - workOrderId: number, - now = nowIso(), -): Promise { - const workOrder = await getWorkOrderById(workOrderId) - if ( - !workOrder || - ![WORK_ORDER_STATUS.OPEN, WORK_ORDER_STATUS.UNASSIGNED].includes(workOrder.status as never) - ) { - return false - } - const profile = await resolveProfileForWorkOrder(workOrder) - if (!profile) return false - // 关闭匹配:退回大厅后不再重新排队等内部库存。 - if (profile.config.matching.mode === 'off') return false - const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch) - if (delayMinutes <= 0) return false - const deadline = addMinutesIso(delayMinutes, now) - const result = await query( - ` - UPDATE work_orders - SET status = 'internal_matching', - consignment_profile_id = $1, - internal_match_deadline_at = $2, - hall_visible_at = $2, - internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, - updated_at = $3 - WHERE id = $4 AND status IN ('open', 'unassigned') - RETURNING * - `, - [profile.profileId, deadline, now, workOrderId], - ) - if (!result.rows[0]) return false - await query( - ` - INSERT INTO work_order_events ( - work_order_id, actor_type, actor_id, event_type, from_status, to_status, - payload_json, created_at - ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_reentered', $2, 'internal_matching', $3::jsonb, $4) - `, - [ - workOrderId, - workOrder.status, - JSON.stringify({ profileId: profile.profileId, delayMinutes, deadline }), - now, - ], - ) - return true -} - /** * 重新匹配失败时的兜底:把工单退回大厅。 * @@ -658,8 +559,8 @@ export async function fallbackConsignmentWorkOrderToHall( } /** - * 代练超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。 - * 分类关闭匹配、等待时长为 0 或找不到配置时,退回大厅,避免工单卡在无人处理的状态。 + * 履约超时「重新匹配」:先记录排除集合,再立即尝试一次自动匹配。 + * 自动匹配关闭或未命中返回 false,由调用方把工单留在/退回大厅(超时结算后已是 open)。 */ export async function startConsignmentRematch( workOrderId: number, @@ -675,10 +576,6 @@ export async function startConsignmentRematch( const workOrder = await getWorkOrderById(workOrderId) if (!workOrder) return false const profile = await resolveProfileForWorkOrder(workOrder) - if (!profile || profile.config.matching.mode === 'off') { - await fallbackConsignmentWorkOrderToHall(workOrderId, now) - return false - } const excludedInventoryIds = [ ...new Set( [...(input.excludeInventoryIds || []), Number(input.excludeInventoryId || 0)].filter( @@ -693,57 +590,30 @@ export async function startConsignmentRematch( ), ), ] - // 与首次进入内部匹配同口径:0 表示不等待、直接发大厅。 - const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch) - if (delayMinutes <= 0) { - await fallbackConsignmentWorkOrderToHall(workOrderId, now) - return false - } - const deadline = addMinutesIso(delayMinutes, now) - const result = await query( - ` - UPDATE work_orders - SET status = 'internal_matching', - assigned_worker_id = NULL, - assigned_at = NULL, - deadline_at = NULL, - consignment_profile_id = $1, - internal_match_deadline_at = $2, - hall_visible_at = $2, - internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, - rematch_excluded_worker_id = $3, - rematch_excluded_inventory_id = $4, - updated_at = $5 - WHERE id = $6 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress') - RETURNING * - `, - [ - profile.profileId, - deadline, - excludedWorkerIds[0] ?? null, - excludedInventoryIds[0] ?? null, - now, - workOrderId, - ], - ) - if (!result.rows[0]) return false + // 单列字段只够兼容旧逻辑;完整排除集合写进 material,拼单的所有原卖家/原库存都会生效。 await query( ` UPDATE work_orders - SET material_json = jsonb_set( + SET rematch_excluded_worker_id = $1, + rematch_excluded_inventory_id = $2, + material_json = jsonb_set( COALESCE(material_json, '{}'::jsonb), '{consignment}', - COALESCE(material_json -> 'consignment', '{}'::jsonb) || $1::jsonb, + COALESCE(material_json -> 'consignment', '{}'::jsonb) || $3::jsonb, true - ) - WHERE id = $2 + ), + updated_at = $4 + WHERE id = $5 `, [ + excludedWorkerIds[0] ?? null, + excludedInventoryIds[0] ?? null, JSON.stringify({ rematchExcludedInventoryIds: excludedInventoryIds, rematchExcludedWorkerIds: excludedWorkerIds, }), + now, workOrderId, ], ) @@ -752,15 +622,13 @@ export async function startConsignmentRematch( INSERT INTO work_order_events ( work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at - ) VALUES ($1, 'system', 'consignment_match', 'consignment_rematch_started', $2, 'internal_matching', $3::jsonb, $4) + ) VALUES ($1, 'system', 'consignment_match', 'consignment_rematch_started', $2, $2, $3::jsonb, $4) `, [ workOrderId, workOrder.status, JSON.stringify({ - profileId: profile.profileId, - delayMinutes, - deadline, + profileId: profile?.profileId ?? null, excludedWorkerId: excludedWorkerIds[0] ?? null, excludedInventoryId: excludedInventoryIds[0] ?? null, excludedWorkerIds, @@ -772,25 +640,35 @@ export async function startConsignmentRematch( await recordConsignmentMatchLog({ workOrderId: Number(workOrderId), workOrderNo: workOrder.work_order_no || '', - profileId: profile.profileId, - profileVersionId: profile.versionId, - matchingMode: profile.config.matching.mode, - exactOnly: profile.config.matching.exactOnly, - categoryId: profile.categoryId, + profileId: profile?.profileId ?? null, + profileVersionId: profile?.versionId ?? null, + matchingMode: 'auto', + exactOnly: profile?.config.matching.exactOnly ?? null, + categoryId: profile?.categoryId ?? null, triggerType: 'rematch', result: 'matched', reasonCode: 'rematch_started', - reasonText: '超时重新进入内部匹配', + reasonText: '超时后立即重新匹配一次', payload: { - delayMinutes, - deadline, + autoMatchEnabled: Boolean(profile?.config.matching.autoMatchEnabled), excludedWorkerId: excludedWorkerIds[0] ?? null, excludedInventoryId: excludedInventoryIds[0] ?? null, excludedWorkerIds, excludedInventoryIds, }, }) - return true + + if (!profile?.config.matching.autoMatchEnabled) { + await fallbackConsignmentWorkOrderToHall(workOrderId, now) + return false + } + const matched = await tryAutoMatchBeforeHall(workOrderId, { + startStatuses: [WORK_ORDER_STATUS.OPEN], + }) + if (!matched) { + await fallbackConsignmentWorkOrderToHall(workOrderId, now) + } + return matched } /** @@ -1400,249 +1278,18 @@ async function applySharingMatch( return result } -/** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */ -export async function autoMatchConsignmentWorkOrders(options: { limit?: number } = {}) { - const limit = Math.min(Math.max(Number(options.limit || 50), 1), 200) - const orders = await query( - ` - SELECT * FROM work_orders - WHERE status = 'internal_matching' - AND (internal_match_deadline_at IS NULL OR internal_match_deadline_at > $2) - ORDER BY reward_amount ASC, created_at ASC - LIMIT $1 - `, - [limit, nowIso()], - ) - // 一轮调度只查一次在库库存;成功匹配后再刷新,避免 50 单 × 全量库存扫描。 - let inventories = await listActiveConsignmentInventoriesForMatching() - const profileCache = new Map>() - const resolveProfileCached = (workOrder: WorkOrderRow) => { - const key = workOrder.consignment_profile_id - ? `profile:${Number(workOrder.consignment_profile_id)}` - : `category:${Number(workOrder.category_id || 0)}` - let pending = profileCache.get(key) - if (!pending) { - pending = resolveProfileForWorkOrder(workOrder) - profileCache.set(key, pending) - } - return pending - } - let matched = 0 - for (const workOrder of orders.rows) { - const startedAt = Date.now() - const baseLog = { - workOrderId: Number(workOrder.id), - workOrderNo: workOrder.work_order_no || '', - triggerType: 'auto' as const, - actorType: 'system', - actorId: 'consignment_match', - } - let profileRef: ConsignmentProfileSnapshot | null = null - try { - const profile = await resolveProfileCached(workOrder) - profileRef = profile - if (!profile) continue - const logBase = { - ...baseLog, - profileId: profile.profileId, - profileVersionId: profile.versionId, - matchingMode: profile.config.matching.mode, - exactOnly: profile.config.matching.exactOnly, - categoryId: profile.categoryId, - } - if (profile.config.matching.mode !== 'auto') continue - const matching = profile.config.matching - const candidates = pickCandidateInventories(workOrder, profile, inventories, { - allowPartial: matching.exactOnly === false, - preferLowReward: matching.preferLowReward !== false, - orderSpecOverride: await resolveOrderSpecOverride(workOrder, profile), - }) - if (candidates.length === 0) { - if (shouldLogMatchSkip(`no_candidate:${workOrder.id}`)) { - await recordConsignmentMatchLog({ - ...logBase, - result: 'skipped', - reasonCode: 'no_candidate', - reasonText: '没有符合条件的在库库存', - candidateCount: inventories.length, - durationMs: Date.now() - startedAt, - payload: { inventoryCount: inventories.length }, - }) - } - continue - } - - if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { - const context: ConsignmentMatchContext = { - matchType: 'auto', - partial: candidates.some(({ evaluation }) => !evaluation.exact), - specConflicts: candidates.flatMap(({ evaluation }) => [ - ...evaluation.conflicts, - ...evaluation.unknowns, - ]), - } - const sharing = await applySharingMatch({ workOrder, profile }, candidates, context) - if (sharing.createdCount > 0) { - matched += 1 - await recordConsignmentMatchLog({ - ...logBase, - result: 'matched', - reasonCode: 'auto_sharing_matched', - candidateCount: candidates.length, - matchedCount: sharing.createdCount, - durationMs: Date.now() - startedAt, - payload: { - partial: context.partial, - specConflicts: context.specConflicts, - requiredQuantity: sharing.requiredQuantity, - availableQuantity: sharing.availableQuantity, - inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)), - }, - }) - // 本轮已有库存被占用,刷新快照,避免后续工单继续拿旧剩余数量做候选。 - inventories = await listActiveConsignmentInventoriesForMatching() - } else { - await recordConsignmentMatchLog({ - ...logBase, - result: 'failed', - reasonCode: - sharing.reason === 'insufficient' - ? 'sharing_quantity_insufficient' - : 'sharing_not_applied', - reasonText: - sharing.reason === 'insufficient' - ? `拼单必须一次拼满:还差 ${sharing.requiredQuantity} 份,可用 ${sharing.availableQuantity} 份` - : '拼单匹配未生效(库存或工单状态已变化)', - candidateCount: candidates.length, - durationMs: Date.now() - startedAt, - payload: { - requiredQuantity: sharing.requiredQuantity, - availableQuantity: sharing.availableQuantity, - }, - }) - } - } else { - // 整单必须由一条库存一次性满足订单总数量,不能只占 1 件。 - 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) { - if (shouldLogMatchSkip(`quantity_insufficient:${workOrder.id}`)) { - await recordConsignmentMatchLog({ - ...logBase, - result: 'skipped', - reasonCode: 'quantity_insufficient', - reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`, - candidateCount: candidates.length, - durationMs: Date.now() - startedAt, - payload: { requiredQuantity }, - }) - } - continue - } - const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, { - matchType: 'auto', - partial: !chosen.evaluation.exact, - specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns], - }) - if (applied) { - matched += 1 - await recordConsignmentMatchLog({ - ...logBase, - result: 'matched', - reasonCode: 'auto_matched', - candidateCount: candidates.length, - matchedCount: 1, - durationMs: Date.now() - startedAt, - payload: { - partial: !chosen.evaluation.exact, - specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns], - inventoryId: Number(chosen.inventory.id), - }, - }) - inventories = await listActiveConsignmentInventoriesForMatching() - } else { - await recordConsignmentMatchLog({ - ...logBase, - result: 'failed', - reasonCode: 'apply_failed', - reasonText: '匹配事务未生效(工单状态或库存数量已变化)', - candidateCount: candidates.length, - durationMs: Date.now() - startedAt, - payload: { inventoryId: Number(chosen.inventory.id) }, - }) - } - } - } catch (error) { - // 单笔失败不影响同批其它工单;下一次调度会重试。 - await recordConsignmentMatchLog({ - ...baseLog, - profileId: profileRef?.profileId ?? null, - categoryId: profileRef?.categoryId ?? null, - result: 'failed', - reasonCode: 'exception', - reasonText: error instanceof Error ? error.message : String(error), - durationMs: Date.now() - startedAt, - }) - } - } - return { scanned: orders.rows.length, matched } -} - -/** - * 内部匹配超时:工单统一发回大厅。 - * 「超时后回大厅」是手动匹配的固定行为,不再支持按配置重新匹配。 - * 拼单已占库存的份额不受影响,剩余份数继续在大厅抢单。 - */ -export async function expireConsignmentInternalMatching(options: { now?: string } = {}) { - const now = options.now || nowIso() - const expired = await query( - ` - SELECT * FROM work_orders - WHERE status = 'internal_matching' - AND (internal_match_deadline_at IS NULL OR internal_match_deadline_at <= $1) - `, - [now], - ) - let expiredCount = 0 - for (const workOrder of expired.rows) { - 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 ( - work_order_id, actor_type, actor_id, event_type, from_status, to_status, - 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], - ) - } - return { expired: expiredCount } -} - /** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: number) { const workOrder = await getWorkOrderById(workOrderId) if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } + if (workOrder.status !== WORK_ORDER_STATUS.OPEN) { + throw createHttpError('只有待抢单工单可以匹配寄卖库存', { + statusCode: 409, + errorCode: 'work_order_not_matchable', + }) + } const profile = await resolveProfileForWorkOrder(workOrder) const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile) const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( @@ -1701,7 +1348,8 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu workOrderId: Number(workOrder.id), profileId: profile?.profileId ?? null, profileVersionId: profile?.versionId ?? null, - matchingMode: profile?.config.matching.mode ?? null, + matchingMode: profile ? (profile.config.matching.autoMatchEnabled ? 'auto' : 'off') : null, + autoMatchEnabled: profile?.config.matching.autoMatchEnabled ?? null, exactOnly: profile?.config.matching.exactOnly ?? null, categoryId: profile?.categoryId ?? (workOrder.category_id ? Number(workOrder.category_id) : null), @@ -1736,7 +1384,8 @@ export async function matchConsignmentWorkOrderManually(input: { workOrderNo: workOrder?.work_order_no || '', profileId: profile?.profileId ?? null, profileVersionId: profile?.versionId ?? null, - matchingMode: profile?.config.matching.mode ?? null, + matchingMode: profile ? (profile.config.matching.autoMatchEnabled ? 'auto' : 'off') : null, + autoMatchEnabled: profile?.config.matching.autoMatchEnabled ?? null, exactOnly: profile?.config.matching.exactOnly ?? null, categoryId: workOrder?.category_id ? Number(workOrder.category_id) : null, triggerType: 'manual' as const, @@ -1786,14 +1435,8 @@ async function runManualConsignmentMatch( if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } - if ( - ![ - WORK_ORDER_STATUS.INTERNAL_MATCHING, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.OPEN, - ].includes(workOrder.status as never) - ) { - throw createHttpError('当前工单状态不允许内部库存匹配', { + if (workOrder.status !== WORK_ORDER_STATUS.OPEN) { + throw createHttpError('只有待抢单工单可以匹配寄卖库存', { statusCode: 409, errorCode: 'work_order_not_matchable', }) @@ -1857,13 +1500,8 @@ async function runManualConsignmentMatch( ...evaluation.conflicts, ...evaluation.unknowns, ]), - // 手动匹配允许从大厅/待分配直接落库;状态流转在 apply 事务内部完成, - // 校验失败(数量不足、并发占库)时不会把工单改成无截止时间的 internal_matching。 - startStatuses: [ - WORK_ORDER_STATUS.INTERNAL_MATCHING, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.OPEN, - ], + // 手动匹配只对待抢单(大厅)工单生效;状态流转在 apply 事务内部完成。 + startStatuses: [WORK_ORDER_STATUS.OPEN], } if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { @@ -1914,35 +1552,3 @@ export async function releaseConsignmentForWorkOrder(workOrderId: number): Promi releaseConsignmentAllocationsForWorkOrderWithClient(client, workOrderId, now), ) } - -let matchTimer: NodeJS.Timeout | null = null -let matchTickRunning = false - -/** 轻量调度:每 30 秒扫描一次内部匹配与超时;unref 不阻塞进程退出。 */ -export function startConsignmentMatchScheduler(intervalMs = 30_000) { - if (matchTimer) return - matchTimer = setInterval( - () => { - // 上一轮还没跑完(库存多/短信慢)时跳过本轮,避免叠加扫描。 - if (matchTickRunning) return - matchTickRunning = true - void (async () => { - try { - await autoMatchConsignmentWorkOrders().catch(() => undefined) - await expireConsignmentInternalMatching().catch(() => undefined) - } finally { - matchTickRunning = false - } - })() - }, - Math.max(5_000, intervalMs), - ) - matchTimer.unref?.() -} - -export function stopConsignmentMatchScheduler() { - if (matchTimer) { - clearInterval(matchTimer) - matchTimer = null - } -} diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index 56272b56..b4ed3aa7 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -9,6 +9,7 @@ * 未设置 CONSIGNMENT_MATCHING_TEST_DATABASE_URL 时整个文件跳过,普通单测不依赖数据库。 */ import test from 'node:test' +import { readFile } from 'node:fs/promises' import { closeDb, query } from '../../db/client.js' import { @@ -16,15 +17,12 @@ import { rejectAdminConsignmentSubmission, } from './consignment-admin-service.js' import { - autoMatchConsignmentWorkOrders, - enterConsignmentInternalMatching, - expireConsignmentInternalMatching, fallbackConsignmentWorkOrderToHall, listConsignmentMatchCandidatesForWorkOrder, matchConsignmentWorkOrderManually, releaseConsignmentForWorkOrder, startConsignmentRematch, - tryMatchConsignmentWorkOrderBeforePublish, + tryAutoMatchBeforeHall, } from './consignment-match-engine-service.js' import { fulfillWorkOrderWithConsignment } from './consignment-fulfillment-service.js' import { @@ -215,15 +213,23 @@ const profileConfig = { serviceFee: { enabled: true, mode: 'percent', percent: 5, amountFen: 0 }, inventoryLimit: { enabled: false, maxActiveQuantity: 0 }, matching: { - mode: 'auto', + autoMatchEnabled: true, exactOnly: true, preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', }, - internalMatch: { delayMinutes: 30 }, timeout: { minutes: 60, policy: 'reopen' }, priceDrop: { enabled: true, hours: 72, mode: 'percent', value: 10 }, } +/** 新口径下自动匹配按单次触发;测试里用指定工单模拟「进入大厅前」那一次。 */ +async function autoMatchForTest(workOrderId: number) { + const matched = await tryAutoMatchBeforeHall(Number(workOrderId), { + startStatuses: ['internal_matching', 'open', 'unassigned'], + }) + return { matched: matched ? 1 : 0, scanned: 1 } +} + async function main() { // ---------- 造数 ---------- const categoryRes = await query<{ id: number }>( @@ -343,7 +349,7 @@ async function main() { input.no, input.productName || '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南', input.categoryId ?? categoryId, - input.status || 'unassigned', + input.status || 'open', 10000, JSON.stringify(material), Boolean(input.sharing), @@ -983,7 +989,7 @@ async function main() { internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) await check('自动匹配:internal_matching 工单命中精确库存', async () => { - const result = await autoMatchConsignmentWorkOrders({ limit: 50 }) + const result = await autoMatchForTest(Number(autoWo.id)) assert(result.scanned >= 1, `scanned=${result.scanned}`) const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ Number(autoWo.id), @@ -1025,7 +1031,7 @@ async function main() { }) const shareWo = await createWorkOrder({ no: `CT-WO-SHARE-${suffix}`, - status: 'internal_matching', + status: 'open', sharing: { total: 2, unitReward: 7000 }, internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) @@ -1108,7 +1114,7 @@ async function main() { }) const multiShareWo = await createWorkOrder({ no: `CT-WO-MULTI-${suffix}`, - status: 'internal_matching', + status: 'open', sharing: { total: 3, unitReward: 7000 }, internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) @@ -1157,11 +1163,11 @@ async function main() { }) const splitWo1 = await createWorkOrder({ no: `CT-WO-SPLIT1-${suffix}`, - status: 'unassigned', + status: 'open', }) const splitWo2 = await createWorkOrder({ no: `CT-WO-SPLIT2-${suffix}`, - status: 'unassigned', + status: 'open', }) await check('多数量库存:拆给两张订单后,卖家列表出现两条占用行 + 一条剩余行', async () => { for (const workOrder of [splitWo1, splitWo2]) { @@ -1201,7 +1207,7 @@ async function main() { }) const giftSplitWo = await createWorkOrder({ no: `CT-WO-GIFTSINGLE-${suffix}`, - status: 'internal_matching', + status: 'open', acceptanceMode: 'friend_gift', internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) @@ -1255,7 +1261,7 @@ async function main() { const giftB = await publishGiftInventory(seller2Id, 'b') const giftShareWo = await createWorkOrder({ no: `CT-WO-GIFTSHARE-${suffix}`, - status: 'internal_matching', + status: 'open', sharing: { total: 2, unitReward: 7000 }, acceptanceMode: 'friend_gift', internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), @@ -1355,28 +1361,14 @@ async function main() { assert(!rowB?.canSubmitEvidence, 'A 提交不能把 B 的按钮也点亮') }) - // ---------- 8. 超时回大厅 ---------- - const expireWo = await createWorkOrder({ - no: `CT-WO-EXPIRE-${suffix}`, - status: 'internal_matching', - internalDeadline: new Date(Date.now() - 60 * 1000).toISOString(), - }) - await check('内部匹配超时:工单回到 open 并写超时事件', async () => { - const result = await expireConsignmentInternalMatching({ - now: new Date(Date.now() + 60 * 1000).toISOString(), - }) - assert(result.expired >= 1, `expired=${result.expired}`) - const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ - Number(expireWo.id), - ]) - assert(wo.rows[0]!.status === 'open', `status=${wo.rows[0]!.status}`) - assert(wo.rows[0]!.internal_match_deadline_at === null, 'deadline 必须清空') - assert(wo.rows[0]!.hall_visible_at === null, 'hall_visible_at 必须清空') - const event = await query>( - `SELECT * FROM work_order_events WHERE work_order_id = $1 AND event_type = 'consignment_internal_matching_expired'`, - [Number(expireWo.id)], + // ---------- 8. 内部匹配等待已取消 ---------- + await check('内部匹配等待已取消:迁移会把历史 internal_matching 转 open', async () => { + const sql = await readFile( + new URL('../../db/migrations/160_consignment_auto_match_setting.sql', import.meta.url), + 'utf8', ) - assert(event.rows.length === 1, '缺少超时事件') + assert(sql.includes("WHERE status = 'internal_matching'"), '迁移必须处理历史内部匹配工单') + assert(sql.includes("status = 'open'"), '历史内部匹配工单必须转回待抢单') }) // ---------- 8.5 草稿隔离(P1-1)---------- @@ -1430,7 +1422,7 @@ async function main() { status: 'internal_matching', internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) - const result = await autoMatchConsignmentWorkOrders({ limit: 50 }) + const result = await autoMatchForTest(Number(partialOnlyWo.id)) assert(result.matched >= 1, `exactOnly=false 仍没有自动匹配,matched=${result.matched}`) const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ Number(partialOnlyWo.id), @@ -1589,7 +1581,7 @@ async function main() { internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), productRuleId: rulesRes.rows[0]!.id, }) - const autoResult = await autoMatchConsignmentWorkOrders({ limit: 50 }) + const autoResult = await autoMatchForTest(Number(tplWo.id)) assert(autoResult.matched >= 1, `模板驱动自动匹配失败 matched=${autoResult.matched}`) const tplWoRow = await query>('SELECT * FROM work_orders WHERE id = $1', [ Number(tplWo.id), @@ -1621,8 +1613,12 @@ async function main() { const offCategoryId = await createCategory('off') const offConfig = { ...profileConfig, - matching: { mode: 'off', exactOnly: true, preferLowReward: true }, - internalMatch: { delayMinutes: 30 }, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, } const offProfile = await createProfileForCategory({ categoryId: offCategoryId, @@ -1641,17 +1637,15 @@ async function main() { }) const offWo = await createWorkOrder({ no: `CT-WO-OFF-${suffix}`, categoryId: offCategoryId }) - const beforePublish = await tryMatchConsignmentWorkOrderBeforePublish(Number(offWo.id)) + const beforePublish = await tryAutoMatchBeforeHall(Number(offWo.id)) assert(!beforePublish, 'off 模式不得在发布前匹配') - const entered = await enterConsignmentInternalMatching(Number(offWo.id)) - assert(!entered, 'off 模式不得进入内部匹配等待') - // 关闭的是自动匹配,人工仍可指定库存:候选接口要如实返回 off 与候选行。 + // 关闭的是自动匹配,人工仍可指定库存:先回到待抢单,再看候选接口。 + await fallbackConsignmentWorkOrderToHall(Number(offWo.id)) const candidates = await listConsignmentMatchCandidatesForWorkOrder(Number(offWo.id)) assert(candidates.matchingMode === 'off', `候选接口 matchingMode=${candidates.matchingMode}`) assert(candidates.options.length === 1, `off 手动候选=${candidates.options.length}`) - await fallbackConsignmentWorkOrderToHall(Number(offWo.id)) const woRow = await query>('SELECT status FROM work_orders WHERE id = $1', [ Number(offWo.id), ]) @@ -1669,8 +1663,12 @@ async function main() { const switchCategoryId = await createCategory('switch') const manualConfig = { ...profileConfig, - matching: { mode: 'manual', exactOnly: true, preferLowReward: true }, - internalMatch: { delayMinutes: 30 }, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, } const switchProfile = await createProfileForCategory({ categoryId: switchCategoryId, @@ -1694,8 +1692,8 @@ async function main() { internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) - // manual 模式不参与自动匹配,工单必须留在内部等待。 - await autoMatchConsignmentWorkOrders({ limit: 50 }) + // manual 配置不参与自动匹配,工单必须留在内部等待。 + await autoMatchForTest(Number(manualWo.id)) const manualRow = await query>( 'SELECT status FROM work_orders WHERE id = $1', [Number(manualWo.id)], @@ -1708,7 +1706,12 @@ async function main() { // 切到 off:重新匹配必须显式回大厅,不能继续占等待窗口。 await updatePublishedConfig(switchProfile.profileId, switchProfile.versionId, { ...manualConfig, - matching: { mode: 'off', exactOnly: true, preferLowReward: true }, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, }) const rematched = await startConsignmentRematch(Number(manualWo.id), {}) assert(!rematched, 'off 模式重新匹配应返回 false') @@ -1721,7 +1724,12 @@ async function main() { // 切到 auto:同一份库存应能被新的待匹配工单自动吃掉。 const autoConfig = { ...profileConfig, - matching: { mode: 'auto', exactOnly: true, preferLowReward: true }, + matching: { + autoMatchEnabled: true, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, } await updatePublishedConfig(switchProfile.profileId, switchProfile.versionId, autoConfig) const autoWo = await createWorkOrder({ @@ -1730,7 +1738,7 @@ async function main() { categoryId: switchCategoryId, internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) - await autoMatchConsignmentWorkOrders({ limit: 50 }) + await autoMatchForTest(Number(autoWo.id)) const autoRow = await query>( 'SELECT status FROM work_orders WHERE id = $1', [Number(autoWo.id)], @@ -1754,7 +1762,12 @@ async function main() { const lockCategoryId = await createCategory('lock') const lockConfig = { ...profileConfig, - matching: { mode: 'manual', exactOnly: true, preferLowReward: true }, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, } const lockProfile = await createProfileForCategory({ categoryId: lockCategoryId, @@ -1813,7 +1826,12 @@ async function main() { const splitCategoryId = await createCategory('split') const splitConfig = { ...profileConfig, - matching: { mode: 'manual', exactOnly: true, preferLowReward: true }, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, } const splitProfile = await createProfileForCategory({ categoryId: splitCategoryId, @@ -1833,14 +1851,14 @@ async function main() { }) const splitWoA = await createWorkOrder({ no: `CT-WO-SPLIT-A-${suffix}`, - status: 'internal_matching', + status: 'open', categoryId: splitCategoryId, sharing: { total: 2, unitReward: 7000 }, internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) const splitWoB = await createWorkOrder({ no: `CT-WO-SPLIT-B-${suffix}`, - status: 'internal_matching', + status: 'open', categoryId: splitCategoryId, internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), }) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts index 1a1da37a..cb72d7bf 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -526,27 +526,26 @@ test('看板数量口径:拼单按整单汇总余量判断能否一次拼满', assert.equal(short.quantityShortCount, 1) }) -test('看板状态口径:内部匹配中的工单必须出现在看板里', () => { +test('看板状态口径:只有待抢单 open 工单进入看板', () => { const result = buildPendingConsignmentMatchPairs({ orders: [ buildOrder({ id: 521, status: 'internal_matching' }), buildOrder({ id: 522, work_order_no: 'WK522', status: 'unassigned' }), - buildOrder({ id: 523, work_order_no: 'WK523', status: 'in_progress' }), + buildOrder({ id: 523, work_order_no: 'WK523', status: 'open' }), + buildOrder({ id: 524, work_order_no: 'WK524', status: 'in_progress' }), ], - inventories: [buildInventory({ id: 524 })], + inventories: [buildInventory({ id: 525 })], profileByOrderId: new Map([ [521, null], [522, null], [523, null], + [524, null], ]), }) assert.deepEqual( result.pairs.map((pair) => [pair.workOrderId, pair.workOrderStatus]), - [ - [522, 'unassigned'], - [521, 'internal_matching'], - ], + [[523, 'open']], ) }) @@ -604,7 +603,12 @@ test('看板自动匹配口径:非精确必须同时满足配置允许与正 config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: [], - matching: { mode: 'auto', exactOnly: false, preferLowReward: true }, + matching: { + autoMatchEnabled: true, + exactOnly: false, + preferLowReward: true, + orderEffectiveFrom: '2026-10-01T00:00:00.000Z', + }, }, }) @@ -632,7 +636,12 @@ test('看板自动匹配口径:订单写清系统且有高配库存时允许 config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: [], - matching: { mode: 'auto', exactOnly: false, preferLowReward: true }, + matching: { + autoMatchEnabled: true, + exactOnly: false, + preferLowReward: true, + orderEffectiveFrom: '2026-10-01T00:00:00.000Z', + }, }, }) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index 76460633..ca4de31f 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -62,15 +62,9 @@ const DEFAULT_RESULT_LIMIT = 300 const MAX_RESULT_LIMIT = 1000 /** - * 看板纳入的工单状态: - * - `internal_matching`:手动匹配模式下工单会在这里等运营挑库存,看板必须能看到; - * - `open` / `unassigned`:已发大厅但还没被接走,仍可由寄卖现货内部消化。 + * 看板纳入的工单状态:手动匹配只对待抢单(大厅)生效,因此只看 open。 */ -const PENDING_BOARD_ORDER_STATUSES = new Set([ - WORK_ORDER_STATUS.INTERNAL_MATCHING, - WORK_ORDER_STATUS.OPEN, - WORK_ORDER_STATUS.UNASSIGNED, -]) +const PENDING_BOARD_ORDER_STATUSES = new Set([WORK_ORDER_STATUS.OPEN]) function buildPairHaystack(pair: PendingPistolMatchPair): string { return [ @@ -217,9 +211,13 @@ export type PendingConsignmentMatchPair = PendingPistolMatchPair & { quantitySufficient: boolean /** 自动匹配是否真会选中它:精确,或配置允许非精确且存在正向规格信号 */ autoMatchable: boolean + /** 该订单所属配置是否开启自动匹配 */ + autoMatchEnabled: boolean + /** 自动匹配生效时间;订单创建时间早于它时 autoMatchable 恒为 false */ + orderEffectiveFrom: string | null /** 是否为拼单工单 */ sharingEnabled: boolean - /** 工单当前状态(看板同时展示 internal_matching 与 open/unassigned) */ + /** 工单当前状态(看板只纳入待抢单 open) */ workOrderStatus: string } @@ -379,7 +377,7 @@ export function buildPendingConsignmentMatchPairs(input: { } of preparedOrders) { const orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null const allowPartial = profile - ? profile.config.matching.mode === 'auto' && profile.config.matching.exactOnly === false + ? profile.config.matching.autoMatchEnabled && profile.config.matching.exactOnly === false : false type EvaluatedInventory = { @@ -469,7 +467,16 @@ export function buildPendingConsignmentMatchPairs(input: { : friendGift ? availableQuantity === requiredQuantity : availableQuantity >= requiredQuantity + const autoMatchEnabled = Boolean(profile?.config.matching.autoMatchEnabled) + const orderCreatedAtMs = Date.parse(String(order.created_at || '')) + const effectiveFromMs = profile ? Date.parse(profile.config.matching.orderEffectiveFrom) : NaN + const orderAfterEffectiveFrom = + !profile || + !Number.isFinite(effectiveFromMs) || + (Number.isFinite(orderCreatedAtMs) && orderCreatedAtMs >= effectiveFromMs) const autoMatchable = + autoMatchEnabled && + orderAfterEffectiveFrom && item.matchType !== 'conflict' && quantitySufficient && (item.matchType === 'exact' || (allowPartial && item.positiveSpecSignal)) @@ -505,6 +512,8 @@ export function buildPendingConsignmentMatchPairs(input: { availableQuantity, quantitySufficient, autoMatchable, + autoMatchEnabled, + orderEffectiveFrom: profile?.config.matching.orderEffectiveFrom ?? null, sharingEnabled: sharingOrder, workOrderStatus: String(order.status || ''), } @@ -564,11 +573,7 @@ export async function listPendingConsignmentMatches( } = {}, ): Promise { const { items, total } = await listWorkOrders({ - statuses: [ - WORK_ORDER_STATUS.INTERNAL_MATCHING, - WORK_ORDER_STATUS.OPEN, - WORK_ORDER_STATUS.UNASSIGNED, - ], + statuses: [WORK_ORDER_STATUS.OPEN], page: 1, pageSize: OPEN_ORDER_FETCH_LIMIT, includeTotal: true, diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index 1e64db2a..a3657276 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -14,9 +14,9 @@ test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费 const config = normalizeConsignmentProfileConfig({}) assert.equal(config.deposit.enabled, false) assert.equal(config.serviceFee.enabled, false) - assert.equal(config.matching.mode, 'off') + assert.equal(config.matching.autoMatchEnabled, false) assert.equal(config.timeout.minutes, 30) - assert.equal(config.internalMatch.delayMinutes, 30) + assert.ok(Number.isFinite(Date.parse(config.matching.orderEffectiveFrom))) assert.equal(config.priceDrop.enabled, true) assert.equal(config.priceDrop.hours, 24) assert.equal(config.fields.quantity, true) @@ -35,7 +35,7 @@ test('寄卖配置:非法枚举与越界数字回落到安全值', () => { assert.equal(config.deposit.unitAmountFen, 0) assert.equal(config.serviceFee.mode, 'percent') assert.equal(config.serviceFee.percent, 100) - assert.equal(config.matching.mode, 'off') + assert.equal(config.matching.autoMatchEnabled, false) assert.equal(config.timeout.policy, 'reopen') assert.equal(config.timeout.minutes, 0) assert.equal(config.priceDrop.hours, 0.1) @@ -170,12 +170,12 @@ test('合并迁移 159 必须包含寄卖配置、库存数量/售价与分配 test('寄卖配置:打手端/工单/仓库只认已发布版本快照,草稿改了也不生效', () => { const published = resolvePublishedProfileConfig({ // 草稿:后台刚保存但还没发布 - config_json: { tabTitle: '草稿标题', matching: { mode: 'auto' } }, + config_json: { tabTitle: '草稿标题', matching: { autoMatchEnabled: true } }, // 当前发布版本快照 - current_version_config_json: { tabTitle: '已发布标题', matching: { mode: 'off' } }, + current_version_config_json: { tabTitle: '已发布标题', matching: { autoMatchEnabled: false } }, }) assert.equal(published.tabTitle, '已发布标题', '必须读已发布快照而不是草稿') - assert.equal(published.matching.mode, 'off', '匹配开关不能被未发布草稿改写') + assert.equal(published.matching.autoMatchEnabled, false, '匹配开关不能被未发布草稿改写') // 历史数据没有版本快照(或版本被删)时回退草稿,避免老配置突然不可用 const legacy = resolvePublishedProfileConfig({ config_json: { tabTitle: '老配置' } }) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index a6f9feb0..33da1abc 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -48,15 +48,12 @@ export type ConsignmentInventoryLimitConfig = { } export type ConsignmentMatchingConfig = { - /** off = 完全关闭匹配(新单直接进大厅);manual = 只允许人工匹配;auto = 调度器自动匹配 */ - mode: 'off' | 'manual' | 'auto' + /** 是否开启自动匹配:订单进入待抢单前只尝试一次,未命中直接进大厅 */ + autoMatchEnabled: boolean exactOnly: boolean preferLowReward: boolean -} - -export type ConsignmentInternalMatchConfig = { - /** 资料提交后等待内部库存匹配的分钟数;0 = 不等待,直接发大厅 */ - delayMinutes: number + /** 只自动匹配该时间之后创建的订单(ISO 时间字符串) */ + orderEffectiveFrom: string } export type ConsignmentTimeoutConfig = { @@ -102,7 +99,6 @@ export type ConsignmentProfileConfig = { serviceFee: ConsignmentServiceFeeConfig inventoryLimit: ConsignmentInventoryLimitConfig matching: ConsignmentMatchingConfig - internalMatch: ConsignmentInternalMatchConfig timeout: ConsignmentTimeoutConfig priceDrop: ConsignmentPriceDropConfig /** 联系方式与游戏凭据:按配置动态渲染;没有该字段时使用默认 7 项 */ @@ -178,12 +174,10 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = { maxActiveQuantity: 0, }, matching: { - mode: 'off', + autoMatchEnabled: false, exactOnly: true, preferLowReward: true, - }, - internalMatch: { - delayMinutes: 30, + orderEffectiveFrom: '', }, timeout: { minutes: 30, @@ -200,6 +194,15 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = { }, } +/** 统一 ISO 时间:无法解析时回退到 fallback,避免把非法字符串带进匹配过滤。 */ +function normalizeIsoTimestamp(value: unknown, fallback: string): string { + const raw = String(value ?? '').trim() + if (!raw) return fallback + const parsed = Date.parse(raw) + if (!Number.isFinite(parsed)) return fallback + return new Date(parsed).toISOString() +} + function record(value: unknown): Record { if (typeof value === 'string') { try { @@ -271,18 +274,15 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr const serviceFee = record(source.serviceFee) const inventoryLimit = record(source.inventoryLimit) const matching = record(source.matching) - const internalMatch = record(source.internalMatch) const timeout = record(source.timeout) const priceDrop = record(source.priceDrop) - const matchingMode: ConsignmentMatchingConfig['mode'] = - matching.mode === 'auto' - ? bool(matching.autoMatchEnabled, true) - ? 'auto' - : 'manual' - : matching.mode === 'manual' - ? 'manual' - : 'off' + // 兼容旧配置:过去用 matching.mode=auto/manual/off;现在只看 autoMatchEnabled。 + const autoMatchEnabled = bool(matching.autoMatchEnabled, matching.mode === 'auto') + const orderEffectiveFrom = normalizeIsoTimestamp( + matching.orderEffectiveFrom ?? matching.order_effective_from, + nowIso(), + ) const depositMode = deposit.mode === 'per_quantity' ? 'per_quantity' @@ -342,12 +342,10 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr maxActiveQuantity: integer(inventoryLimit.maxActiveQuantity, 0, 0, 100_000), }, matching: { - mode: matchingMode, + autoMatchEnabled, exactOnly: bool(matching.exactOnly, true), preferLowReward: bool(matching.preferLowReward, true), - }, - internalMatch: { - delayMinutes: integer(internalMatch.delayMinutes, 30, 0, 7 * 24 * 60), + orderEffectiveFrom, }, timeout: { minutes: integer(timeout.minutes, 30, 0, 7 * 24 * 60), diff --git a/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts index 1516ee5d..33c7ea89 100644 --- a/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts @@ -67,7 +67,7 @@ async function createWorker(label: string) { VALUES ($1, 'x', $2, $3, 'active', $4, TRUE, NOW(), NOW()) RETURNING id`, [ `ctimeout_${label}_${suffix}`, - `超时口径打手-${label}`, + `超时口径打手-${label}-${suffix}`, `139${String(Date.now()).slice(-8)}`, `CT${label}${suffix}`.slice(0, 32), ], @@ -101,8 +101,12 @@ async function createProfile(input: { tabTitle: `超时口径-${input.label}`, specs: [], specSource: 'manual', - matching: { mode: input.matchingMode, exactOnly: true, preferLowReward: true }, - internalMatch: { delayMinutes: 30 }, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, timeout: { minutes: 60, policy: 'reopen' }, } const profileRes = await query<{ id: number }>( @@ -421,7 +425,7 @@ async function main() { }) await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) await assertScenario(scenario, { - status: 'internal_matching', + status: 'open', deposit: 'deduct', publishForfeited: true, available: 95000, @@ -473,7 +477,7 @@ async function main() { }) await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) await assertScenario(scenario, { - status: 'internal_matching', + status: 'open', deposit: 'release', publishForfeited: false, available: 98000, diff --git a/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts b/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts index 6a04202c..7c216952 100644 --- a/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts @@ -8,7 +8,6 @@ import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { publishAdminWorkOrder } from './admin-work-order-management-service.js' -import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js' type Queryable = Pick @@ -142,13 +141,7 @@ async function processAutoPublishJob( return 'failed' } - // 分类命中寄卖配置:先进入内部匹配,不发大厅;超时由调度任务发回大厅。 - const enteredInternalMatching = await enterConsignmentInternalMatching(workOrderId) - if (enteredInternalMatching) { - await finishJob(job, 'skipped', '已进入寄卖内部匹配', { internalMatching: true }) - return 'skipped' - } - + // 发布到大厅统一由 publishAdminWorkOrder 处理:开启自动匹配时先试一次,未命中才进大厅。 const snapshot = safeParseJson(order.service_profile_snapshot_json) const autoPublish = snapshot.autoPublish && typeof snapshot.autoPublish === 'object' 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 d822f5bb..bcabd258 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,10 +17,6 @@ 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, - tryMatchConsignmentWorkOrderBeforePublish, -} from './consignment-match-engine-service.js' import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js' import { ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, @@ -390,15 +386,8 @@ export async function submitOrderServiceMaterial( } publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) - // 发布前先试一次自动匹配:命中直接 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) - } - + // 自动匹配统一放在真正发布到大厅的入口(publishAdminWorkOrder)执行; + // 资料提交阶段只负责生成待发布工单,不再提前占库。 if (result.autoPublishEnabled) { void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined) } diff --git a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts index a0015859..2b2f6a02 100644 --- a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts +++ b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts @@ -16,8 +16,8 @@ import { } from './work-order-timeout-policy.js' import { fallbackConsignmentWorkOrderToHall, - reenterConsignmentInternalMatching, startConsignmentRematch, + tryAutoMatchBeforeHall, } from './consignment-match-engine-service.js' /** 从工单物料里取本次匹配占用的寄卖库存/卖家;拼单会占多条库存、多个卖家。 */ @@ -116,8 +116,10 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI nowIso(), ) } else if (policy === 'reopen') { - // 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。 - await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false) + // 寄卖单退回大厅后,立即按配置尝试一次自动匹配;未命中就留在待抢单。 + await tryAutoMatchBeforeHall(Number(workOrder.id), { + startStatuses: [WORK_ORDER_STATUS.OPEN], + }).catch(() => false) } publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts index fc1192d9..9623ce37 100644 --- a/apps/backend/src/startup/bootstrap.ts +++ b/apps/backend/src/startup/bootstrap.ts @@ -4,7 +4,6 @@ import { ensureFulfillmentCatalogBootstrapped } from '../services/bootstrap/fulf import { migrateJsonConfigFilesToDatabase } from '../services/config/json-config-migration-service.js' import { startKuaishouIndustrySendCallbackRetryWorker } from '../services/platforms/kuaishou-industry/send-code-service.js' import { startScheduledJobs } from '../services/scheduler/scheduler-service.js' -import { startConsignmentMatchScheduler } from '../services/worker-platform/consignment-match-engine-service.js' import { logError, logInfo } from '../utils/logger.js' import { startMemoryReporter } from './memory-reporter.js' import { formatStartupError, type StartupState } from './state.js' @@ -47,7 +46,6 @@ export async function bootstrapCoreServices( startScheduledJobs() startKuaishouIndustrySendCallbackRetryWorker() - startConsignmentMatchScheduler() startMemoryReporter() break } catch (error) { diff --git a/apps/backend/src/startup/shutdown.ts b/apps/backend/src/startup/shutdown.ts index 8b2f7526..3755a73e 100644 --- a/apps/backend/src/startup/shutdown.ts +++ b/apps/backend/src/startup/shutdown.ts @@ -2,7 +2,6 @@ import type { Server } from 'node:http' import { stopKuaishouIndustrySendCallbackRetryWorker } from '../services/platforms/kuaishou-industry/send-code-service.js' import { stopScheduledJobs } from '../services/scheduler/scheduler-service.js' -import { stopConsignmentMatchScheduler } from '../services/worker-platform/consignment-match-engine-service.js' import { closeDb } from '../db/client.js' import { logError, logInfo } from '../utils/logger.js' import type { StartupState } from './state.js' @@ -29,7 +28,6 @@ export function createShutdownController( stopScheduledJobs() stopKuaishouIndustrySendCallbackRetryWorker() - stopConsignmentMatchScheduler() if (server.listening) { await new Promise((resolve) => { diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index c19ec340..5d7ff902 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -19,6 +19,7 @@ import { App, Button, Card, + DatePicker, Empty, Form, Input, @@ -33,6 +34,7 @@ import { Typography, } from 'antd' import { useEffect, useMemo, useState, type CSSProperties } from 'react' +import dayjs, { type Dayjs } from 'dayjs' import PercentTable from '@/components/common/PercentTable' import ConsignmentTemplateCopyModal, { @@ -93,11 +95,11 @@ type ConfigFormValues = { serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number } inventoryLimit: { enabled: boolean; maxActiveQuantity: number } matching: { - mode: 'off' | 'manual' | 'auto' + autoMatchEnabled: boolean exactOnly: boolean preferLowReward: boolean + orderEffectiveFrom: Dayjs } - internalMatch: { delayMinutes: number } timeout: { hours: number policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' @@ -208,19 +210,10 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues { }, inventoryLimit: { ...config.inventoryLimit }, matching: { - mode: config.matching.mode || 'off', + autoMatchEnabled: Boolean(config.matching.autoMatchEnabled), exactOnly: config.matching.exactOnly, preferLowReward: config.matching.preferLowReward, - }, - internalMatch: { - // 内部等待只属于手动匹配;非手动模式切回手动时给默认 30 分钟。 - // 兼容旧配置/旧缓存缺少 internalMatch 的情况,不能直接读嵌套字段。 - delayMinutes: - config.matching.mode === 'manual' - ? Number(config.internalMatch?.delayMinutes || 0) - : Number(config.internalMatch?.delayMinutes ?? 0) > 0 - ? Number(config.internalMatch?.delayMinutes) - : 30, + orderEffectiveFrom: dayjs(config.matching.orderEffectiveFrom || undefined), }, timeout: { hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60), @@ -257,11 +250,11 @@ function emptyFormValues(): ConfigFormValues { serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 }, inventoryLimit: { enabled: false, maxActiveQuantity: 0 }, matching: { - mode: 'off', + autoMatchEnabled: false, exactOnly: true, preferLowReward: true, + orderEffectiveFrom: dayjs(), }, - internalMatch: { delayMinutes: 30 }, timeout: { hours: 0.5, policy: 'reopen' }, priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 }, } @@ -338,14 +331,11 @@ function buildPayload( : 0, }, matching: { - mode: values.matching.mode, + autoMatchEnabled: values.matching.autoMatchEnabled, exactOnly: values.matching.exactOnly, preferLowReward: values.matching.preferLowReward, - }, - internalMatch: { - // 内部等待只属于手动匹配:关闭匹配不再等待;自动匹配不使用等待窗口。 - delayMinutes: - values.matching.mode === 'off' ? 0 : Number(values.internalMatch?.delayMinutes || 0), + orderEffectiveFrom: + values.matching.orderEffectiveFrom?.toISOString() || new Date().toISOString(), }, timeout: { minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)), @@ -1036,7 +1026,7 @@ export default function ConsignmentConfigPanel() { const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form) const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form) const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form) - const matchingMode = Form.useWatch(['matching', 'mode'], form) + const autoMatchEnabled = Form.useWatch(['matching', 'autoMatchEnabled'], form) const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form) const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form) const headerStatus: ProfileStatusInfo = selectedProfile @@ -1923,25 +1913,28 @@ export default function ConsignmentConfigPanel() {
-