diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts index 3efc2686..fef6d737 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts @@ -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 { + const result = await client.query( + `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, diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-revoke-release.test.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-revoke-release.test.ts new file mode 100644 index 00000000..84fe0d04 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-revoke-release.test.ts @@ -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> + 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) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 584ebf18..e4133265 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [inv.id], + ) + inv = refreshedInv.rows[0] || inv + // 卖家发布押金按选择走:auto 时回库保持冻结、作废退还;只有显式「扣除没收」才扣。 // 买家退款不因已确认加好友而没收卖家押金。 const consignmentDepositAction = resolveConsignmentDepositAction(input.consignmentDeposit, { diff --git a/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts b/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts index c6a8ec43..fdea70e6 100644 --- a/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts @@ -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(参数个数由该模块自己的单测锁定)