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`,
|
||||
|
||||
@@ -46,6 +46,12 @@ function throwConsignmentDepositHttpError(error: unknown, restock: boolean): nev
|
||||
errorCode: 'consignment_deposit_balance_inconsistent',
|
||||
})
|
||||
}
|
||||
if (code === 'consignment_deposit_already_settled') {
|
||||
throw createHttpError(
|
||||
'该提报单的发布押金已退还或已被没收,无法自动重新冻结;请让卖家重新提报,或人工核对押金后再上架',
|
||||
{ statusCode: 409, errorCode: 'consignment_deposit_already_settled' },
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
releaseConsignmentInventoryClaim,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import {
|
||||
forfeitConsignmentPublishDepositBySubmissionWithClient,
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||
getConsignmentDepositErrorCode,
|
||||
releaseConsignmentPublishDepositBySubmissionWithClient,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
type WorkOrderRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
@@ -96,6 +98,12 @@ function throwConsignmentDepositHttpError(error: unknown, restock: boolean): nev
|
||||
errorCode: 'consignment_deposit_balance_inconsistent',
|
||||
})
|
||||
}
|
||||
if (code === 'consignment_deposit_already_settled') {
|
||||
throw createHttpError(
|
||||
'该提报单的发布押金已退还或已被没收(确认加好友后押金不退),无法自动重新冻结;请人工核对后再撤销或回库',
|
||||
{ statusCode: 409, errorCode: 'consignment_deposit_already_settled' },
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -698,7 +706,18 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
|
||||
const submissionId = consignmentInfo.sourceSubmissionId || inv.source_submission_id
|
||||
if (submissionId) {
|
||||
if (input.action === 'archive_inventory') {
|
||||
// 已确认加好友的单子,卖家发布押金不再退还:撤销回库/取消/作废一律走没收,
|
||||
// 不能再走"退还押金"或"重新冻结"(后者会把押金留着等下次退还)。
|
||||
if (isWorkOrderGiftDepositForfeitable(lockedOrder)) {
|
||||
await forfeitConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
workOrderId: Number(lockedOrder.id),
|
||||
note: `已确认加好友,寄卖押金不退(${
|
||||
input.action === 'archive_inventory' ? '履约作废' : '撤销回库'
|
||||
})${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
} else if (input.action === 'archive_inventory') {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖履约作废退还押金 ${inv.inventory_no || submissionId}`,
|
||||
|
||||
@@ -59,6 +59,9 @@ import { resolveAfterSalesRecoveryAllocation } from '../../repositories/worker-p
|
||||
import { normalizeWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||
import {
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||
getConsignmentDepositErrorCode,
|
||||
forfeitConsignmentPublishDepositBySubmissionWithClient,
|
||||
forfeitConsignmentPublishDepositByWorkOrderWithClient,
|
||||
releaseConsignmentPublishDepositWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||
import {
|
||||
@@ -2133,3 +2136,176 @@ test('内部现货履约不再自动跳过加好友直接进冷却', async () =>
|
||||
)
|
||||
assert.ok(!src.includes('forceFriendGift'), '不能再有"自动确认加好友"的强制进冷却开关')
|
||||
})
|
||||
|
||||
test('没收寄卖发布押金:只扣冻结额、写 deposit_deduction,可用余额不动', async () => {
|
||||
const calls: Array<{ sql: string; values?: unknown[] }> = []
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
calls.push({ sql, values })
|
||||
if (sql.includes('FROM consignment_submissions')) {
|
||||
return { rows: [{ worker_id: 88, deposit_amount_fen: 2000, submission_no: 'SUB-1' }] }
|
||||
}
|
||||
if (sql.includes('FROM worker_wallet_ledgers')) {
|
||||
// 台账快照(押金归属)与 outstanding 汇总复用同一条 SQL 前缀
|
||||
if (sql.includes('AS outstanding')) return { rows: [{ outstanding: 2000 }] }
|
||||
return { rows: [{ worker_id: 88, amount: 2000 }] }
|
||||
}
|
||||
if (sql.includes('FROM worker_wallets')) {
|
||||
return { rows: [{ worker_id: 88, available_amount: 500, frozen_deposit_amount: 2000 }] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const forfeited = await forfeitConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId: 7,
|
||||
workOrderId: 100154,
|
||||
note: '已确认加好友,取消工单没收寄卖发布押金',
|
||||
now: '2026-09-16T12:00:00.000Z',
|
||||
})
|
||||
|
||||
assert.equal(forfeited, 2000)
|
||||
|
||||
const walletUpdate = calls.find(
|
||||
(call) => call.sql.includes('UPDATE worker_wallets') && !call.sql.includes('available_amount'),
|
||||
)
|
||||
assert.ok(walletUpdate, '没收只应改冻结额')
|
||||
assert.equal(walletUpdate.values?.[0], 0, '冻结额应扣到 0')
|
||||
assert.ok(!walletUpdate.sql.includes('available_amount'), '没收不能给卖家退款')
|
||||
|
||||
const ledger = calls.find((call) => call.sql.includes('INSERT INTO worker_wallet_ledgers'))
|
||||
assert.ok(ledger)
|
||||
assert.ok(ledger.sql.includes("'deposit_deduction'"), '流水类型必须是没收')
|
||||
assert.equal(ledger.values?.[1], -2000, '没收金额应为负数核销')
|
||||
assert.equal(ledger.values?.[4], 100154, '流水要关联工单,便于对账')
|
||||
assert.ok(String(ledger.values?.[6]).includes('"giftDepositForfeited":true'))
|
||||
})
|
||||
|
||||
test('押金已释放或已没收时不再重复没收', async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
calls.push(sql)
|
||||
if (sql.includes('FROM consignment_submissions')) {
|
||||
return { rows: [{ worker_id: 88, deposit_amount_fen: 2000, submission_no: 'SUB-1' }] }
|
||||
}
|
||||
if (sql.includes('AS outstanding')) return { rows: [{ outstanding: 0 }] }
|
||||
if (sql.includes('FROM worker_wallet_ledgers')) return { rows: [] }
|
||||
if (sql.includes('FROM worker_wallets')) {
|
||||
return { rows: [{ worker_id: 88, available_amount: 500, frozen_deposit_amount: 0 }] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const forfeited = await forfeitConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId: 7,
|
||||
note: '重复触发',
|
||||
now: '2026-09-16T12:00:00.000Z',
|
||||
})
|
||||
|
||||
assert.equal(forfeited, 0)
|
||||
assert.ok(
|
||||
!calls.some((sql) => sql.includes('INSERT INTO worker_wallet_ledgers')),
|
||||
'没有可没收的押金就不该写流水',
|
||||
)
|
||||
assert.ok(!calls.some((sql) => sql.includes('UPDATE worker_wallets')))
|
||||
})
|
||||
|
||||
test('不是寄卖履约单的工单不会去没收卖家押金', async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
calls.push(sql)
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const forfeited = await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, {
|
||||
workOrderId: 100154,
|
||||
note: '无寄卖现货',
|
||||
now: '2026-09-16T12:00:00.000Z',
|
||||
})
|
||||
|
||||
assert.equal(forfeited, 0)
|
||||
assert.equal(calls.length, 1, '查不到寄卖现货就该直接返回,不做多余写操作')
|
||||
assert.ok(calls[0]!.includes("material_json->'consignment'->>'inventoryId'"))
|
||||
})
|
||||
|
||||
test('确认加好友后的取消/退单/作废路径都要没收卖家发布押金', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const files = [
|
||||
'work-order-cancel-repo.ts',
|
||||
'work-order-issue-repo.ts',
|
||||
'worker-cancel-request-repo.ts',
|
||||
]
|
||||
for (const file of files) {
|
||||
const src = await readFile.readFile(
|
||||
new URL(`../../repositories/worker-platform/${file}`, import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const hookCount = src.split('forfeitConsignmentPublishDepositByWorkOrderWithClient(').length - 1
|
||||
const forfeitFlags = src.split('isWorkOrderGiftDepositForfeitable(workOrder)').length - 1
|
||||
assert.ok(forfeitFlags > 0, `${file} 应保留押金不退判定`)
|
||||
assert.equal(hookCount, forfeitFlags, `${file} 每处"押金不退"判定都要同步没收卖家发布押金`)
|
||||
assert.equal(
|
||||
src.split('isWorkOrderGiftDepositForfeitable(workOrder)\n // 确认加好友之后该单押金不退')
|
||||
.length - 1,
|
||||
forfeitFlags,
|
||||
`${file} 没收动作必须紧跟判定、位于同一事务里`,
|
||||
)
|
||||
}
|
||||
|
||||
const serviceSrc = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const revokeBody = serviceSrc.slice(
|
||||
serviceSrc.indexOf('export async function revokeWorkOrderConsignmentFulfillment('),
|
||||
)
|
||||
assert.ok(
|
||||
revokeBody.includes('forfeitConsignmentPublishDepositBySubmissionWithClient('),
|
||||
'撤销寄售履约在已确认加好友后不能再退卖家押金',
|
||||
)
|
||||
})
|
||||
|
||||
test('押金已退还或已没收时不再尝试二次冻结(台账唯一索引只允许一条冻结流水)', async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
calls.push(sql)
|
||||
if (sql.includes('FROM consignment_submissions')) {
|
||||
return { rows: [{ worker_id: 88, deposit_amount_fen: 2000, submission_no: 'SUB-1' }] }
|
||||
}
|
||||
if (sql.includes('AS outstanding')) return { rows: [{ outstanding: 0 }] }
|
||||
if (sql.includes('FROM worker_wallet_ledgers')) {
|
||||
// 已经存在一条冻结流水 → 说明之前冻结过、现已结清
|
||||
return { rows: [{ worker_id: 88, amount: 2000 }] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||
submissionId: 7,
|
||||
note: '退回复库重新冻结',
|
||||
now: '2026-09-16T12:00:00.000Z',
|
||||
}),
|
||||
(error: unknown) => (error as { code?: string }).code === 'consignment_deposit_already_settled',
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
!calls.some((sql) => sql.includes('INSERT INTO worker_wallet_ledgers')),
|
||||
'不能再去插第二条冻结流水(会撞唯一索引)',
|
||||
)
|
||||
const settledError = Object.assign(new Error('settled'), {
|
||||
code: 'consignment_deposit_already_settled',
|
||||
})
|
||||
assert.equal(
|
||||
getConsignmentDepositErrorCode(settledError),
|
||||
'consignment_deposit_already_settled',
|
||||
'这个码要被服务层识别并映射成 409 文案,不能漏成 500',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
|
||||
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
||||
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
|
||||
| `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` |
|
||||
| 本批提交(批次 F2,同一提交内含本文档,故不写自身 hash) | 批次 F2:确认加好友后卖家发布押金不退(`deposit_deduction` 落地)+ 二次冻结护栏 | `consignment-deposit-repo.ts`、取消/退单仓库、撤销履约服务 |
|
||||
| 本批提交(批次 E,同一提交内含本文档,故不写自身 hash) | 前端四权限门控收口、`allocation_status` 约束与权限文案修正、面板错误态/防双提交/真实待审计数 | `admin-auth.ts`、两个寄卖面板、`AdminWorkerPlatformPage.tsx`、迁移 `080` |
|
||||
|
||||
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
|
||||
@@ -274,6 +276,30 @@
|
||||
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
||||
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
||||
|
||||
### 批次 F · 待加好友流程与押金不退(本轮)
|
||||
|
||||
1. ✅ **内部现货履约不再自动进冷却**:新增 `parkWorkOrderWaitingFriendAddWithClient`,占库落地时把工单停在
|
||||
`acceptance_mode=friend_gift` + `gift_phase=material_required` 的**待加好友**阶段(`friend_added_at` /
|
||||
`gift_available_at` / `deadline_at` 全部留空),客服线下通知加好友后手动点「确认加好友」
|
||||
(复用已有 `POST /worker-platform/orders/:id/confirm-friend`)才进入赠送冷却。
|
||||
冷却时长按商品规则落到工单,两者都为 0 时补默认 72 小时(否则确认后会得到 0 秒冷却)。
|
||||
同时删除已无调用方的 `forceFriendGift` 开关,避免再出现"自动确认加好友"的旁路。
|
||||
2. ✅ **前端文案**:内部现货单在该阶段显示「待加好友」(打手线仍是「待打手提交资料」),
|
||||
确认按钮统一为「确认加好友」;阶段文案抽到 `utils/gift-phase.ts` 便于单测。
|
||||
3. ✅ **后台备注快捷项**:「已通知加好友」一键追加「[已通知加好友] 操作人 时间」,仍可手动补充(复用 `admin_note`)。
|
||||
4. ✅ **确认加好友后押金不退(P2-2 `deposit_deduction` 死分支落地)**:
|
||||
- 打手履约押金:沿用既有的 `isWorkOrderGiftDepositForfeitable`(`friend_added_at` 有值即不退);
|
||||
- **寄卖卖家发布押金同口径新增**:`forfeitConsignmentPublishDepositBySubmissionWithClient` /
|
||||
`...ByWorkOrderWithClient`,写一条负数 `deposit_deduction` 流水(可用余额不动、只扣冻结额),
|
||||
挂在 6 处取消/退单/脱离判定点 + 「撤销寄售履约」的作废/回库分支上。
|
||||
因为台账 `outstanding` 会把 `deposit_deduction` 计入已核销,之后同一提报单的
|
||||
release / 重新冻结都拿不到钱——**天然幂等,退不回去**。
|
||||
- 顺带修掉一个会 500 的边角:押金已退还/已没收时 `ensureConsignmentPublishDepositFrozenBySubmissionWithClient`
|
||||
会撞"每提报单只允许一条冻结流水"的唯一索引,现改为显式抛
|
||||
`consignment_deposit_already_settled`(409),由运营决定重新提报或人工处理。
|
||||
5. ⏳ 待你拍板:客服一直不点「确认加好友」时订单会无限期停住(押金也一直冻着),
|
||||
目前**没有**超时提醒或自动策略;另外没收后同一现货再被匹配、卖家要不要重新补押金,需要业务规则。
|
||||
|
||||
### 批次 E · 权限门控与运营体验(P1/P2)—— 🔶 部分完成(本轮)
|
||||
|
||||
1. ✅ **前端四权限门控(P1-8 剩余部分)**:
|
||||
|
||||
Reference in New Issue
Block a user