feat(consignment): 后端支持"手动指定现货"匹配(含规格冲突强制匹配留痕)

需求:客服在后台能人工指定现货与订单匹配,而不是只能用系统算出的候选。

- 新增纯函数 listConsignmentMatchOptions()/classifyConsignmentMatch():把某订单的**全部**
  在库现货列出来并逐件判定 exact/partial/conflict,conflict 时给出字段级差异
  (describeConsignmentSpecDifferences,例如「系统:订单「苹果」/ 现货「安卓」」)
- 新增 describeWorkOrderSpecConflicts():强制匹配时把差异写进工单 material 与事件留痕
- fulfill/assign 两条落地链路支持 allowSpecMismatch(规格真冲突显式放行)+ matchNote
  (强制匹配必填原因,否则 400 consignment_match_note_required),
  material 与工单事件都记录 manualMatch/matchType/matchNote
- 新增两个只读接口:订单侧 GET /consignment/matches/:workOrderId/options(全部在库现货+判定),
  库存侧 GET /consignment/inventories/:id/match-orders(该现货可匹配的待指派/待补资料订单+判定)
- 抽取 classifyConsignmentMatch 让正反两个方向共用同一套匹配口径,避免"界面说能选、接口却拒绝"

真实库验证:订单要苹果微信暗潮2钻、现货为安卓QQ苍龙4钻时正确判定 conflict 并列出 4 项差异;
不填原因强制匹配被拒(consignment_match_note_required);不强制则被规格复检拒绝
(consignment_spec_mismatch);填原因强制匹配成功且工单事件与 material 都有留痕。
This commit is contained in:
yml2213
2026-09-17 12:20:23 +08:00
parent f62f9b6645
commit 94328ac643
5 changed files with 473 additions and 8 deletions
@@ -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: '已指派打手,押金已冻结,工单流转进行中!',
@@ -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<ConsignmentMatchOption, 'inventory'> & {
inventory: ReturnType<typeof mapConsignmentInventory>
}
/**
* 订单侧「手动指定现货」:列出该订单的**全部**在库现货(含不符合规格的)。
*
* 自动候选只给"符不符合"的结论,人工指定时要知道**差在哪**,所以这里连不符合的也返回,
* 并用 `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<typeof mapConsignmentInventory>
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<ConsignmentMatchResult, 'inventory'> & {
inventory: ReturnType<typeof mapConsignmentInventory>
}
@@ -175,6 +344,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
actorName: string
/** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */
allowPartial?: boolean
/** 手动指定:规格真冲突(系统/区服/系列/钻级不符)也放行,必须配 matchNote */
allowSpecMismatch?: boolean
/** 手动指定现货的原因(强制匹配时必填,写入工单事件与 material 留痕) */
matchNote?: string
}): Promise<{
order: ReturnType<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
@@ -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<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
@@ -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
@@ -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> = {}): 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('))
})
@@ -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<ConsignmentMatchOption, 'inventory'> {
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[],
@@ -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 () => {