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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user