feat(consignment): 自动匹配改为进入待抢单前一次性匹配

1) 配置改为开启自动匹配 + 匹配订单生效时间,移除内部匹配等待与定时轮询;2) 所有进入待抢单的路径统一先自动匹配一次,未命中进大厅;3) 手动匹配仅允许待抢单;4) 新增迁移 160 回填配置并迁移存量 internal_matching。
This commit is contained in:
2026-11-13 13:25:05 +08:00
parent 5bda59f94e
commit 341f8b2000
27 changed files with 596 additions and 822 deletions
@@ -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 }
}
@@ -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,
@@ -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 }
}
@@ -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
@@ -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,
@@ -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'), '不得再回内部匹配池')
})
@@ -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'),
'重新匹配必须写入并清空排除字段',
)
@@ -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 () => {
@@ -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,再立即尝试匹配',
)
})
@@ -50,19 +50,6 @@ type MatchCandidate = {
profile: ConsignmentProfileSnapshot | null
}
/** 自动匹配的「无候选」跳过日志按工单做 10 分钟节流,避免 30 秒调度刷爆日志表。 */
const matchSkipLogThrottle = new Map<string, number>()
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<string, unknown> {
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<boolean> {
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<boolean> {
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<WorkOrderRow>(
`
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<boolean> {
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<WorkOrderRow>(
`
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<WorkOrderRow>(
`
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<WorkOrderRow>(
`
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<string, Promise<ConsignmentProfileSnapshot | null>>()
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<WorkOrderRow>(
`
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
}
}
@@ -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<Record<string, any>>('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<Record<string, any>>('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<Record<string, any>>(
`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<Record<string, any>>('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<Record<string, any>>('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<Record<string, any>>('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<Record<string, any>>(
'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<Record<string, any>>(
'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(),
})
@@ -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',
},
},
})
@@ -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<string>([
WORK_ORDER_STATUS.INTERNAL_MATCHING,
WORK_ORDER_STATUS.OPEN,
WORK_ORDER_STATUS.UNASSIGNED,
])
const PENDING_BOARD_ORDER_STATUSES = new Set<string>([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<PendingConsignmentMatchResult> {
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,
@@ -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: '老配置' } })
@@ -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<string, unknown> {
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),
@@ -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,
@@ -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<PoolClient, 'query'>
@@ -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'
@@ -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)
}
@@ -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),