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
|
||||
}
|
||||
|
||||
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
||||
|
||||
Reference in New Issue
Block a user