feat(consignment): 自动匹配改为发布前即时匹配,manual 保留内部等待

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