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:
yml2213
2026-09-17 10:21:38 +08:00
parent a47c243421
commit d158cc91bc
12 changed files with 577 additions and 29 deletions
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isDepositHandlingOverridden,
normalizeConsignmentDepositHandling,
normalizeWorkerDepositHandling,
resolveConsignmentDepositAction,
resolveWorkerDepositMode,
} from './consignment-deposit-policy.js'
test('押金处置选项归一:非法值一律退回按规则自动', () => {
assert.equal(normalizeWorkerDepositHandling('refund'), 'refund')
assert.equal(normalizeWorkerDepositHandling('forfeit'), 'forfeit')
assert.equal(normalizeWorkerDepositHandling('auto'), 'auto')
assert.equal(normalizeWorkerDepositHandling('随便写的'), 'auto')
assert.equal(normalizeWorkerDepositHandling(undefined), 'auto')
assert.equal(normalizeConsignmentDepositHandling('keep_frozen'), 'keep_frozen')
assert.equal(normalizeConsignmentDepositHandling('forfeit'), 'forfeit')
assert.equal(normalizeConsignmentDepositHandling(null), 'auto')
})
test('打手履约押金:自动规则跟随"是否已确认加好友"', () => {
// 已确认加好友 → 自动没收
assert.equal(resolveWorkerDepositMode('auto', true), 'forfeit')
// 没确认加好友 → 自动全额退还
assert.equal(resolveWorkerDepositMode('auto', false), 'release')
assert.equal(resolveWorkerDepositMode(undefined, false), 'release')
})
test('打手履约押金:运营显式选择可以推翻自动规则', () => {
// 已确认加好友仍然选择退还
assert.equal(resolveWorkerDepositMode('refund', true), 'release')
// 没确认加好友也选择扣除
assert.equal(resolveWorkerDepositMode('forfeit', false), 'forfeit')
})
test('卖家发布押金:自动规则 = 已确认加好友没收 / 作废退还 / 回库保持冻结', () => {
assert.equal(
resolveConsignmentDepositAction('auto', { autoForfeit: true, archive: true }),
'forfeit',
)
assert.equal(
resolveConsignmentDepositAction('auto', { autoForfeit: true, archive: false }),
'forfeit',
)
assert.equal(
resolveConsignmentDepositAction('auto', { autoForfeit: false, archive: true }),
'release',
)
assert.equal(
resolveConsignmentDepositAction('auto', { autoForfeit: false, archive: false }),
'keep_frozen',
)
})
test('卖家发布押金:显式"保留冻结"不会去退也不会去打款流水', () => {
assert.equal(
resolveConsignmentDepositAction('keep_frozen', { autoForfeit: true, archive: true }),
'keep_frozen',
)
assert.equal(
resolveConsignmentDepositAction('forfeit', { autoForfeit: false, archive: true }),
'forfeit',
)
})
test('覆盖押金处置时视为需要留原因', () => {
assert.equal(isDepositHandlingOverridden({}), false)
assert.equal(isDepositHandlingOverridden({ workerDeposit: 'auto' }), false)
assert.equal(isDepositHandlingOverridden({ workerDeposit: 'refund' }), true)
assert.equal(isDepositHandlingOverridden({ consignmentDeposit: 'keep_frozen' }), true)
})
@@ -0,0 +1,89 @@
/**
* 寄卖撤销时的押金处置策略。
*
* 撤销寄售履约会牵动两笔钱:接单打手的履约押金、寄卖卖家的发布押金。
* 默认都由规则自动判定(`auto`),运营也可以显式覆盖;覆盖时必须填撤销原因,
* 便于事后对账和追责。
*/
export const WORKER_DEPOSIT_HANDLING = {
/** 按规则自动:已确认加好友则没收,否则全额退还 */
AUTO: 'auto',
REFUND: 'refund',
FORFEIT: 'forfeit',
} as const
export const CONSIGNMENT_DEPOSIT_HANDLING = {
/** 按规则自动:已确认加好友则没收;否则作废时退还、回库时继续冻结 */
AUTO: 'auto',
/** 保持冻结(既不退还也不没收,留给后续人工处理) */
KEEP_FROZEN: 'keep_frozen',
FORFEIT: 'forfeit',
} as const
export type WorkerDepositHandling =
(typeof WORKER_DEPOSIT_HANDLING)[keyof typeof WORKER_DEPOSIT_HANDLING]
export type ConsignmentDepositHandling =
(typeof CONSIGNMENT_DEPOSIT_HANDLING)[keyof typeof CONSIGNMENT_DEPOSIT_HANDLING]
/** 打手履约押金的最终处置动作 */
export type WorkerDepositMode = 'release' | 'forfeit'
/** 卖家发布押金的最终处置动作;keep_frozen 表示本次不动这笔押金 */
export type ConsignmentDepositAction = 'release' | 'forfeit' | 'keep_frozen'
export function normalizeWorkerDepositHandling(value: unknown): WorkerDepositHandling {
const raw = String(value ?? '').trim()
if (raw === WORKER_DEPOSIT_HANDLING.REFUND) return WORKER_DEPOSIT_HANDLING.REFUND
if (raw === WORKER_DEPOSIT_HANDLING.FORFEIT) return WORKER_DEPOSIT_HANDLING.FORFEIT
return WORKER_DEPOSIT_HANDLING.AUTO
}
export function normalizeConsignmentDepositHandling(value: unknown): ConsignmentDepositHandling {
const raw = String(value ?? '').trim()
if (raw === CONSIGNMENT_DEPOSIT_HANDLING.KEEP_FROZEN) {
return CONSIGNMENT_DEPOSIT_HANDLING.KEEP_FROZEN
}
if (raw === CONSIGNMENT_DEPOSIT_HANDLING.FORFEIT) return CONSIGNMENT_DEPOSIT_HANDLING.FORFEIT
return CONSIGNMENT_DEPOSIT_HANDLING.AUTO
}
/**
* 打手履约押金:`auto` 时跟随「是否已确认加好友」——确认加好友意味着货已不可逆,
* 取消/脱离由打手侧承担损失。
*/
export function resolveWorkerDepositMode(
handling: unknown,
autoForfeit: boolean,
): WorkerDepositMode {
const normalized = normalizeWorkerDepositHandling(handling)
if (normalized === WORKER_DEPOSIT_HANDLING.REFUND) return 'release'
if (normalized === WORKER_DEPOSIT_HANDLING.FORFEIT) return 'forfeit'
return autoForfeit ? 'forfeit' : 'release'
}
/**
* 卖家发布押金:`auto` 时——已确认加好友一律没收;否则按现货去向定,
* 作废(货不再挂卖)退还,回库(继续挂卖)保持冻结。
*/
export function resolveConsignmentDepositAction(
handling: unknown,
input: { autoForfeit: boolean; archive: boolean },
): ConsignmentDepositAction {
const normalized = normalizeConsignmentDepositHandling(handling)
if (normalized === CONSIGNMENT_DEPOSIT_HANDLING.KEEP_FROZEN) return 'keep_frozen'
if (normalized === CONSIGNMENT_DEPOSIT_HANDLING.FORFEIT) return 'forfeit'
if (input.autoForfeit) return 'forfeit'
return input.archive ? 'release' : 'keep_frozen'
}
/** 显式覆盖了押金处置(非 auto)时必须留原因。 */
export function isDepositHandlingOverridden(input: {
workerDeposit?: unknown
consignmentDeposit?: unknown
}): boolean {
return (
normalizeWorkerDepositHandling(input.workerDeposit) !== WORKER_DEPOSIT_HANDLING.AUTO ||
normalizeConsignmentDepositHandling(input.consignmentDeposit) !==
CONSIGNMENT_DEPOSIT_HANDLING.AUTO
)
}