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: '已指派打手,押金已冻结,工单流转进行中!',