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:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user