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