feat(consignment): 自动匹配改为进入待抢单前一次性匹配
1) 配置改为开启自动匹配 + 匹配订单生效时间,移除内部匹配等待与定时轮询;2) 所有进入待抢单的路径统一先自动匹配一次,未命中进大厅;3) 手动匹配仅允许待抢单;4) 新增迁移 160 回填配置并迁移存量 internal_matching。
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+39
-34
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+73
-55
@@ -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(),
|
||||
})
|
||||
|
||||
+18
-9
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
|
||||
Reference in New Issue
Block a user