diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 88e67042..7d91bcd8 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -25,6 +25,11 @@ import { normalizeWorkerDepositHandling, } from '../../../domain/consignment-deposit-policy.js' +import { + listInventoryMatchableOrders, + listWorkOrderInventoryMatchOptions, +} from '../../../services/worker-platform/consignment-fulfillment-service.js' + const router = Router() // ---------------- 库存管理与统计 ---------------- @@ -202,6 +207,38 @@ router.get( }), ) +// 订单侧「手动指定现货」:全部在库现货 + 逐件标出规格是否相符 +router.get( + '/worker-platform/consignment/matches/:workOrderId/options', + requireAdminPermission('consignment.view'), + createJsonHandler( + (req) => listWorkOrderInventoryMatchOptions(String(req.params.workOrderId || '')), + { + successMessage: 'ok', + errorMessage: '读取可指定现货失败', + scope: '[admin/consignment/matches/options]', + }, + ), +) + +// 库存侧「手动匹配订单」:这件现货可以匹配的待指派/待补资料订单 +router.get( + '/worker-platform/consignment/inventories/:id/match-orders', + requireAdminPermission('consignment.view'), + createJsonHandler( + (req) => + listInventoryMatchableOrders(String(req.params.id || ''), { + keyword: String(req.query.keyword || ''), + pageSize: Number(req.query.pageSize || 0) || undefined, + }), + { + successMessage: 'ok', + errorMessage: '读取可匹配订单失败', + scope: '[admin/consignment/inventories/match-orders]', + }, + ), +) + router.post( '/worker-platform/consignment/fulfill', requireAdminPermission('consignment.fulfill'), @@ -212,6 +249,8 @@ router.post( inventoryId: String(req.body?.inventoryId || ''), actorName: req.adminSession?.username || 'admin', allowPartial: req.body?.allowPartial === true, + allowSpecMismatch: req.body?.allowSpecMismatch === true, + matchNote: String(req.body?.matchNote || ''), }), { successMessage: '已使用内部寄售库存直接履约!', @@ -232,6 +271,8 @@ router.post( workerId: req.body?.workerId ? Number(req.body.workerId) : null, actorName: req.adminSession?.username || 'admin', allowPartial: req.body?.allowPartial === true, + allowSpecMismatch: req.body?.allowSpecMismatch === true, + matchNote: String(req.body?.matchNote || ''), }), { successMessage: '已指派打手,押金已冻结,工单流转进行中!', diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index baf04b99..a9aa4ac6 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -21,6 +21,7 @@ import { createWorkOrderEvent, createWorkOrderEventWithClient, getWorkOrderById, + listWorkOrders, parkWorkOrderWaitingFriendAddWithClient, updateWorkOrder, type WorkOrderRow, @@ -49,9 +50,13 @@ import { type WorkOrderVoucherConsumeResult, } from './publish-voucher-service.js' import { + type ConsignmentMatchOption, type ConsignmentMatchResult, + classifyConsignmentMatch, + describeConsignmentSpecDifferences, extractPeacekeeperSpecs, findMatchingPeacekeeperInventories, + listConsignmentMatchOptions, matchPeacekeeperOrderWithInventory, } from './consignment-matching-service.js' import { mapConsignmentInventory } from './consignment-admin-service.js' @@ -69,7 +74,8 @@ function assertConsignmentSpecMatchedOrder( workOrder: WorkOrderRow, inventory: ConsignmentInventoryRow, allowPartial: boolean, -): ConsignmentMatchResult { + allowSpecMismatch = false, +): ConsignmentMatchResult | null { const fields = resolveCollectSubmittedFieldValues(workOrder) const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, { system: fields.system, @@ -83,10 +89,16 @@ function assertConsignmentSpecMatchedOrder( allowUnspecifiedSpecs: true, }) if (!match) { - throw createHttpError('该寄卖库存与订单规格不符(系统/区服/系列/钻级不匹配),已拒绝本次履约', { - statusCode: 409, - errorCode: 'consignment_spec_mismatch', - }) + // 硬冲突(系统/区服/系列/钻级不符):默认拒绝,只有客服在界面上显式确认「规格确实不一致」 + // 才放行 —— 留痕与必填原因由调用方保证(见 resolveManualMatchNote)。 + if (allowSpecMismatch) return null + throw createHttpError( + `该寄卖库存与订单规格不符(${describeConsignmentSpecDifferences(orderSpecs, inventory).join(';') || '系统/区服/系列/钻级不匹配'}),已拒绝本次履约`, + { + statusCode: 409, + errorCode: 'consignment_spec_mismatch', + }, + ) } if (match.matchType !== 'exact' && !allowPartial) { throw createHttpError( @@ -122,6 +134,163 @@ function throwConsignmentDepositHttpError(error: unknown, restock: boolean): nev throw error } +/** + * 手动指定现货(强制匹配)必须写原因:谁在什么时候、因为什么指定了这件货, + * 将来对账/追责要查得到。原因同时写入工单事件与工单 material 留痕。 + */ +/** 工单要求 vs 这件现货的字段级差异(强制匹配时写进留痕,便于事后核对) */ +function describeWorkOrderSpecConflicts( + workOrder: WorkOrderRow, + inventory: ConsignmentInventoryRow, +): string[] { + const fields = resolveCollectSubmittedFieldValues(workOrder) + const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, { + system: fields.system, + serverZone: fields.serverZone, + series: fields.series, + }) + return describeConsignmentSpecDifferences(orderSpecs, inventory) +} + +function resolveManualMatchNote(input: { + allowSpecMismatch?: boolean + matchNote?: unknown +}): string { + const note = String(input.matchNote || '').trim() + if (input.allowSpecMismatch === true && !note) { + throw createHttpError('强制匹配规格不符的现货必须填写原因', { + statusCode: 400, + errorCode: 'consignment_match_note_required', + }) + } + return note +} + +export type ConsignmentMatchOptionView = Omit & { + inventory: ReturnType +} + +/** + * 订单侧「手动指定现货」:列出该订单的**全部**在库现货(含不符合规格的)。 + * + * 自动候选只给"符不符合"的结论,人工指定时要知道**差在哪**,所以这里连不符合的也返回, + * 并用 `matchType`(精确/非精确/规格冲突)与 `conflicts` 标清楚,由客服知情决策。 + */ +export async function listWorkOrderInventoryMatchOptions(workOrderId: number | string): Promise<{ + hasMatch: boolean + exactCount: number + partialCount: number + options: ConsignmentMatchOptionView[] +}> { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + const fields = resolveCollectSubmittedFieldValues(workOrder) + const inventories = await listActiveConsignmentInventoriesForMatching() + const ranking = { exact: 0, partial: 1, conflict: 2 } + const options = listConsignmentMatchOptions(workOrder.product_name, inventories, { + system: fields.system, + serverZone: fields.serverZone, + series: fields.series, + }) + .map((option) => ({ ...option, inventory: mapConsignmentInventory(option.inventory) })) + .sort((a, b) => { + if (ranking[a.matchType] !== ranking[b.matchType]) { + return ranking[a.matchType] - ranking[b.matchType] + } + const priceDiff = + Number(a.inventory.costPriceFen || 0) - Number(b.inventory.costPriceFen || 0) + if (priceDiff !== 0) return priceDiff + return Number(a.inventory.id) - Number(b.inventory.id) + }) + + return { + hasMatch: options.some((option) => option.matchType !== 'conflict'), + exactCount: options.filter((option) => option.matchType === 'exact').length, + partialCount: options.filter((option) => option.matchType === 'partial').length, + options, + } +} + +/** + * 库存侧「手动匹配订单」:列出这件现货可以手动匹配的订单(待指派 / 待补资料), + * 并逐单标出规格是否相符、差在哪。落地仍复用履约/指派两条既有链路。 + */ +export async function listInventoryMatchableOrders( + inventoryId: number | string, + input: { keyword?: string | undefined; pageSize?: number | undefined } = {}, +): Promise<{ + inventory: ReturnType + orders: Array<{ + workOrderId: number + workOrderNo: string + productName: string + status: string + rewardAmountFen: number + requiredSpec: { system: string; serverZone: string; series: string; diamondLevel: number } + matchType: 'exact' | 'partial' | 'conflict' + score: number + reason: string + conflicts: string[] + }> +}> { + const inventory = await getConsignmentInventoryById(inventoryId) + if (!inventory) { + throw createHttpError('寄卖物品不存在', { + statusCode: 404, + errorCode: 'consignment_inventory_not_found', + }) + } + if (inventory.status !== 'in_stock' || !inventory.allow_matching) { + throw createHttpError('该寄卖物品不可用或已被暂停匹配', { + statusCode: 409, + errorCode: 'consignment_inventory_unavailable', + }) + } + + const { items } = await listWorkOrders({ + statuses: [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.PENDING_MATERIAL], + keyword: String(input.keyword || '').trim(), + pageSize: Math.min(Math.max(Number(input.pageSize || 50), 1), 100), + sort: 'id_desc', + }) + + const ranking = { exact: 0, partial: 1, conflict: 2 } + const orders = items + .map((order) => { + const fields = resolveCollectSubmittedFieldValues(order) + const orderSpecs = extractPeacekeeperSpecs(order.product_name, { + system: fields.system, + serverZone: fields.serverZone, + series: fields.series, + }) + const classified = classifyConsignmentMatch(orderSpecs, inventory) + return { + workOrderId: Number(order.id), + workOrderNo: order.work_order_no || '', + productName: order.product_name || '', + status: String(order.status || ''), + rewardAmountFen: Number(order.reward_amount || 0), + requiredSpec: { + system: orderSpecs.system, + serverZone: orderSpecs.serverZone, + series: orderSpecs.series, + diamondLevel: Number(orderSpecs.diamondLevel || 0), + }, + ...classified, + } + }) + .sort((a, b) => { + if (ranking[a.matchType] !== ranking[b.matchType]) { + return ranking[a.matchType] - ranking[b.matchType] + } + return b.workOrderId - a.workOrderId + }) + + return { inventory: mapConsignmentInventory(inventory), orders } +} + export type ConsignmentMatchResultView = Omit & { inventory: ReturnType } @@ -175,6 +344,10 @@ export async function fulfillWorkOrderWithConsignment(input: { actorName: string /** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */ allowPartial?: boolean + /** 手动指定:规格真冲突(系统/区服/系列/钻级不符)也放行,必须配 matchNote */ + allowSpecMismatch?: boolean + /** 手动指定现货的原因(强制匹配时必填,写入工单事件与 material 留痕) */ + matchNote?: string }): Promise<{ order: ReturnType inventory: ReturnType @@ -212,8 +385,14 @@ export async function fulfillWorkOrderWithConsignment(input: { }) } + const matchNote = resolveManualMatchNote(input) // 服务端规格复检:不能只依赖前端弹窗展示的匹配结果 - assertConsignmentSpecMatchedOrder(workOrder, inventory, input.allowPartial === true) + const specMatch = assertConsignmentSpecMatchedOrder( + workOrder, + inventory, + input.allowPartial === true, + input.allowSpecMismatch === true, + ) const material = safeParseJson(workOrder.material_json) const source = safeParseJson(material.source) @@ -287,6 +466,14 @@ export async function fulfillWorkOrderWithConsignment(input: { fulfillmentType: CONSIGNMENT_FULFILLMENT_TYPE.INTERNAL_STOCK, fulfilledAt: now, fulfilledBy: input.actorName, + ...(matchNote ? { matchNote } : {}), + ...(input.allowSpecMismatch === true + ? { + manualMatch: true, + allowSpecMismatch: true, + specConflicts: specMatch ? [] : describeWorkOrderSpecConflicts(workOrder, inventory), + } + : {}), }, } @@ -302,6 +489,10 @@ export async function fulfillWorkOrderWithConsignment(input: { costPriceFen: inv.cost_price_fen, buyerName: inv.buyer_name, shopName, + matchType: + specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'), + manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true, + ...(matchNote ? { matchNote } : {}), }, }) if (!wo) { @@ -383,6 +574,10 @@ export async function assignWorkOrderWithConsignmentWorker(input: { actorName: string /** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */ allowPartial?: boolean + /** 手动指定:规格真冲突(系统/区服/系列/钻级不符)也放行,必须配 matchNote */ + allowSpecMismatch?: boolean + /** 手动指定现货的原因(强制匹配时必填,写入工单事件与 material 留痕) */ + matchNote?: string }): Promise<{ order: ReturnType inventory: ReturnType @@ -408,8 +603,14 @@ export async function assignWorkOrderWithConsignmentWorker(input: { }) } + const matchNote = resolveManualMatchNote(input) // 服务端规格复检:不能只依赖前端弹窗展示的匹配结果 - assertConsignmentSpecMatchedOrder(workOrder, inventory, input.allowPartial === true) + const specMatch = assertConsignmentSpecMatchedOrder( + workOrder, + inventory, + input.allowPartial === true, + input.allowSpecMismatch === true, + ) const targetWorkerId = input.workerId || inventory.worker_id if (!targetWorkerId) { @@ -472,6 +673,14 @@ export async function assignWorkOrderWithConsignmentWorker(input: { fulfillmentType: 'worker_deposit', assignedAt: now, assignedBy: input.actorName, + ...(matchNote ? { matchNote } : {}), + ...(input.allowSpecMismatch === true + ? { + manualMatch: true, + allowSpecMismatch: true, + specConflicts: specMatch ? [] : describeWorkOrderSpecConflicts(workOrder, claimed), + } + : {}), }, } @@ -496,6 +705,10 @@ export async function assignWorkOrderWithConsignmentWorker(input: { payloadJson: JSON.stringify({ inventoryId: claimed.id, inventoryNo: claimed.inventory_no, + matchType: + specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'), + manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true, + ...(matchNote ? { matchNote } : {}), workerId: targetWorkerId, costPriceFen: claimed.cost_price_fen, sourceSubmissionId: claimed.source_submission_id diff --git a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts new file mode 100644 index 00000000..b0ba5379 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + classifyConsignmentMatch, + describeConsignmentSpecDifferences, + extractPeacekeeperSpecs, + listConsignmentMatchOptions, +} from './consignment-matching-service.js' +import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' + +function inventory(overrides: Partial = {}): ConsignmentInventoryRow { + return { + id: 1, + inventory_no: 'INV1', + item_name: '苹果微信暗潮2钻', + system: '苹果', + server_zone: '微信区', + series: '暗潮', + diamond_level: 2, + brilliance_count: 0, + status: 'in_stock', + allow_matching: true, + cost_price_fen: 12400, + ...overrides, + } as ConsignmentInventoryRow +} + +test('手动指定现货:连不符合规格的也要列出来,但必须标清差在哪', () => { + const orderText = '苹果微信区暗潮2钻 苏尔南' + const options = listConsignmentMatchOptions(orderText, [ + inventory({ id: 1 }), + inventory({ id: 2, system: '安卓' }), + inventory({ id: 3, series: '苍龙' }), + ]) + + assert.equal(options[0]!.matchType, 'exact') + assert.equal(options[1]!.matchType, 'conflict') + assert.ok(options[1]!.conflicts.some((item) => item.includes('系统'))) + assert.ok(options[1]!.conflicts.some((item) => item.includes('安卓'))) + assert.ok(options[2]!.conflicts.some((item) => item.includes('系列'))) +}) + +test('字段级差异:订单没写规格时不报差异(那是"非精确",不是冲突)', () => { + const specs = extractPeacekeeperSpecs('和平精英 暗潮 2钻', {}) + assert.deepEqual(describeConsignmentSpecDifferences(specs, inventory({})), []) +}) + +test('字段级差异逐项列出,包含钻石与光泽', () => { + const specs = extractPeacekeeperSpecs('苹果微信区暗潮4钻', {}) + const diffs = describeConsignmentSpecDifferences(specs, inventory({ diamond_level: 2 })) + assert.equal(diffs.length, 1) + assert.ok(diffs[0]!.includes('钻石等级')) + assert.ok(diffs[0]!.includes('4 钻')) + assert.ok(diffs[0]!.includes('2 钻')) +}) + +test('高配充低配算非精确(需客服确认),不是冲突', () => { + const specs = extractPeacekeeperSpecs('苹果微信区暗潮1钻', {}) + const classified = classifyConsignmentMatch(specs, inventory({ diamond_level: 2 })) + assert.equal(classified.matchType, 'partial') +}) + +test('不可用/暂停匹配的现货不参与匹配(手动指定也一样)', () => { + const specs = extractPeacekeeperSpecs('苹果微信区暗潮2钻', {}) + assert.equal( + classifyConsignmentMatch(specs, inventory({ allow_matching: false })).matchType, + 'conflict', + ) + assert.equal( + classifyConsignmentMatch(specs, inventory({ status: 'consumed' as never })).matchType, + 'conflict', + ) +}) + +test('强制匹配规格冲突时必须填原因,且两条落地链路都留痕', async () => { + const { readFile } = await import('node:fs/promises') + const src = await readFile( + new URL('./consignment-fulfillment-service.ts', import.meta.url), + 'utf8', + ) + + assert.ok(src.includes('consignment_match_note_required'), '强制匹配必须要求填原因') + assert.ok(src.includes('if (allowSpecMismatch) return null'), '显式确认时才放行冲突现货') + for (const field of ['manualMatch: true', 'allowSpecMismatch: true', 'matchNote']) { + assert.ok(src.includes(field), `留痕字段缺失:${field}`) + } + // 履约与指派两条链路,各自的 material 与工单事件都要写原因留痕(共 4 处) + assert.ok( + src.split('...(matchNote ? { matchNote } : {})').length - 1 >= 4, + '履约与指派都要在 material 与事件里写原因留痕', + ) +}) + +test('手动匹配的两个读取接口只读,不改状态', async () => { + const { readFile } = await import('node:fs/promises') + const routes = await readFile( + new URL('../../routes/admin/worker-platform/consignment.ts', import.meta.url), + 'utf8', + ) + assert.ok(routes.includes("'/worker-platform/consignment/matches/:workOrderId/options'")) + assert.ok(routes.includes("'/worker-platform/consignment/inventories/:id/match-orders'")) + assert.ok(routes.includes('listWorkOrderInventoryMatchOptions(')) + assert.ok(routes.includes('listInventoryMatchableOrders(')) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index eabd4a4b..545e3d50 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -246,6 +246,109 @@ export function matchPeacekeeperOrderWithInventory( /** * 检索并排序可匹配的库存候选 */ +/** 手动匹配时的全量选项:候选之外的现货也要能选,但要标清楚"哪里不一致" */ +export type ConsignmentMatchOption = { + inventory: ConsignmentInventoryRow + /** exact=精确匹配;partial=非精确(规格没写全或高配充低配);conflict=规格冲突 */ + matchType: 'exact' | 'partial' | 'conflict' + score: number + reason: string + /** conflict 时的字段级差异,便于客服/运营核对到底哪里不符 */ + conflicts: string[] +} + +/** + * 字段级规格差异说明(给"手动指定现货"用)。 + * + * 自动匹配只给"符不符合"的结论,人工指定时需要知道**差在哪**: + * 例如「系统:订单「苹果」/ 现货「安卓」」,一眼看出是不是发错货。 + */ +export function describeConsignmentSpecDifferences( + order: ParsedOrderSpecs, + inventory: ConsignmentInventoryRow, +): string[] { + const diffs: string[] = [] + const compare = (label: string, orderValue: string, inventoryValue: string) => { + const left = String(orderValue || '').trim() || '未指定' + const right = String(inventoryValue || '').trim() || '未填写' + if (left === '未指定' || left === right) return + diffs.push(`${label}:订单「${left}」/ 现货「${right}」`) + } + + compare('系统', order.system, normalizePeacekeeperSystem(inventory.system)) + compare('区服', order.serverZone, normalizePeacekeeperServerZone(inventory.server_zone)) + compare('系列', order.series, normalizePeacekeeperSeries(inventory.series)) + const orderDiamond = Number(order.diamondLevel || 0) + const invDiamond = Number(inventory.diamond_level || 0) + if (orderDiamond > 0 && orderDiamond !== invDiamond) { + diffs.push(`钻石等级:订单「${orderDiamond} 钻」/ 现货「${invDiamond} 钻」`) + } + const orderBrilliance = Number(order.brillianceCount || 0) + const invBrilliance = Number(inventory.brilliance_count || 0) + if (orderBrilliance > 0 && orderBrilliance !== invBrilliance) { + diffs.push(`光泽数量:订单「${orderBrilliance}」/ 现货「${invBrilliance}」`) + } + return diffs +} + +/** + * 列出某订单的**全部**在库现货选项(含系统候选之外的),供客服手动指定。 + * + * 与自动匹配的区别:自动匹配只返回"符合规格"的候选,这里连不符合的也返回, + * 但用 `matchType` 与 `conflicts` 标清楚,让客服在知情的条件下人工拍板。 + * 口径与履约复检一致(`allowUnspecifiedSpecs`),避免"界面说能选、接口却拒绝"。 + */ +export function classifyConsignmentMatch( + orderSpecs: ParsedOrderSpecs, + inventory: ConsignmentInventoryRow, +): Omit { + const strict = matchPeacekeeperOrderWithInventory(orderSpecs, inventory) + if (strict) { + return { + matchType: strict.matchType, + score: strict.score, + reason: strict.reason, + conflicts: [], + } + } + const loose = matchPeacekeeperOrderWithInventory(orderSpecs, inventory, { + allowUnspecifiedSpecs: true, + }) + if (loose) { + // 订单规格没写全之类的宽松通过:算非精确,需要客服显式确认 + return { + matchType: 'partial', + score: loose.score, + reason: loose.reason || '订单规格信息不全,需人工确认', + conflicts: describeConsignmentSpecDifferences(orderSpecs, inventory), + } + } + const conflicts = describeConsignmentSpecDifferences(orderSpecs, inventory) + return { + matchType: 'conflict', + score: 0, + reason: conflicts.length > 0 ? conflicts.join(';') : '规格不匹配', + conflicts, + } +} + +export function listConsignmentMatchOptions( + rawRequirementText: string, + inventories: ConsignmentInventoryRow[], + extraMeta?: { + system?: string | undefined + serverZone?: string | undefined + series?: string | undefined + diamondLevel?: number | undefined + }, +): ConsignmentMatchOption[] { + const orderSpecs = extractPeacekeeperSpecs(rawRequirementText, extraMeta) + return inventories.map((inventory) => ({ + inventory, + ...classifyConsignmentMatch(orderSpecs, inventory), + })) +} + export function findMatchingPeacekeeperInventories( rawRequirementText: string, inventories: ConsignmentInventoryRow[], diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index fd4ed767..f0fdfbfe 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -2062,10 +2062,13 @@ test('履约与指派两条路径都要过服务端规格复检(不只依赖 'utf8', ) - const callCount = src.split('assertConsignmentSpecMatchedOrder(workOrder, inventory').length - 1 + // 定义处 1 次 + 内部履约、指派打手各 1 次调用 + const callCount = src.split('assertConsignmentSpecMatchedOrder(').length - 1 - 1 assert.equal(callCount, 2, '内部履约与指派打手都必须复检规格') assert.ok(src.includes('consignment_spec_mismatch')) assert.ok(src.includes('consignment_spec_partial_requires_confirm')) + // 强制匹配(规格真冲突)必须有原因,否则人工指定就成了无痕操作 + assert.ok(src.includes('consignment_match_note_required')) }) test('内部履约的电子凭证核销失败要写事件留痕(不能静默假成功)', async () => {