fix(consignment): 补齐指派补偿与规格复检,封闭寄卖资金/货物不一致窗口
- 指派失败改为完整补偿:取消指派(解冻打手押金 + 工单退回大厅 + 解绑现货)→ 兜底释放占用 → 摘除工单寄售标记。此前只把现货放回在库池,一旦"指派已提交、写 material 失败",工单会停在 in_progress + 押金冻结却不带寄售记录,现货还回到可匹配池,同一件货会被第二张单占用。 - 电子凭证核销失败写 consignment_fulfill_voucher_consume_failed 事件,前端按 voucherConsume.ok 弹警告并提示人工核销,不再无条件报成功(核销走快手外部接口,无法与数据库事务合并回滚)。 - 匹配规格改为 match/mismatch/unknown 三态,钻级兜底由 || 1 改 || 0:订单规格信息不全一律不进 "精确匹配",降级 partial 并在原因里列出缺失字段,消除"任意现货都 100 分精确匹配"的错发根因。 - 履约与指派在占库前做服务端规格复检:冲突 409 consignment_spec_mismatch;非 exact 必须显式 传 allowPartial(前端在部分匹配候选上先危险确认),高配充低配保留但不可误触。 - 人工解绑守卫由"状态黑名单"收窄为只拦交付中三态(与撤销白名单对齐),修复 "工单未开始 + 现货仍绑定"没有任何人工出口的问题。 - 新增 detachConsignmentMaterialFromWorkOrderWithClient,解绑回库/人工退回复库/作废同步摘掉 工单寄售标记,消除"已回库却仍显示寄卖履约"的陈旧痕迹。 - 回归测试新增 8 例(后端 410 例 / 407 通过),附迁移 079 上库前检查脚本。
This commit is contained in:
@@ -616,7 +616,15 @@ export async function releaseConsignmentInventoryClaim(
|
||||
return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input))
|
||||
}
|
||||
|
||||
/** 仅当库存未被"进行中的工单"占用时才允许人工改状态(作废 / 退回复库) */
|
||||
/**
|
||||
* 仅当库存未被"交付中的工单"占用时才允许人工改状态(作废 / 退回复库)。
|
||||
*
|
||||
* "交付中"= 工单已指派打手且正在推进(in_progress / pending_acceptance / problem),
|
||||
* 与「撤销寄售履约」的状态白名单保持一致:这种单子只能走撤销流程,人工改库存一律拦截。
|
||||
* 其余状态(unassigned / open / pending_material / accepted / cancelled)不算占用,
|
||||
* 运营必须能人工修数据 —— 尤其是"指派事务提交后写资料失败/进程崩溃"留下的
|
||||
* 「工单未开始 + 现货仍绑定」组合,人工退回复库是唯一出口。
|
||||
*/
|
||||
const NO_LIVE_BOUND_WORK_ORDER_SQL = `
|
||||
(
|
||||
ci.bound_work_order_id IS NULL
|
||||
@@ -624,11 +632,61 @@ const NO_LIVE_BOUND_WORK_ORDER_SQL = `
|
||||
SELECT 1
|
||||
FROM work_orders wo
|
||||
WHERE wo.id = ci.bound_work_order_id
|
||||
AND wo.status NOT IN ('accepted', 'cancelled')
|
||||
AND wo.status IN ('in_progress', 'pending_acceptance', 'problem')
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
/**
|
||||
* 摘掉工单 material 上的寄售履约标记,并把它推进 consignmentHistory 保留痕迹。
|
||||
*
|
||||
* 任何"解绑库存"的路径(撤销、人工回库/作废、撤单、超时回滚、指派补偿)都必须调用它,
|
||||
* 否则工单会一直带着「寄售履约中 + 某个现货编号」的陈旧标记:列表继续展示已回库的现货,
|
||||
* 撤销按钮再次出现却必然 409。
|
||||
*
|
||||
* 传了 inventoryId 时只清理指向该现货的标记,避免误伤后续重新绑定到同一工单的新现货。
|
||||
*/
|
||||
export async function detachConsignmentMaterialFromWorkOrderWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workOrderId: number | string
|
||||
inventoryId?: number | string | null
|
||||
reason: string
|
||||
now: string
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const res = await client.query<{ id: number }>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
material_json = jsonb_set(
|
||||
material_json - 'consignment',
|
||||
'{consignmentHistory}',
|
||||
COALESCE(material_json -> 'consignmentHistory', '[]'::jsonb)
|
||||
|| jsonb_build_array(
|
||||
(material_json -> 'consignment')
|
||||
|| jsonb_build_object('detachedAt', $1::text, 'detachReason', $2::text)
|
||||
),
|
||||
true
|
||||
),
|
||||
updated_at = $1
|
||||
WHERE id = $3
|
||||
AND material_json ? 'consignment'
|
||||
AND ($4::text IS NULL OR material_json -> 'consignment' ->> 'inventoryId' = $4::text)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
input.now,
|
||||
input.reason,
|
||||
Number(input.workOrderId),
|
||||
input.inventoryId === undefined || input.inventoryId === null
|
||||
? null
|
||||
: String(input.inventoryId),
|
||||
],
|
||||
)
|
||||
return Boolean(res.rows[0])
|
||||
}
|
||||
|
||||
export async function archiveConsignmentInventoryWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
@@ -647,7 +705,19 @@ export async function archiveConsignmentInventoryWithClient(
|
||||
`,
|
||||
[now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
const archived = res.rows[0] || null
|
||||
if (!archived) return null
|
||||
|
||||
// 作废同样要摘掉工单上的寄售标记(作废后现货不会再交付)
|
||||
if (archived.bound_work_order_id) {
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId: archived.bound_work_order_id,
|
||||
inventoryId: archived.id,
|
||||
reason: `人工作废寄卖库存: ${archived.inventory_no || archived.id}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
return archived
|
||||
}
|
||||
|
||||
export async function returnConsignmentInventoryToStockWithClient(
|
||||
@@ -656,6 +726,14 @@ export async function returnConsignmentInventoryToStockWithClient(
|
||||
reason: string,
|
||||
now: string,
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
// 先锁行读出旧绑定:下面的 UPDATE 会清空 bound_work_order_id,
|
||||
// 之后就不知道要摘哪张工单的寄售标记了(会造成"已回库却仍显示寄售履约")。
|
||||
const lockedRes = await client.query<{ bound_work_order_id: number | null }>(
|
||||
'SELECT bound_work_order_id FROM consignment_inventories WHERE id = $1 FOR UPDATE',
|
||||
[Number(id)],
|
||||
)
|
||||
const previousWorkOrderId = lockedRes.rows[0]?.bound_work_order_id ?? null
|
||||
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories ci
|
||||
@@ -680,7 +758,18 @@ export async function returnConsignmentInventoryToStockWithClient(
|
||||
`,
|
||||
[reason, now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
const returned = res.rows[0] || null
|
||||
if (!returned) return null
|
||||
|
||||
if (previousWorkOrderId) {
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId: previousWorkOrderId,
|
||||
inventoryId: returned.id,
|
||||
reason: `人工退回复库: ${reason || ''}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
return returned
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,7 +807,18 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
|
||||
`,
|
||||
[input.now, workOrderId, input.reason || ''],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
const released = res.rows[0] || null
|
||||
if (!released) return null
|
||||
|
||||
// 现货已解绑回库,工单上的寄售标记必须同步摘掉:否则工单列表会继续显示
|
||||
// 「寄售履约: 某现货」,撤销按钮也会再次出现(点下去必然 409)。
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId,
|
||||
inventoryId: released.id,
|
||||
reason: `工单解绑回库: ${input.reason || ''}`,
|
||||
now: input.now,
|
||||
})
|
||||
return released
|
||||
}
|
||||
|
||||
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
||||
|
||||
@@ -207,6 +207,7 @@ router.post(
|
||||
workOrderId: String(req.body?.workOrderId || ''),
|
||||
inventoryId: String(req.body?.inventoryId || ''),
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
allowPartial: req.body?.allowPartial === true,
|
||||
}),
|
||||
{
|
||||
successMessage: '已使用内部寄售库存直接履约!',
|
||||
@@ -226,6 +227,7 @@ router.post(
|
||||
inventoryId: String(req.body?.inventoryId || ''),
|
||||
workerId: req.body?.workerId ? Number(req.body.workerId) : null,
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
allowPartial: req.body?.allowPartial === true,
|
||||
}),
|
||||
{
|
||||
successMessage: '已指派打手,押金已冻结,工单流转进行中!',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WORK_ORDER_STATUS, type WorkOrderStatus } from '../../domain/work-order
|
||||
import {
|
||||
type ConsignmentInventoryRow,
|
||||
claimConsignmentInventoryForWorker,
|
||||
detachConsignmentMaterialFromWorkOrderWithClient,
|
||||
getConsignmentInventoryById,
|
||||
listActiveConsignmentInventoriesForMatching,
|
||||
releaseConsignmentInventoryClaim,
|
||||
@@ -28,6 +29,7 @@ import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-service.js'
|
||||
import { unassignAdminWorkOrder } from './admin-work-order-cancellation-service.js'
|
||||
import { mapWorkOrderAdmin } from './mappers.js'
|
||||
import {
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||
@@ -35,11 +37,49 @@ import {
|
||||
} from './publish-voucher-service.js'
|
||||
import {
|
||||
type ConsignmentMatchResult,
|
||||
extractPeacekeeperSpecs,
|
||||
findMatchingPeacekeeperInventories,
|
||||
matchPeacekeeperOrderWithInventory,
|
||||
} from './consignment-matching-service.js'
|
||||
import { mapConsignmentInventory } from './consignment-admin-service.js'
|
||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||
|
||||
/**
|
||||
* 履约/指派前的服务端规格复检。
|
||||
*
|
||||
* 匹配结果此前只在前端弹窗里展示,接口本身不校验规格 —— 只要拿到 inventoryId 就能把
|
||||
* 系统/区服/系列/钻级都不对的现货发出去。这里再确认一次:
|
||||
* - 规格冲突(null)→ 直接拒绝;
|
||||
* - 非精确匹配(订单规格信息不全,或高配充低配)→ 必须显式传 allowPartial 才放行。
|
||||
*/
|
||||
function assertConsignmentSpecMatchedOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
inventory: ConsignmentInventoryRow,
|
||||
allowPartial: boolean,
|
||||
): ConsignmentMatchResult {
|
||||
const fields = resolveCollectSubmittedFieldValues(workOrder)
|
||||
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||
system: fields.system,
|
||||
serverZone: fields.serverZone,
|
||||
series: fields.series,
|
||||
})
|
||||
|
||||
const match = matchPeacekeeperOrderWithInventory(orderSpecs, inventory)
|
||||
if (!match) {
|
||||
throw createHttpError('该寄卖库存与订单规格不符(系统/区服/系列/钻级不匹配),已拒绝本次履约', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_spec_mismatch',
|
||||
})
|
||||
}
|
||||
if (match.matchType !== 'exact' && !allowPartial) {
|
||||
throw createHttpError(
|
||||
`该寄卖库存不是精确匹配(${match.reason});确认要用于本订单请勾选"允许非精确匹配"后重试`,
|
||||
{ statusCode: 409, errorCode: 'consignment_spec_partial_requires_confirm' },
|
||||
)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
|
||||
const code = getConsignmentDepositErrorCode(error)
|
||||
if (code === 'consignment_deposit_insufficient') {
|
||||
@@ -110,6 +150,8 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
workOrderId: number | string
|
||||
inventoryId: number | string
|
||||
actorName: string
|
||||
/** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */
|
||||
allowPartial?: boolean
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||
@@ -147,6 +189,9 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 服务端规格复检:不能只依赖前端弹窗展示的匹配结果
|
||||
assertConsignmentSpecMatchedOrder(workOrder, inventory, input.allowPartial === true)
|
||||
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
const source = safeParseJson(material.source)
|
||||
const shopName = String(source.shopName || source.shopTitle || '').trim()
|
||||
@@ -245,7 +290,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
return { updatedOrder: wo, updatedInventory: inv }
|
||||
})
|
||||
|
||||
// 顺带核销快手电子凭证
|
||||
// 顺带核销快手电子凭证。
|
||||
// 核销要走快手外部接口、无法和上面的数据库事务合并回滚,因此这里只能做两件事:
|
||||
// 1) 失败时不吞错:写事件留痕,并把 voucherConsume.ok=false 交给前端提示运营人工核销;
|
||||
// 2) 绝不因为"凭证没核销"就谎报成功(此前前端无条件弹成功提示,属于假成功)。
|
||||
let voucherConsume: WorkOrderVoucherConsumeResult
|
||||
try {
|
||||
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder)
|
||||
@@ -260,6 +308,22 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
|
||||
}
|
||||
}
|
||||
if (!voucherConsume.ok) {
|
||||
try {
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(updatedOrder.id),
|
||||
actorType: 'system',
|
||||
actorId: 'kuaishou_send_code',
|
||||
eventType: 'consignment_fulfill_voucher_consume_failed',
|
||||
fromStatus: String(updatedOrder.status),
|
||||
toStatus: String(updatedOrder.status),
|
||||
payloadJson: JSON.stringify(voucherConsume),
|
||||
now: nowIso(),
|
||||
})
|
||||
} catch {
|
||||
// 事件写入失败不影响履约结果,仍把 voucherConsume 返回给前端
|
||||
}
|
||||
}
|
||||
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
|
||||
|
||||
@@ -278,6 +342,8 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
inventoryId: number | string
|
||||
workerId?: number | string | null
|
||||
actorName: string
|
||||
/** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */
|
||||
allowPartial?: boolean
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||
@@ -303,6 +369,9 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 服务端规格复检:不能只依赖前端弹窗展示的匹配结果
|
||||
assertConsignmentSpecMatchedOrder(workOrder, inventory, input.allowPartial === true)
|
||||
|
||||
const targetWorkerId = input.workerId || inventory.worker_id
|
||||
if (!targetWorkerId) {
|
||||
throw createHttpError('该寄卖物品未关联打手,请在指派弹窗中选择打手账号', {
|
||||
@@ -402,20 +471,74 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
voucherConsume: assignResult.voucherConsume,
|
||||
}
|
||||
} catch (error) {
|
||||
// 指派/写资料失败:立刻把现货放回在库池,避免库存被长期占用而无法再匹配。
|
||||
// 回滚失败不能掩盖原始错误,仅在库存备注中保留痕迹。
|
||||
try {
|
||||
await releaseConsignmentInventoryClaim(inventory.id, {
|
||||
now: nowIso(),
|
||||
reason: '指派打手失败自动回滚',
|
||||
})
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
// 指派/写资料失败:必须把这次操作的全部效果撤干净,不能只还库存。
|
||||
// 否则一旦「指派事务已提交、写 material 失败」,工单会停在 in_progress + 押金已冻结,
|
||||
// 而系统并不认为它挂着寄售现货 —— 现货还回到了可匹配池,同一件货会被第二张单占用。
|
||||
await compensateFailedConsignmentAssignment({
|
||||
workOrderId: workOrder.id,
|
||||
inventoryId: inventory.id,
|
||||
actorName: input.actorName,
|
||||
reason: error instanceof Error ? error.message : '指派打手失败',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原一次失败的「选用寄售库存 + 指派打手」操作。
|
||||
*
|
||||
* 每一步都是独立 try:补偿本身失败不能掩盖原始错误,但要在库存备注/工单 material 里留痕,
|
||||
* 方便运营复盘(现货回不去时会带上备注说明)。
|
||||
*/
|
||||
async function compensateFailedConsignmentAssignment(input: {
|
||||
workOrderId: number | string
|
||||
inventoryId: number | string
|
||||
actorName: string
|
||||
reason: string
|
||||
}): Promise<void> {
|
||||
const detachedReason = `指派打手失败自动回滚: ${input.reason}`
|
||||
|
||||
// 1) 指派事务若已提交(工单已进入进行中且已指派打手),走管理端「取消指派」:
|
||||
// 解冻打手押金、工单退回大厅,并顺带解绑这次占用的现货。
|
||||
try {
|
||||
const latest = await getWorkOrderById(input.workOrderId)
|
||||
if (
|
||||
latest &&
|
||||
latest.assigned_worker_id &&
|
||||
String(latest.status) === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
) {
|
||||
await unassignAdminWorkOrder(input.workOrderId, input.actorName)
|
||||
}
|
||||
} catch {
|
||||
// 忽略:下一步的库存兜底仍会执行
|
||||
}
|
||||
|
||||
// 2) 兜底把现货放回在库池(指派未提交时的主路径;已解绑时是空操作)
|
||||
try {
|
||||
await releaseConsignmentInventoryClaim(input.inventoryId, {
|
||||
now: nowIso(),
|
||||
reason: detachedReason,
|
||||
})
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
|
||||
// 3) 摘掉工单上的寄售标记:避免留下"寄售履约中"的陈旧痕迹,
|
||||
// 也避免重新指派时把上一件现货的信息带给新的打手。
|
||||
try {
|
||||
await withTransaction((client: PoolClient) =>
|
||||
detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
inventoryId: input.inventoryId,
|
||||
reason: detachedReason,
|
||||
now: nowIso(),
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许「撤销寄售履约」的工单状态白名单。
|
||||
*
|
||||
|
||||
@@ -240,3 +240,49 @@ test('findMatchingPeacekeeperInventories matches 苹果微信暗潮1钻 to 苹
|
||||
assert.equal(matches[0]!.matchType, 'exact')
|
||||
assert.equal(matches[1]!.matchType, 'exact')
|
||||
})
|
||||
|
||||
test('订单规格信息不全时绝不判为"精确匹配",钻级不再兜底为 1', () => {
|
||||
// 商品名只写"苏尔南":系统/区服/系列/钻级全部缺失
|
||||
const vague = extractPeacekeeperSpecs('苏尔南')
|
||||
assert.equal(vague.diamondLevel, 0, '解析不到钻级必须记为 0,不能兜底成 1')
|
||||
|
||||
const inv = buildInventory({ diamond_level: 1, brilliance_count: 0 })
|
||||
const matched = matchPeacekeeperOrderWithInventory(vague, inv)
|
||||
assert.ok(matched, '信息不全的订单仍应给出候选,但只能是部分匹配')
|
||||
assert.equal(matched.matchType, 'partial')
|
||||
assert.ok(matched.reason.includes('订单规格信息不全'))
|
||||
assert.ok(matched.reason.includes('系统'))
|
||||
assert.ok(matched.reason.includes('区服'))
|
||||
assert.ok(matched.reason.includes('系列'))
|
||||
assert.ok(matched.reason.includes('钻级'))
|
||||
})
|
||||
|
||||
test('订单缺钻级但其余规格完整时,也不得判为精确匹配', () => {
|
||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮')
|
||||
assert.equal(order.diamondLevel, 0)
|
||||
|
||||
const matched = matchPeacekeeperOrderWithInventory(order, buildInventory({ diamond_level: 1 }))
|
||||
assert.ok(matched)
|
||||
assert.equal(matched.matchType, 'partial')
|
||||
assert.ok(matched.reason.includes('钻级'))
|
||||
})
|
||||
|
||||
test('库存侧缺系统/区服时不能算精确匹配(无法确认一致)', () => {
|
||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
|
||||
const inv = buildInventory({ system: '', server_zone: '', series: '暗潮', diamond_level: 1 })
|
||||
|
||||
const matched = matchPeacekeeperOrderWithInventory(order, inv)
|
||||
assert.ok(matched)
|
||||
assert.equal(matched.matchType, 'partial')
|
||||
assert.ok(matched.reason.includes('系统'))
|
||||
assert.ok(matched.reason.includes('区服'))
|
||||
})
|
||||
|
||||
test('通用件与明确规格冲突时仍然排除', () => {
|
||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
|
||||
const wrongSystem = buildInventory({ system: '苹果' })
|
||||
assert.equal(matchPeacekeeperOrderWithInventory(order, wrongSystem), null)
|
||||
|
||||
const wrongZone = buildInventory({ server_zone: 'QQ区' })
|
||||
assert.equal(matchPeacekeeperOrderWithInventory(order, wrongZone), null)
|
||||
})
|
||||
|
||||
@@ -116,15 +116,29 @@ export function extractPeacekeeperSpecs(
|
||||
system: fromText.system || fromMeta.system,
|
||||
serverZone: fromText.serverZone || fromMeta.serverZone,
|
||||
series: fromText.series || fromMeta.series,
|
||||
diamondLevel: fromText.diamondLevel || fromMeta.diamondLevel || extraMeta?.diamondLevel || 1,
|
||||
// 解析不到钻级时记为 0(未知),绝不兜底成 1:
|
||||
// 兜底成 1 会让"没写钻级"的订单把任意 1 钻库存判成"精确匹配",属于错发根因。
|
||||
diamondLevel: fromText.diamondLevel || fromMeta.diamondLevel || extraMeta?.diamondLevel || 0,
|
||||
brillianceCount: fromText.brillianceCount,
|
||||
rawText,
|
||||
}
|
||||
}
|
||||
|
||||
function sameSpec(orderValue: string, inventoryValue: string): boolean {
|
||||
if (!orderValue || !inventoryValue || inventoryValue === '通用') return true
|
||||
return orderValue === inventoryValue
|
||||
/**
|
||||
* 单项规格比对结论:
|
||||
* - `match` 双方都有值且一致(或库存侧是"通用"件)
|
||||
* - `mismatch` 双方都有值且冲突 → 直接排除该库存
|
||||
* - `unknown` 任一侧没值,无法确认 → **不能算精确匹配**,只能作为"部分匹配"候选
|
||||
*/
|
||||
type SpecVerdict = 'match' | 'mismatch' | 'unknown'
|
||||
|
||||
function compareSpec(orderValue: string, inventoryValue: string): SpecVerdict {
|
||||
const order = String(orderValue || '').trim()
|
||||
const inventory = String(inventoryValue || '').trim()
|
||||
if (!inventory) return 'unknown'
|
||||
if (inventory === '通用') return 'match'
|
||||
if (!order) return 'unknown'
|
||||
return order === inventory ? 'match' : 'mismatch'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,24 +157,35 @@ export function matchPeacekeeperOrderWithInventory(
|
||||
const invZone = normalizePeacekeeperServerZone(inventory.server_zone)
|
||||
const invSeries = normalizePeacekeeperSeries(inventory.series)
|
||||
|
||||
if (!sameSpec(order.system, invSystem)) return null
|
||||
if (!sameSpec(order.serverZone, invZone)) return null
|
||||
|
||||
// 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)
|
||||
if (order.series && invSeries && order.series !== invSeries) {
|
||||
return null
|
||||
}
|
||||
const verdictSystem = compareSpec(order.system, invSystem)
|
||||
if (verdictSystem === 'mismatch') return null
|
||||
const verdictZone = compareSpec(order.serverZone, invZone)
|
||||
if (verdictZone === 'mismatch') return null
|
||||
const verdictSeries = compareSpec(order.series, invSeries)
|
||||
if (verdictSeries === 'mismatch') return null
|
||||
|
||||
const invDiamond = Number(inventory.diamond_level || 1)
|
||||
const invBrilliance = Number(inventory.brilliance_count || 0)
|
||||
const orderDiamond = Number(order.diamondLevel || 0)
|
||||
const orderBrilliance = Number(order.brillianceCount || 0)
|
||||
|
||||
// 低配不能充高配:若库存钻级低于订单要求,或库存璀璨低于订单要求,直接不匹配
|
||||
if (invDiamond < order.diamondLevel || invBrilliance < order.brillianceCount) {
|
||||
return null
|
||||
}
|
||||
// 低配不能充高配:订单明确要求了钻级/璀璨时,库存低于要求直接不匹配。
|
||||
if (orderDiamond > 0 && invDiamond < orderDiamond) return null
|
||||
if (invBrilliance < orderBrilliance) return null
|
||||
|
||||
// 1. 全部匹配(Exact Match)
|
||||
if (invDiamond === order.diamondLevel && invBrilliance === order.brillianceCount) {
|
||||
// 订单侧没写清楚的字段:这些项无法确认为"一致",因此不能给精确匹配。
|
||||
const unknownFields: string[] = []
|
||||
if (verdictSystem === 'unknown') unknownFields.push('系统')
|
||||
if (verdictZone === 'unknown') unknownFields.push('区服')
|
||||
if (verdictSeries === 'unknown') unknownFields.push('系列')
|
||||
if (orderDiamond <= 0) unknownFields.push('钻级')
|
||||
|
||||
// 1. 全部匹配(Exact Match):所有关键规格都明确且一致
|
||||
if (
|
||||
unknownFields.length === 0 &&
|
||||
invDiamond === orderDiamond &&
|
||||
invBrilliance === orderBrilliance
|
||||
) {
|
||||
return {
|
||||
inventory,
|
||||
matchType: 'exact',
|
||||
@@ -172,23 +197,28 @@ export function matchPeacekeeperOrderWithInventory(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 部分匹配(Partial / Superset Match):高配充低配
|
||||
// 2. 部分匹配(Partial / Superset Match):高配充低配,或订单规格信息不全
|
||||
const reasons: string[] = []
|
||||
if (invDiamond > order.diamondLevel) {
|
||||
reasons.push(`钻级更高(${invDiamond}钻充${order.diamondLevel}钻)`)
|
||||
if (unknownFields.length > 0) {
|
||||
reasons.push(`订单规格信息不全(未写: ${unknownFields.join('、')}),需人工确认`)
|
||||
}
|
||||
if (invBrilliance > order.brillianceCount) {
|
||||
reasons.push(`多出【${invBrilliance - order.brillianceCount}璀璨】`)
|
||||
if (orderDiamond > 0 && invDiamond > orderDiamond) {
|
||||
reasons.push(`钻级更高(${invDiamond}钻充${orderDiamond}钻)`)
|
||||
}
|
||||
if (invBrilliance > orderBrilliance) {
|
||||
reasons.push(`多出【${invBrilliance - orderBrilliance}璀璨】`)
|
||||
}
|
||||
|
||||
const strictlyHigher = orderDiamond > 0 && invDiamond > orderDiamond
|
||||
|
||||
return {
|
||||
inventory,
|
||||
matchType: 'partial',
|
||||
score: invDiamond > order.diamondLevel ? 70 : 80,
|
||||
score: strictlyHigher ? 70 : 80,
|
||||
matchedSeries: invSeries || order.series,
|
||||
matchedDiamond: invDiamond,
|
||||
matchedBrilliance: invBrilliance,
|
||||
reason: `属于高配充低配:${reasons.join(',')}`,
|
||||
reason: reasons.length > 0 ? `需人工确认:${reasons.join(',')}` : '属于高配充低配',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开
|
||||
assert.ok(!sql.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('人工作废与退回复库在现货仍被进行中工单占用时不改状态', async () => {
|
||||
test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
@@ -184,14 +184,91 @@ test('人工作废与退回复库在现货仍被进行中工单占用时不改
|
||||
'2026-09-15T00:00:00.000Z',
|
||||
)
|
||||
|
||||
for (const sql of statements) {
|
||||
const inventoryUpdates = statements.filter((sql) =>
|
||||
sql.includes('UPDATE consignment_inventories'),
|
||||
)
|
||||
assert.equal(inventoryUpdates.length, 2)
|
||||
|
||||
for (const sql of inventoryUpdates) {
|
||||
// 只拦"交付中"的工单:与撤销履约白名单一致
|
||||
assert.ok(
|
||||
sql.includes('FROM work_orders wo') && sql.includes("NOT IN ('accepted', 'cancelled')"),
|
||||
`缺少进行中工单占用保护:${sql}`,
|
||||
sql.includes('FROM work_orders wo') &&
|
||||
sql.includes("IN ('in_progress', 'pending_acceptance', 'problem')"),
|
||||
`缺少交付中工单占用保护:${sql}`,
|
||||
)
|
||||
// 已验收/已取消/未指派不得算占用:否则"指派提交后写资料失败/进程崩溃"留下的
|
||||
// 「工单未开始 + 现货仍绑定」组合就没有人工修复出口。
|
||||
assert.ok(!sql.includes("NOT IN ('accepted', 'cancelled')"), `不该再按状态黑名单拦截:${sql}`)
|
||||
}
|
||||
// 退回复库不得再强制打开匹配开关(避免覆盖运营手动暂停)
|
||||
assert.ok(!statements[1]!.includes('allow_matching'))
|
||||
assert.ok(!inventoryUpdates[1]!.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('人工退回复库先锁行读出旧绑定,再连同工单寄售标记一起摘除', async () => {
|
||||
const statements: { sql: string; values: unknown[] }[] = []
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('SELECT bound_work_order_id')) {
|
||||
return { rows: [{ bound_work_order_id: 100154 }] }
|
||||
}
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 9,
|
||||
inventory_no: 'INV009',
|
||||
status: 'in_stock',
|
||||
bound_work_order_id: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { rows: [{ id: 100154 }] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await returnConsignmentInventoryToStockWithClient(
|
||||
client,
|
||||
9,
|
||||
'买家退货',
|
||||
'2026-09-15T00:00:00.000Z',
|
||||
)
|
||||
|
||||
// 1) 先加锁读旧绑定(UPDATE 之后 bound_work_order_id 就被清空了)
|
||||
assert.ok(statements[0]!.sql.includes('SELECT bound_work_order_id'))
|
||||
assert.ok(statements[0]!.sql.includes('FOR UPDATE'))
|
||||
// 2) 再回库
|
||||
assert.ok(statements[1]!.sql.includes('UPDATE consignment_inventories'))
|
||||
// 3) 最后摘掉该工单的寄售标记,并推入 consignmentHistory 留痕
|
||||
const detach = statements[2]!
|
||||
assert.ok(detach.sql.includes('UPDATE work_orders'))
|
||||
assert.ok(detach.sql.includes("material_json - 'consignment'"))
|
||||
assert.ok(detach.sql.includes('consignmentHistory'))
|
||||
assert.deepEqual(detach.values.slice(2), [100154, '9'])
|
||||
})
|
||||
|
||||
test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
const statements: { sql: string; values: unknown[] }[] = []
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return { rows: [{ id: 9, inventory_no: 'INV009', status: 'in_stock' }] }
|
||||
}
|
||||
return { rows: [{ id: 100154 }] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 100154, {
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
assert.equal(statements.length, 2)
|
||||
assert.ok(statements[1]!.sql.includes('UPDATE work_orders'))
|
||||
assert.ok(statements[1]!.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(statements[1]!.values.slice(2), [100154, '9'])
|
||||
})
|
||||
|
||||
test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => {
|
||||
@@ -1905,3 +1982,48 @@ test('打手查询条件支持提现黑名单筛选', () => {
|
||||
assert.equal(allWhere.whereClause, '')
|
||||
assert.equal(allWhere.params.length, 0)
|
||||
})
|
||||
|
||||
test('指派"部分成功"窗口:失败补偿必须撤干净(取消指派 + 还库存 + 摘寄售标记)', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const src = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
src.includes('await compensateFailedConsignmentAssignment('),
|
||||
'指派失败不能再只调 releaseConsignmentInventoryClaim',
|
||||
)
|
||||
|
||||
const body = src.slice(src.indexOf('function compensateFailedConsignmentAssignment('))
|
||||
assert.ok(body.includes('unassignAdminWorkOrder('), '补偿必须撤销已提交的指派并解冻押金')
|
||||
assert.ok(body.includes('releaseConsignmentInventoryClaim('), '补偿必须把现货放回在库池')
|
||||
assert.ok(
|
||||
body.includes('detachConsignmentMaterialFromWorkOrderWithClient('),
|
||||
'补偿必须摘掉工单上的寄售标记,避免留下"寄售履约中"的陈旧痕迹',
|
||||
)
|
||||
})
|
||||
|
||||
test('履约与指派两条路径都要过服务端规格复检(不只依赖前端弹窗)', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const src = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const callCount = src.split('assertConsignmentSpecMatchedOrder(workOrder, inventory').length - 1
|
||||
assert.equal(callCount, 2, '内部履约与指派打手都必须复检规格')
|
||||
assert.ok(src.includes('consignment_spec_mismatch'))
|
||||
assert.ok(src.includes('consignment_spec_partial_requires_confirm'))
|
||||
})
|
||||
|
||||
test('内部履约的电子凭证核销失败要写事件留痕(不能静默假成功)', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const src = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
|
||||
assert.ok(src.includes('if (!voucherConsume.ok)'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user