From a47c243421279a443d3252a3bdfca4fd830058e9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 16 Sep 2026 21:27:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=8A=A0=E5=A5=BD=E5=8F=8B=E5=90=8E=E5=8D=96=E5=AE=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=8A=BC=E9=87=91=E4=B8=8D=E9=80=80=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=B0=81=E4=BD=8F=E4=BA=8C=E6=AC=A1=E5=86=BB=E7=BB=93=E7=9A=84?= =?UTF-8?q?=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:确认加好友之后押金不退回,卖家的押金也不退回。 - 新增 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 例)全绿。 --- .../consignment-deposit-repo.ts | 131 ++++++++++++- .../worker-platform/work-order-cancel-repo.ts | 28 +++ .../worker-platform/work-order-issue-repo.ts | 19 ++ .../worker-cancel-request-repo.ts | 10 + .../consignment-admin-service.ts | 6 + .../consignment-fulfillment-service.ts | 21 ++- .../worker-platform-service.test.ts | 176 ++++++++++++++++++ docs/寄卖系统优化方案.md | 26 +++ 8 files changed, 415 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts index 885d6196..2e4e604a 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts @@ -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 { 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 { + 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 { + 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, + }) +} diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index b7797355..2b26656b 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -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( diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index d4cd80f2..396488df 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -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 = diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts index 714d182a..989e4961 100644 --- a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -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( `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 19ee5dc6..03ad2f42 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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 } diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 509209c0..ef8f909d 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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}`, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index d8802ea2..22586fd9 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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', + ) +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 89dc2f66..954c5768 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -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 剩余部分)**: