diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts index ea08108b..c73202b7 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts @@ -22,13 +22,12 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败 const body = source.slice(start, end) assert.ok( - body.includes('startStatuses: [WORK_ORDER_STATUS.OPEN]'), - '手动匹配只允许对待抢单 open 生效,由 apply 事务内部流转', + body.includes('startStatuses: MANUAL_CONSIGNMENT_MATCHABLE_STATUSES'), + '手动匹配状态必须与候选接口共用同一常量,由 apply 事务内部流转', ) assert.ok( - !body.includes('WORK_ORDER_STATUS.INTERNAL_MATCHING') && - !body.includes('WORK_ORDER_STATUS.UNASSIGNED'), - '手动匹配不得再接受内部匹配中/待分配状态', + body.includes('MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)'), + '手动匹配必须校验可操作状态', ) assert.ok( !body.includes("UPDATE work_orders SET status = 'internal_matching'"), @@ -40,6 +39,20 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败 ) }) +test('手动匹配状态:只允许待分配/待抢单,待补资料/内部匹配中不允许', async () => { + const source = await readFile(ENGINE, 'utf8') + const start = source.indexOf('const MANUAL_CONSIGNMENT_MATCHABLE_STATUSES') + const end = source.indexOf('\n]', start) + assert.ok(start >= 0 && end > start, '找不到手动匹配状态常量') + const block = source.slice(start, end) + for (const status of ['UNASSIGNED', 'OPEN']) { + assert.ok(block.includes(`WORK_ORDER_STATUS.${status}`), `手动匹配状态缺少 ${status}`) + } + for (const status of ['PENDING_MATERIAL', 'INTERNAL_MATCHING']) { + assert.ok(!block.includes(`WORK_ORDER_STATUS.${status}`), `手动匹配状态不得包含 ${status}`) + } +}) + test('apply 事务内部:允许起始状态、整单检查有效份额、拼单必须一次拼满', async () => { const source = await readFile(ENGINE, 'utf8') const wholeStart = source.indexOf('async function applyWholeOrderMatch') diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index c20f6009..a6f193a5 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -50,6 +50,23 @@ type MatchCandidate = { profile: ConsignmentProfileSnapshot | null } +/** + * 手动内部库存匹配允许的工单状态。 + * + * 这里只控制「哪些工单阶段可以人工指定现货」,不改变候选范围: + * 候选仍由 `inventoryMatchesProfile` 限定为同一寄卖配置的库存。 + * + * - unassigned:待分配 + * - open:待抢单 + * + * 待补资料(pending_material)必须先把资料补全、发布到待抢单后再匹配; + * internal_matching 是历史状态,迁移 160 已统一转为 open,不再作为匹配起点。 + */ +const MANUAL_CONSIGNMENT_MATCHABLE_STATUSES: string[] = [ + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.OPEN, +] + function parseMaterial(value: unknown): Record { const parsed = safeParseJson(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) @@ -224,9 +241,8 @@ type ConsignmentMatchContext = { specConflicts?: string[] partial?: boolean /** - * 允许作为匹配起点的工单状态。自动匹配只允许 internal_matching; - * 手动匹配允许 internal_matching / unassigned / open,由落库事务内部完成状态流转, - * 避免事务外先改状态、失败后留下无截止时间的 internal_matching。 + * 允许作为匹配起点的工单状态。自动匹配由调用方传入(unassigned/open); + * 手动匹配只允许 unassigned / open,由落库事务内部完成状态流转。 */ startStatuses?: string[] } @@ -1284,8 +1300,8 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } - if (workOrder.status !== WORK_ORDER_STATUS.OPEN) { - throw createHttpError('只有待抢单工单可以匹配寄卖库存', { + if (!MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)) { + throw createHttpError('当前工单状态不允许匹配寄卖库存', { statusCode: 409, errorCode: 'work_order_not_matchable', }) @@ -1435,8 +1451,8 @@ async function runManualConsignmentMatch( if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } - if (workOrder.status !== WORK_ORDER_STATUS.OPEN) { - throw createHttpError('只有待抢单工单可以匹配寄卖库存', { + if (!MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)) { + throw createHttpError('当前工单状态不允许匹配寄卖库存', { statusCode: 409, errorCode: 'work_order_not_matchable', }) @@ -1500,8 +1516,9 @@ async function runManualConsignmentMatch( ...evaluation.conflicts, ...evaluation.unknowns, ]), - // 手动匹配只对待抢单(大厅)工单生效;状态流转在 apply 事务内部完成。 - startStatuses: [WORK_ORDER_STATUS.OPEN], + // 手动匹配允许待补资料/待分配/历史内部匹配中/待抢单; + // 状态流转在 apply 事务内部完成,失败时不会留下无截止时间的 internal_matching。 + startStatuses: MANUAL_CONSIGNMENT_MATCHABLE_STATUSES, } if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index cb9852b0..70ba7925 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -1897,6 +1897,66 @@ async function main() { assert(Number(finalInv.rows[0]!.remaining_quantity) === 0, '拆分完成后库存应扣完') }) + // ---------- 16. 待分配工单可手动匹配,待补资料不允许 ---------- + await check('待分配工单:候选可读且手动匹配落库;待补资料直接拒绝', async () => { + const manualStatusCategoryId = await createCategory('manual-status') + const manualStatusConfig = { + ...profileConfig, + matching: { + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + orderEffectiveFrom: '1970-01-01T00:00:00.000Z', + }, + } + const manualStatusProfile = await createProfileForCategory({ + categoryId: manualStatusCategoryId, + label: 'manual-status', + config: manualStatusConfig, + }) + + const pendingWorkOrder = await createWorkOrder({ + no: `CT-WO-MANUAL-PENDING-${suffix}`, + status: 'pending_material', + categoryId: manualStatusCategoryId, + }) + let pendingRejected = false + try { + await listConsignmentMatchCandidatesForWorkOrder(Number(pendingWorkOrder.id)) + } catch (error) { + pendingRejected = + error instanceof Error && error.message.includes('当前工单状态不允许匹配寄卖库存') + } + assert(pendingRejected, '待补资料工单不得读取匹配候选') + + const statusInventory = await createInventory({ + no: `CT-INV-MANUAL-UNASSIGNED-${suffix}`, + workerId: sellerId, + categoryId: manualStatusCategoryId, + profileId: manualStatusProfile.profileId, + profileVersionId: manualStatusProfile.versionId, + profileSnapshotConfig: manualStatusConfig, + saleFen: 9000, + capFen: 9000, + }) + const statusWorkOrder = await createWorkOrder({ + no: `CT-WO-MANUAL-UNASSIGNED-${suffix}`, + status: 'unassigned', + categoryId: manualStatusCategoryId, + }) + const candidates = await listConsignmentMatchCandidatesForWorkOrder(Number(statusWorkOrder.id)) + assert( + candidates.options.some((item) => item.inventoryId === Number(statusInventory.id)), + '待分配工单候选缺少同配置库存', + ) + const matched = await matchConsignmentWorkOrderManually({ + workOrderId: Number(statusWorkOrder.id), + inventoryIds: [Number(statusInventory.id)], + actorName: 'ctest-manual-status', + }) + assert(matched.matched, '待分配工单手动匹配未落库') + }) + // ---------- 输出 ---------- console.log('\n========== 结果 ==========') for (const result of results) { diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index 5c7f060f..d07b76c0 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -61,6 +61,9 @@ export default function ConsignmentWorkOrderMatchModal({ queryKey: ['admin-consignment-match-candidates', workOrderId], queryFn: () => fetchAdminConsignmentMatchCandidates(Number(workOrderId)), enabled: open && Boolean(workOrderId), + // 409 等业务拒绝对话框要直接把原因展示出来,不能重试后再被误判成“没有库存”。 + retry: false, + refetchOnWindowFocus: false, }) const data = candidatesQuery.data?.data const options = useMemo(() => data?.options || [], [data?.options]) @@ -222,10 +225,25 @@ export default function ConsignmentWorkOrderMatchModal({ } > {candidatesQuery.isLoading ? : null} - {!candidatesQuery.isLoading && options.length === 0 ? ( + {candidatesQuery.isError ? ( + void candidatesQuery.refetch()}> + 重试 + + } + /> + ) : null} + {!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? ( ) : null} - {options.length > 0 ? ( + {!candidatesQuery.isError && options.length > 0 ? ( {data?.matchingMode === 'off' ? ( diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index f6df0171..792baf7a 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -163,7 +163,6 @@ const ADMIN_NOTE_QUICK_TAGS = ['已通知加好友'] as const const FILTERABLE_WORK_ORDER_STATUSES = [ 'pending_material', 'unassigned', - 'internal_matching', 'open', 'in_progress', 'pending_acceptance', @@ -2071,19 +2070,13 @@ export default function WorkOrdersPanel() { 寄卖匹配只保留新引擎入口:旧的 /fulfill 手动匹配不支持数量拆分, 还会绕过 profile/重新匹配排除,已从 UI 收敛掉。 */} - {canFulfillConsignment && - ['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? ( + {canFulfillConsignment && ['unassigned', 'open'].includes(String(row.status || '')) ? ( ) : null} {canCustomerRefund && diff --git a/apps/frontend/src/utils/consignment-manual-match.ts b/apps/frontend/src/utils/consignment-manual-match.ts index e23083a7..8158e728 100644 --- a/apps/frontend/src/utils/consignment-manual-match.ts +++ b/apps/frontend/src/utils/consignment-manual-match.ts @@ -74,11 +74,11 @@ export function filterManualMatchOptions( /** * 哪些订单状态可以手动指定现货。 * - * 与后端 `fulfillWorkOrderWithConsignment` 的前置校验保持一致(允许 unassigned / - * pending_material / open):入口先按同一口径收敛,避免"按钮点了才报错"。 + * 与后端新引擎的前置校验保持一致:只允许待分配(unassigned)和待抢单(open)。 + * 待补资料必须先补资料/发布到待抢单;历史内部匹配中已由迁移统一转为待抢单。 */ export function isManuallyMatchableOrderStatus(status: string): boolean { - return status === 'unassigned' || status === 'pending_material' || status === 'open' + return status === 'unassigned' || status === 'open' } /** diff --git a/apps/frontend/tests/consignment-manual-match.test.ts b/apps/frontend/tests/consignment-manual-match.test.ts index 894e5feb..74f985ec 100644 --- a/apps/frontend/tests/consignment-manual-match.test.ts +++ b/apps/frontend/tests/consignment-manual-match.test.ts @@ -61,11 +61,11 @@ test('关键词过滤:现货能按寄卖单号/物品名搜到,大小写与 assert.equal(filterManualMatchOptions(list, '不存在', pick).length, 0) }) -test('待指派/待补资料/大厅待抢的订单均能手动指定现货(与后端前置校验同口径)', async () => { +test('待指派/大厅待抢的订单能手动指定现货,待补资料不再允许', async () => { const { isManuallyMatchableOrderStatus } = await import('../src/utils/consignment-manual-match.ts') assert.equal(isManuallyMatchableOrderStatus('unassigned'), true) - assert.equal(isManuallyMatchableOrderStatus('pending_material'), true) + assert.equal(isManuallyMatchableOrderStatus('pending_material'), false) assert.equal(isManuallyMatchableOrderStatus('open'), true) for (const status of ['in_progress', 'pending_acceptance', 'accepted', 'cancelled', 'disputed']) { assert.equal(isManuallyMatchableOrderStatus(status), false, `${status} 不该给手动匹配入口`) @@ -96,7 +96,7 @@ test('寄卖只做整单:已有打手参与的拼单工单不给「手动匹 ) // 普通整单 assert.equal(canManuallyMatchConsignmentOrder({ status: 'unassigned' }), true) - assert.equal(canManuallyMatchConsignmentOrder({ status: 'pending_material' }), true) + assert.equal(canManuallyMatchConsignmentOrder({ status: 'pending_material' }), false) // 状态本身不允许的仍然不给 for (const status of ['in_progress', 'pending_acceptance', 'accepted', 'cancelled']) { diff --git a/apps/frontend/tests/consignment-match-coverage.test.ts b/apps/frontend/tests/consignment-match-coverage.test.ts index f3ee96bd..f4c11a0e 100644 --- a/apps/frontend/tests/consignment-match-coverage.test.ts +++ b/apps/frontend/tests/consignment-match-coverage.test.ts @@ -114,15 +114,33 @@ test('整单覆盖:必须一条库存满足全部数量', () => { assert.equal(enough.sufficient, true) }) -test('工单页:internal_matching 显示「手动匹配寄卖」入口', () => { +test('工单页:内部库存匹配只对待分配/待抢单显示,不再包含内部匹配中', () => { const source = readFileSync( fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)), 'utf8', ) - assert.ok(source.includes('手动匹配寄卖'), '内部匹配中的按钮文案必须是手动匹配寄卖') assert.ok( - source.includes("String(row.status || '') === 'internal_matching'"), - '按钮文案必须只在 internal_matching 下切换', + source.includes("['unassigned', 'open'].includes(String(row.status || ''))"), + '内部库存匹配入口必须只覆盖待分配/待抢单', + ) + assert.ok(!source.includes("'internal_matching'"), '状态筛选与入口都不应再包含内部匹配中') +}) + +test('匹配弹窗:接口错误必须展示,不能伪装成没有库存', () => { + const source = readFileSync( + fileURLToPath( + new URL('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx', import.meta.url), + ), + 'utf8', + ) + assert.ok(source.includes('candidatesQuery.isError'), '必须处理候选查询错误') + assert.ok(source.includes('读取可匹配寄卖库存失败'), '必须展示错误标题') + assert.ok(source.includes('重试'), '必须提供重试入口') + assert.ok( + source.includes( + '!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0', + ), + '空状态必须排除错误场景', ) }) diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts index e1dab3eb..1d7a012b 100644 --- a/apps/frontend/tests/consignment-matching-config.test.ts +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -71,14 +71,17 @@ test('打手端:寄卖页必须按已发布配置动态生成 tab,并支持 assert.ok(page.includes('寄售数量'), '发布表单必须支持数量') }) -test('工单页:必须挂载内部库存匹配弹窗,并支持状态 internal_matching', () => { +test('工单页:必须挂载内部库存匹配弹窗,且不再提供内部匹配中筛选', () => { const panel = read('../src/pages/admin/panels/WorkOrdersPanel.tsx') assert.ok(panel.includes('ConsignmentWorkOrderMatchModal'), '工单页必须挂载内部匹配弹窗') - assert.ok(panel.includes("'internal_matching'"), '状态筛选必须支持内部匹配中') + assert.ok(!panel.includes("'internal_matching'"), '状态筛选必须移除内部匹配中') const shared = read('../src/pages/admin/panels/shared.tsx') - assert.ok(shared.includes("internal_matching: '内部匹配中'"), '状态文案必须包含内部匹配中') - assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中') + assert.ok( + shared.includes("internal_matching: '内部匹配中'"), + '历史事件详情仍需展示内部匹配中文案', + ) + assert.ok(shared.includes("internal_matching: 'magenta'"), '历史事件详情仍需保留内部匹配中配色') }) test('寄卖配置面板:订单服务风格分类树、独立分区与价格配置', () => {