feat(consignment): 确认加好友后现货作废不回池,撤销履约押金可选并结算打手押金
现货口径(严):已确认加好友 = 买家好友已加上、货随时可能送出去,回池会被下一个工单 再次占用(等于二次出货)。收口在 releaseConsignmentInventoryBindingForWorkOrderWithClient: 解绑前读工单 acceptance_mode/friend_added_at,命中则 archived + allow_matching=FALSE + 留痕, 否则仍回池。所有解绑路径自动继承,发单系统接口未动。 撤销寄售履约(寄卖专属入口)支持两笔押金各自可选,默认按规则自动: - 打手履约押金:auto / refund / forfeit - 卖家发布押金:auto / keep_frozen / forfeit 策略抽成 domain/consignment-deposit-policy.ts 纯函数;覆盖时原因照旧必填,选项与金额写进 工单事件便于对账。 顺带修掉两个 bug: 1. 撤销寄售履约从不结算打手履约押金——只清 assigned_worker_id、只处理卖家押金, worker_deposit 单撤销后打手押金永久冻结。现按选项结算(退还/没收)。 2. detachConsignmentMaterialFromWorkOrderWithClient 里 $1 既作 $1::text(detachedAt) 又赋值给 updated_at,Postgres 整体推断成 text → 42804。凡解绑现货摘寄售标记的路径 (取消指派/撤单/退单/售后/撤销履约/人工回库)在真实库上必然 500,假 client 单测抓不到。 已显式 ::timestamptz,并补类级静态护栏扫同类写法。 验证:真实库(全回滚)——已确认加好友解绑 → archived/allow_matching=false; 未确认 → 仍 in_stock;打手押金冻结 3000 → 退还 3000(重复结算 0)→ 再冻 2500 → 没收 2500 (可用余额不变、冻结归零、流水 deposit_deduction)。后端 432 例(新增 5 例)全绿。
This commit is contained in:
@@ -27,6 +27,17 @@ import {
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
normalizeConsignmentDepositHandling,
|
||||
normalizeWorkerDepositHandling,
|
||||
resolveConsignmentDepositAction,
|
||||
resolveWorkerDepositMode,
|
||||
} from '../../domain/consignment-deposit-policy.js'
|
||||
import type {
|
||||
ConsignmentDepositHandling,
|
||||
WorkerDepositHandling,
|
||||
} from '../../domain/consignment-deposit-policy.js'
|
||||
import { settleWorkOrderDepositWithClient } from '../../repositories/worker-platform/work-order-deposit-repo.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
@@ -610,6 +621,10 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
workOrderId: number | string
|
||||
action: 'reopen' | 'archive_inventory' | 'cancel_order'
|
||||
reason: string
|
||||
/** 打手履约押金处置:auto(按规则)/ refund(全额退还)/ forfeit(全额扣除) */
|
||||
workerDeposit?: WorkerDepositHandling | undefined
|
||||
/** 卖家发布押金处置:auto(按规则)/ keep_frozen(保留冻结)/ forfeit(扣除没收) */
|
||||
consignmentDeposit?: ConsignmentDepositHandling | undefined
|
||||
actorName: string
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
@@ -658,9 +673,12 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
// 1. 处理关联库存解绑与状态流转。
|
||||
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
|
||||
// 造成同一件现货被多张工单履约。
|
||||
const invSql =
|
||||
input.action === 'archive_inventory'
|
||||
? `
|
||||
// 已确认加好友 = 买家好友已加上、货随时可能送出去:不论运营选的是回库还是作废,
|
||||
// 都只能作废下架,否则这件现货会回到在库池被下一个工单再次占用(二次出货)。
|
||||
const autoForfeit = isWorkOrderGiftDepositForfeitable(lockedOrder)
|
||||
const archiveOnly = autoForfeit || input.action === 'archive_inventory'
|
||||
const invSql = archiveOnly
|
||||
? `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
@@ -669,12 +687,12 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
remark = remark || ' [交付异常作废: ' || $1 || ']',
|
||||
remark = remark || ($5::text || $1 || ']'),
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
|
||||
RETURNING *
|
||||
`
|
||||
: `
|
||||
: `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
@@ -695,6 +713,7 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
now,
|
||||
inventoryId,
|
||||
lockedOrder.id,
|
||||
autoForfeit ? ' [已确认加好友作废下架: ' : ' [交付异常作废: ',
|
||||
])
|
||||
inv = invRes.rows[0] || null
|
||||
if (!inv) {
|
||||
@@ -704,34 +723,50 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 卖家发布押金:默认走规则(已确认加好友→没收;作废→退还;回库→保持冻结),
|
||||
// 运营可以显式覆盖成"保留冻结"或"扣除没收"。
|
||||
const consignmentDepositAction = resolveConsignmentDepositAction(input.consignmentDeposit, {
|
||||
autoForfeit,
|
||||
archive: archiveOnly,
|
||||
})
|
||||
const submissionId = consignmentInfo.sourceSubmissionId || inv.source_submission_id
|
||||
if (submissionId) {
|
||||
// 已确认加好友的单子,卖家发布押金不再退还:撤销回库/取消/作废一律走没收,
|
||||
// 不能再走"退还押金"或"重新冻结"(后者会把押金留着等下次退还)。
|
||||
if (isWorkOrderGiftDepositForfeitable(lockedOrder)) {
|
||||
if (submissionId && consignmentDepositAction !== 'keep_frozen') {
|
||||
if (consignmentDepositAction === 'forfeit') {
|
||||
await forfeitConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
workOrderId: Number(lockedOrder.id),
|
||||
note: `已确认加好友,寄卖押金不退(${
|
||||
input.action === 'archive_inventory' ? '履约作废' : '撤销回库'
|
||||
})${inv.inventory_no || submissionId}`,
|
||||
note: `寄卖押金没收(${archiveOnly ? '履约作废' : '撤销回库'})${
|
||||
inv.inventory_no || submissionId
|
||||
}`,
|
||||
now,
|
||||
})
|
||||
} else if (input.action === 'archive_inventory') {
|
||||
} else {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖履约作废退还押金 ${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
} else {
|
||||
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖撤销回库重新冻结押金 ${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打手履约押金:撤销寄售履约会把工单打回未指派/已取消,必须同时结算打手押金,
|
||||
// 否则这笔钱会一直冻着(以前这里只清 assigned_worker_id,押金没有出口)。
|
||||
const revokedWorkerId = Number(lockedOrder.assigned_worker_id || 0)
|
||||
const workerDepositMode = resolveWorkerDepositMode(input.workerDeposit, autoForfeit)
|
||||
let workerDepositAmount = 0
|
||||
if (revokedWorkerId > 0) {
|
||||
workerDepositAmount = await settleWorkOrderDepositWithClient(client, {
|
||||
workOrderId: Number(lockedOrder.id),
|
||||
workerId: revokedWorkerId,
|
||||
mode: workerDepositMode,
|
||||
note:
|
||||
workerDepositMode === 'forfeit'
|
||||
? '撤销寄售履约扣除履约押金(押金不退)'
|
||||
: '撤销寄售履约退还履约押金',
|
||||
now,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 处理工单状态流转
|
||||
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
|
||||
if (input.action === 'cancel_order') {
|
||||
@@ -800,6 +835,14 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
action: input.action,
|
||||
reason,
|
||||
inventoryId,
|
||||
// 押金去向要留痕:运营覆盖了规则时能对账、能追责
|
||||
depositHandling: {
|
||||
workerDeposit: normalizeWorkerDepositHandling(input.workerDeposit),
|
||||
consignmentDeposit: normalizeConsignmentDepositHandling(input.consignmentDeposit),
|
||||
},
|
||||
workerDeposit: { mode: workerDepositMode, amount: workerDepositAmount },
|
||||
consignmentDeposit: { action: consignmentDepositAction },
|
||||
inventoryArchived: archiveOnly,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
async function readServiceSource() {
|
||||
return readFile(new URL('./consignment-fulfillment-service.ts', import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
test('撤销寄售履约必须结算打手履约押金,不能只清 assigned_worker_id', async () => {
|
||||
const source = await readServiceSource()
|
||||
// 曾经的漏洞:撤销只清 assigned_worker_id、只处理卖家发布押金,
|
||||
// 打手的履约押金没有任何出口,会一直冻在钱包里。
|
||||
assert.ok(source.includes('settleWorkOrderDepositWithClient('))
|
||||
assert.ok(source.includes('resolveWorkerDepositMode('))
|
||||
assert.ok(source.includes('revokedWorkerId'))
|
||||
assert.ok(source.includes('撤销寄售履约退还履约押金'))
|
||||
assert.ok(source.includes('撤销寄售履约扣除履约押金'))
|
||||
})
|
||||
|
||||
test('撤销寄售履约的现货去向由押金规则与运营选择共同决定', async () => {
|
||||
const source = await readServiceSource()
|
||||
assert.ok(source.includes('const autoForfeit = isWorkOrderGiftDepositForfeitable(lockedOrder)'))
|
||||
assert.ok(
|
||||
source.includes("const archiveOnly = autoForfeit || input.action === 'archive_inventory'"),
|
||||
)
|
||||
assert.ok(source.includes('resolveConsignmentDepositAction(input.consignmentDeposit'))
|
||||
// 押金去向要写进工单事件,方便对账
|
||||
assert.ok(
|
||||
source.includes('workerDeposit: { mode: workerDepositMode, amount: workerDepositAmount }'),
|
||||
)
|
||||
assert.ok(source.includes('consignmentDeposit: { action: consignmentDepositAction }'))
|
||||
assert.ok(source.includes('inventoryArchived: archiveOnly'))
|
||||
})
|
||||
|
||||
test('已确认加好友后作废现货:必须关掉匹配开关并留痕', async () => {
|
||||
const source = await readServiceSource()
|
||||
assert.ok(source.includes('allow_matching = FALSE'))
|
||||
assert.ok(source.includes('已确认加好友作废下架'))
|
||||
})
|
||||
@@ -159,6 +159,9 @@ test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
if (sql.includes('SELECT acceptance_mode')) {
|
||||
return { rows: [{ acceptance_mode: 'standard', friend_added_at: null }] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
@@ -167,12 +170,46 @@ test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单取消订单',
|
||||
})
|
||||
const sql = statements[0]!
|
||||
const sql = statements.find((item) => item.includes('UPDATE consignment_inventories'))!
|
||||
assert.ok(sql.includes('WHERE bound_work_order_id = $2'))
|
||||
assert.ok(sql.includes("status = 'consumed'"))
|
||||
assert.ok(sql.includes("status = 'in_stock'"))
|
||||
assert.ok(!sql.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('已确认加好友后解绑:现货作废下架,绝不回池被下一个工单占用', async () => {
|
||||
const statements: { sql: string; values: unknown[] }[] = []
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('SELECT acceptance_mode')) {
|
||||
return {
|
||||
rows: [{ acceptance_mode: 'friend_gift', friend_added_at: '2026-09-15T00:00:00.000Z' }],
|
||||
}
|
||||
}
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return { rows: [{ id: 9, inventory_no: 'INV009', status: 'archived' }] }
|
||||
}
|
||||
return { rows: [{ id: 100154 }] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const released = await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 100154, {
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
const invStatement = statements.find((item) =>
|
||||
item.sql.includes('UPDATE consignment_inventories'),
|
||||
)!
|
||||
// 作废下架而不是回库:status 必须落在 archived,并且顺手关掉匹配开关
|
||||
assert.ok(invStatement.sql.includes("status = 'archived'"))
|
||||
assert.ok(invStatement.sql.includes('allow_matching = FALSE'))
|
||||
assert.ok(!invStatement.sql.includes("status = 'in_stock'"))
|
||||
assert.ok(invStatement.sql.includes('已确认加好友,作废下架'))
|
||||
assert.equal(released?.status, 'archived')
|
||||
})
|
||||
|
||||
test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
@@ -259,6 +296,10 @@ test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('SELECT acceptance_mode')) {
|
||||
// 未确认加好友的普通单:现货仍然回池
|
||||
return { rows: [{ acceptance_mode: 'standard', friend_added_at: null }] }
|
||||
}
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return { rows: [{ id: 9, inventory_no: 'INV009', status: 'in_stock' }] }
|
||||
}
|
||||
@@ -271,10 +312,14 @@ test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
assert.equal(statements.length, 2)
|
||||
assert.ok(statements[1]!.sql.includes('UPDATE work_orders'))
|
||||
assert.ok(statements[1]!.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(statements[1]!.values.slice(2), [100154, '9'])
|
||||
const detach = statements.find((item) => item.sql.includes('UPDATE work_orders'))!
|
||||
assert.ok(detach.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(detach.values.slice(2), [100154, '9'])
|
||||
// 摘标记必须发生在现货改状态之后,否则工单会继续显示已失效的寄售履约
|
||||
const invIndex = statements.findIndex((item) =>
|
||||
item.sql.includes('UPDATE consignment_inventories'),
|
||||
)
|
||||
assert.ok(statements.indexOf(detach) > invIndex)
|
||||
})
|
||||
|
||||
test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => {
|
||||
|
||||
Reference in New Issue
Block a user