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`,
@@ -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',
)
})
+26
View File
@@ -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 剩余部分)**: