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:
yml2213
2026-09-16 20:12:14 +08:00
parent 632dddc4c3
commit 5d11bf2012
10 changed files with 727 additions and 70 deletions
@@ -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
}
/** 退回复库(将已出库物品恢复为在库待匹配) */