feat(consignment): 确认加好友后卖家发布押金不退,并封住二次冻结的 500

需求:确认加好友之后押金不退回,卖家的押金也不退回。

- 新增 forfeitConsignmentPublishDepositBySubmissionWithClient /
  ...ByWorkOrderWithClient:release 的镜像——可用余额不动、只扣冻结额,写一条负数
  deposit_deduction 流水并带上工单号与 giftDepositForfeited 标记。
  台账 outstanding 会把 deposit_deduction 计入已核销,因此之后同一提报单的
  release 与重新冻结都拿不到钱,天然幂等、退不回去。
- 挂载点:取消指派/撤单/退单/售后回退等 6 处「押金不退」判定点,以及「撤销寄售履约」的
  作废与回库分支(后者原来会把押金留着或直接退还卖家)。
- 修掉一个会 500 的边角:押金已退还/已没收时 ensureConsignmentPublishDepositFrozenBySubmissionWithClient
  会去插第二条 deposit_freeze 流水,撞上"每个提报单只允许一条冻结流水"的唯一索引
  (没收后现货回库、再被匹配后撤销/退回复库即可触发)。现显式抛
  consignment_deposit_already_settled,两个服务层都映射成 409 文案。

验证:真实库(回滚)验证没收全链路——冻结 2000 → 工单侧没收 2000 → 可用余额不变、
冻结额归零、流水为 deposit_deduction(-2000, 关联工单);没收后 release 恒为 0。
后端 420 例(417 通过,新增 5 例)全绿。
This commit is contained in:
yml2213
2026-09-16 21:27:47 +08:00
parent d6f6219fe9
commit a47c243421
8 changed files with 415 additions and 2 deletions
@@ -33,7 +33,8 @@ export function getConsignmentDepositErrorCode(error: unknown): string | null {
const code = String((error as Error & { code?: string }).code || error.message || '').trim()
if (
code === 'consignment_deposit_insufficient' ||
code === 'consignment_deposit_balance_inconsistent'
code === 'consignment_deposit_balance_inconsistent' ||
code === 'consignment_deposit_already_settled'
) {
return code
}
@@ -275,6 +276,27 @@ export async function ensureConsignmentPublishDepositFrozenBySubmissionWithClien
if (outstanding > 0) return outstanding
if (target.depositAmountFen <= 0) return 0
// 押金已经冻结过、但当前没有未核销金额:说明它已被退还或已按"确认加好友后不退"没收。
// 台账上有唯一索引 uniq_worker_wallet_ledgers_consignment_publish_freeze
// (每个提报单只允许一条 deposit_freeze 流水),直接补冻会撞索引报 500,
// 这里显式抛业务错误,让运营决定重新提报还是人工处理。
const settled = await client.query<{ id: number }>(
`
SELECT id
FROM worker_wallet_ledgers
WHERE ledger_type = 'deposit_freeze'
AND COALESCE(payload_json->>'kind', '') = 'consignment_publish'
AND COALESCE(payload_json->>'consignmentSubmissionId', '') = $1
LIMIT 1
`,
[String(input.submissionId)],
)
if (settled.rows.length > 0) {
const error = new Error('consignment_deposit_already_settled')
;(error as Error & { code?: string }).code = 'consignment_deposit_already_settled'
throw error
}
await freezeConsignmentPublishDepositWithClient(client, {
workerId: target.workerId,
amountFen: target.depositAmountFen,
@@ -294,3 +316,110 @@ export async function releaseConsignmentPublishDeposit(input: {
}): Promise<number> {
return withTransaction((client) => releaseConsignmentPublishDepositWithClient(client, input))
}
/**
* 没收寄卖发布押金(不退)。
*
* 与打手履约押金同口径:确认加好友后订单取消/退单/作废时,卖家(寄卖提报人)的发布押金不再退还。
* 实现是 release 的镜像——可用余额不动、只扣冻结额,并写一条负数 deposit_deduction 流水;
* 因为 getOutstandingConsignmentDepositFen 会把 deposit_deduction 计入已核销,
* 之后同一提报单的 release / ensure 都会拿到 outstanding=0 而变成空操作,天生幂等。
*/
export async function forfeitConsignmentPublishDepositBySubmissionWithClient(
client: PoolClient,
input: {
submissionId: number | string
workOrderId?: number | null
note: string
now: string
},
): Promise<number> {
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
if (!target) return 0
await ensureWorkerWalletWithClient(client, target.workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, target.workerId)
// 锁住钱包后再读流水,与 release 同样的串行化顺序,避免并发下重复核销。
const forfeitAmount = await getOutstandingConsignmentDepositFen(
client,
target.workerId,
input.submissionId,
)
if (forfeitAmount <= 0) return 0
const frozen = Number(wallet?.frozen_deposit_amount || 0)
if (frozen < forfeitAmount) {
const error = new Error('consignment_deposit_balance_inconsistent')
;(error as Error & { code?: string }).code = 'consignment_deposit_balance_inconsistent'
throw error
}
const nextFrozen = Math.max(0, frozen - forfeitAmount)
await client.query(
`
UPDATE worker_wallets
SET frozen_deposit_amount = $1, updated_at = $2
WHERE worker_id = $3
`,
[nextFrozen, input.now, target.workerId],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_deduction', $2, $3, $4, $5, $6, $7::jsonb, $8)
`,
[
target.workerId,
-forfeitAmount,
Number(wallet?.available_amount || 0),
nextFrozen,
input.workOrderId ?? null,
input.note,
toJsonString({
kind: 'consignment_publish',
consignmentSubmissionId: String(input.submissionId),
giftDepositForfeited: true,
}),
input.now,
],
)
return forfeitAmount
}
/**
* 工单侧入口:按工单 material_json 里的寄卖现货找到卖家的提报单,没收其发布押金。
* 不是寄卖履约单、或押金已释放/已没收时返回 0(幂等)。
*/
export async function forfeitConsignmentPublishDepositByWorkOrderWithClient(
client: PoolClient,
input: {
workOrderId: number | string
note: string
now: string
},
): Promise<number> {
const res = await client.query<{ submission_id: number | null }>(
`
SELECT inv.source_submission_id AS submission_id
FROM work_orders wo
JOIN consignment_inventories inv
ON inv.id = CASE
WHEN (wo.material_json->'consignment'->>'inventoryId') ~ '^[0-9]+$'
THEN (wo.material_json->'consignment'->>'inventoryId')::int
END
WHERE wo.id = $1
`,
[input.workOrderId],
)
const submissionId = res.rows[0]?.submission_id
if (!submissionId) return 0
return forfeitConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId,
workOrderId: Number(input.workOrderId),
note: input.note,
now: input.now,
})
}
@@ -1,5 +1,6 @@
import { withTransaction } from '../../db/client.js'
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
@@ -133,6 +134,15 @@ export async function unassignWorkOrder(input: {
}
const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder)
// 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理,
// 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。
if (forfeitGiftDeposit) {
await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
workOrderId: Number(workOrder.id),
note: '已确认加好友,取消/脱离工单没收寄卖发布押金',
now: input.now,
})
}
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
const releaseAmount = Math.min(
@@ -245,6 +255,15 @@ export async function returnAssignedWorkOrderToHall(input: {
}
const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder)
// 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理,
// 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。
if (forfeitGiftDeposit) {
await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
workOrderId: Number(workOrder.id),
note: '已确认加好友,取消/脱离工单没收寄卖发布押金',
now: input.now,
})
}
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
const releaseAmount = Math.min(
@@ -360,6 +379,15 @@ export async function cancelAssignedWorkOrder(input: {
}
const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder)
// 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理,
// 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。
if (forfeitGiftDeposit) {
await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
workOrderId: Number(workOrder.id),
note: '已确认加好友,取消/脱离工单没收寄卖发布押金',
now: input.now,
})
}
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
const releaseAmount = Math.min(
@@ -1,5 +1,6 @@
import { withTransaction } from '../../db/client.js'
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
@@ -61,6 +62,15 @@ export async function resolveProblemWorkOrder(input: {
// 赠送单已确认加好友后不再退押金:释放类处置转为扣除,事件类型按实际资金动作记录。
const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder)
// 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理,
// 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。
if (forfeitGiftDeposit) {
await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
workOrderId: Number(workOrder.id),
note: '已确认加好友,取消/脱离工单没收寄卖发布押金',
now: input.now,
})
}
const releaseActions = ['reopen', 'cancel_release']
const shouldReleaseDeposit = releaseActions.includes(input.action) && !forfeitGiftDeposit
const shouldDeductDeposit =
@@ -238,6 +248,15 @@ export async function settleOverdueWorkOrder(input: {
// 赠送单已确认加好友后不再退押金:释放类处置转为扣除,事件类型按实际资金动作记录。
const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder)
// 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理,
// 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。
if (forfeitGiftDeposit) {
await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
workOrderId: Number(workOrder.id),
note: '已确认加好友,取消/脱离工单没收寄卖发布押金',
now: input.now,
})
}
const releasePolicies = ['reopen', 'cancel_release']
const shouldReleaseDeposit = releasePolicies.includes(input.policy) && !forfeitGiftDeposit
const shouldDeductDeposit =
@@ -2,6 +2,7 @@ import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
import {
ensureWorkerWalletWithClient,
@@ -462,6 +463,15 @@ export async function reviewWorkerCancelRequest(input: {
let releasedDepositAmount = 0
// 赠送单与拼单互斥,拼单份额分支不会命中赠送单;整单分支按确认加好友判定扣押金。
const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder)
// 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理,
// 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。
if (forfeitGiftDeposit) {
await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
workOrderId: Number(workOrder.id),
note: '已确认加好友,取消/脱离工单没收寄卖发布押金',
now: input.now,
})
}
if (request.work_order_share_id) {
const shareResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,