fix(consignment): 撤销寄售履约释放占用,修复现货回库后仍被占用

撤销寄售履约旧实现只更新 consignment_inventories(status/bound_*),
不释放 consignment_inventory_allocations,也不回补 remaining_quantity。
结果:现货状态回到在库、余量停在 0、占用行停在 reserved ——
库存永远匹配不到,卖家端还一直显示进行中(工单 217368 / 库存 1584)。

- 新增 releaseConsignmentAllocationsForRevokedFulfillmentWithClient:
  回库分支复用 releaseAllocationRows(回补余量、consumed→in_stock、解绑/改绑);
  报废分支只关闭占用行、不回补余量,避免作废货重新进池。
- 撤销事务在库存更新后调用释放,并重读库存让接口返回真实余量。
- 补 5 个用例:回库回补、其它占用改绑、报废不回补、空占用幂等、
  以及源码断言锁定撤销必须释放占用。
This commit is contained in:
2026-11-15 22:39:18 +08:00
parent 8181ef50a5
commit 24d25aa77a
4 changed files with 211 additions and 1 deletions
@@ -254,6 +254,64 @@ export async function restoreConsignmentOriginalRewardWithClient(
return Boolean(result.rows[0])
}
/**
* 「撤销寄售履约」释放被该工单占用的现货。
*
* 占用模型下 remaining_quantity 与卖家端「进行中」都以 allocation 为准:撤销以前只改
* consignment_inventories(status/bound_*),占用行停在 reserved、余量停在 0,
* 现货回库却永远匹配不到,卖家端还一直显示占用(工单 217368 / 库存 1584)。
*
* 回库分支复用 releaseAllocationRows(回补余量、consumed→in_stock、解绑/改绑);
* 报废分支只关闭占用行、不回补余量,避免作废货重新进池被二次出售。
*/
export async function releaseConsignmentAllocationsForRevokedFulfillmentWithClient(
client: PoolClient,
input: {
workOrderId: number
inventoryId: number
/** true = 运营选择「报废该现货」 */
archive: boolean
now: string
},
): Promise<number> {
const result = await client.query<ConsignmentAllocationRow>(
`SELECT * FROM consignment_inventory_allocations
WHERE work_order_id = $1 AND inventory_id = $2 AND status IN ('reserved', 'consumed')
FOR UPDATE`,
[input.workOrderId, input.inventoryId],
)
if (result.rows.length === 0) return 0
if (!input.archive) {
await releaseAllocationRows(client, result.rows, input.now)
return result.rows.length
}
// 报废:占用必须关掉(否则卖家端一直显示进行中),但余量不能回补到作废货上。
await client.query(
`UPDATE consignment_inventory_allocations
SET status = 'released', updated_at = $1
WHERE work_order_id = $2 AND inventory_id = $3 AND status IN ('reserved', 'consumed')`,
[input.now, input.workOrderId, input.inventoryId],
)
// 共享库存还有别的工单在履约时不动余量,只释放本单。
const active = await client.query<{ count: number }>(
`SELECT COUNT(*)::integer AS count
FROM consignment_inventory_allocations
WHERE inventory_id = $1 AND status IN ('reserved', 'consumed')`,
[input.inventoryId],
)
if (Number(active.rows[0]?.count || 0) === 0) {
await client.query(
`UPDATE consignment_inventories
SET remaining_quantity = 0, allocation_status = '待安排', updated_at = $1
WHERE id = $2`,
[input.now, input.inventoryId],
)
}
return result.rows.length
}
/** 释放整个工单占用的全部库存(整单取消 / 退回大厅 / 撤销履约)。 */
export async function releaseConsignmentAllocationsForWorkOrderWithClient(
client: PoolClient,
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { PoolClient } from 'pg'
import { releaseConsignmentAllocationsForRevokedFulfillmentWithClient } from './consignment-allocation-repo.js'
type Statement = { sql: string; values: unknown[] }
function fakeClient(options: {
allocations: Array<Record<string, unknown>>
activeAfterRelease?: number
}) {
const statements: Statement[] = []
const client = {
query: async (sql: string, values?: unknown[]) => {
statements.push({ sql, values: values || [] })
if (sql.includes('FROM consignment_inventory_allocations') && sql.includes('FOR UPDATE')) {
return { rows: options.allocations }
}
if (sql.includes('COUNT(*)::integer AS count')) {
return { rows: [{ count: options.activeAfterRelease ?? 0 }] }
}
return { rows: [] }
},
} as unknown as PoolClient
return { client, statements }
}
const allocationRow = {
id: 38,
inventory_id: 1584,
work_order_id: 217368,
work_order_share_id: null,
quantity: 1,
unit_price_fen: 19500,
status: 'reserved',
created_at: '2026-11-15T06:39:16.805Z',
updated_at: '2026-11-15T06:39:16.805Z',
}
test('撤销回库:按占用行回补余量并释放占用,避免现货永久占库', async () => {
const { client, statements } = fakeClient({ allocations: [allocationRow] })
const released = await releaseConsignmentAllocationsForRevokedFulfillmentWithClient(client, {
workOrderId: 217368,
inventoryId: 1584,
archive: false,
now: '2026-11-15T08:32:44.000Z',
})
assert.equal(released, 1)
// 真实故障:撤销只改库存状态,余量停在 0,匹配一律被 remaining_quantity > 0 挡掉。
const restore = statements.find((item) =>
item.sql.includes('remaining_quantity = remaining_quantity + $1'),
)
assert.ok(restore, '回库必须回补 remaining_quantity,否则现货仍是 0 件可匹配')
assert.deepEqual(restore!.values, [1, '2026-11-15T08:32:44.000Z', 1584])
const releaseRow = statements.find((item) => item.sql.includes("SET status = 'released'"))
assert.ok(releaseRow, '占用行必须置 released,否则卖家端一直显示进行中')
assert.deepEqual(releaseRow!.values, ['2026-11-15T08:32:44.000Z', 38])
const clearBinding = statements.find((item) => item.sql.includes('bound_order_id = NULL'))
assert.ok(clearBinding, '没有其它在途占用时必须清空卖家端绑定')
})
test('撤销回库:还有其它在途占用时只释放本单,绑定改指向剩下的工单', async () => {
const { client, statements } = fakeClient({ allocations: [allocationRow], activeAfterRelease: 1 })
await releaseConsignmentAllocationsForRevokedFulfillmentWithClient(client, {
workOrderId: 217368,
inventoryId: 1584,
archive: false,
now: '2026-11-15T08:32:44.000Z',
})
assert.ok(
!statements.some((item) => item.sql.includes('bound_order_id = NULL')),
'还有其它在途占用时不能把卖家端绑定清空',
)
assert.ok(
statements.some((item) => item.sql.includes('FROM work_orders wo')),
'还有其它在途占用时必须把绑定改指向剩下的最新工单',
)
})
test('撤销报废:只关闭占用,不回补余量,避免作废货被二次出售', async () => {
const { client, statements } = fakeClient({ allocations: [allocationRow] })
const released = await releaseConsignmentAllocationsForRevokedFulfillmentWithClient(client, {
workOrderId: 217368,
inventoryId: 1584,
archive: true,
now: '2026-11-15T08:32:44.000Z',
})
assert.equal(released, 1)
assert.ok(
!statements.some((item) => item.sql.includes('remaining_quantity = remaining_quantity + $1')),
'报废现货不能回补余量',
)
const zero = statements.find((item) => item.sql.includes('SET remaining_quantity = 0'))
assert.ok(zero, '报废后余量必须归零,避免作废货显示可匹配数量')
assert.ok(statements.some((item) => item.sql.includes("SET status = 'released'")))
})
test('没有可释放的占用行时保持幂等,不写库存', async () => {
const { client, statements } = fakeClient({ allocations: [] })
const released = await releaseConsignmentAllocationsForRevokedFulfillmentWithClient(client, {
workOrderId: 217368,
inventoryId: 1584,
archive: false,
now: '2026-11-15T08:32:44.000Z',
})
assert.equal(released, 0)
assert.equal(statements.length, 1)
})
@@ -40,6 +40,7 @@ import type {
} from '../../domain/consignment-deposit-policy.js'
import { settleWorkOrderDepositWithClient } from '../../repositories/worker-platform/work-order-deposit-repo.js'
import { buildConsignmentRevokeInventoryUpdate } from '../../repositories/worker-platform/consignment-revoke-inventory-sql.js'
import { releaseConsignmentAllocationsForRevokedFulfillmentWithClient } from '../../repositories/worker-platform/consignment-allocation-repo.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
@@ -1083,7 +1084,7 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
const lockedMaterial = safeParseJson(lockedOrder.material_json)
resolveRevocableConsignmentInventoryId(safeParseJson(lockedMaterial.consignment))
let inv: ConsignmentInventoryRow | null = null
let inv: ConsignmentInventoryRow | null
// 1. 处理关联库存解绑与状态流转。
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
@@ -1112,6 +1113,22 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
})
}
// 1.1 释放占用记录:remaining_quantity 与卖家端「进行中」都以 allocation 为准。
// 旧实现只改 consignment_inventories(status/bound_*),占用行停在 reserved、余量停在 0,
// 现货回库却永远匹配不到,卖家端还一直显示占用(工单 217368 / 库存 1584)。
await releaseConsignmentAllocationsForRevokedFulfillmentWithClient(client, {
workOrderId: Number(lockedOrder.id),
inventoryId: Number(inv.id),
archive: archiveOnly,
now,
})
// 释放后余量/状态已变,重读一次让接口返回真实值,前端不用再等下一次刷新。
const refreshedInv = await client.query<ConsignmentInventoryRow>(
'SELECT * FROM consignment_inventories WHERE id = $1',
[inv.id],
)
inv = refreshedInv.rows[0] || inv
// 卖家发布押金按选择走:auto 时回库保持冻结、作废退还;只有显式「扣除没收」才扣。
// 买家退款不因已确认加好友而没收卖家押金。
const consignmentDepositAction = resolveConsignmentDepositAction(input.consignmentDeposit, {
@@ -32,6 +32,21 @@ test('撤销寄售履约的现货去向和押金都按运营选择,已确认
assert.ok(source.includes('inventoryArchived: archiveOnly'))
})
test('撤销寄售履约必须释放占用记录,不能只把库存状态改回在库', async () => {
const source = await readServiceSource()
const start = source.indexOf('export async function revokeWorkOrderConsignmentFulfillment(')
assert.ok(start > 0, '未找到撤销寄售履约函数')
const body = source.slice(start, source.indexOf('\n}\n', start))
const inventoryUpdateIndex = body.indexOf('buildConsignmentRevokeInventoryUpdate(')
const releaseIndex = body.indexOf('releaseConsignmentAllocationsForRevokedFulfillmentWithClient(')
assert.ok(inventoryUpdateIndex > 0, '撤销必须先解绑库存')
assert.ok(
releaseIndex > inventoryUpdateIndex,
'解绑库存后必须释放 allocation:remaining_quantity 与卖家端「进行中」都以它为准,只改库存会让现货永远匹配不到',
)
})
test('已确认加好友后作废现货:必须关掉匹配开关并留痕', async () => {
const source = await readServiceSource()
// 解绑 SQL 已抽到 consignment-revoke-inventory-sql.ts(参数个数由该模块自己的单测锁定)