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:
@@ -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,
|
||||
|
||||
+120
@@ -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(参数个数由该模块自己的单测锁定)
|
||||
|
||||
Reference in New Issue
Block a user