diff --git a/apps/backend/src/db/migrations/109_after_sales_withdraw_deduction.sql b/apps/backend/src/db/migrations/109_after_sales_withdraw_deduction.sql new file mode 100644 index 00000000..0f78100b --- /dev/null +++ b/apps/backend/src/db/migrations/109_after_sales_withdraw_deduction.sql @@ -0,0 +1,22 @@ +-- 109_after_sales_withdraw_deduction.sql +-- +-- 售后追缴要能把「提现冻结中」的钱一并扣掉。 +-- +-- 背景:追缴原本只扣「待解冻押金 + 可用余额」,打手在验收后立刻申请提现, +-- 钱就从可用余额挪进 frozen_withdraw_amount,追缴只能扣到零头,剩下挂欠款; +-- 而提现驳回后钱回到可用余额,也不会再抵扣。生产 case AS4f0566f0a192 即为此: +-- 追缴 75.15,实扣 0.35,1 分 27 秒后提现被驳回,75.00 停留在可用余额。 +-- +-- 现在的口径:追缴按 待解冻押金 → 提现冻结 → 可用余额 → 欠款 依次扣, +-- 动到提现冻结时自动把对应提现申请置为 rejected(备注写明是售后追缴驳回)。 + +ALTER TABLE worker_after_sales_cases + ADD COLUMN IF NOT EXISTS withdraw_deduction_amount BIGINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN worker_after_sales_cases.withdraw_deduction_amount IS + '本单从「提现冻结」中扣走的金额(同时会驳回对应提现申请)'; + +-- 追缴时需要按打手捞 pending 提现申请并加行锁,补一个部分索引。 +CREATE INDEX IF NOT EXISTS idx_worker_finance_requests_worker_pending_withdraw + ON worker_finance_requests (worker_id, created_at, id) + WHERE request_type = 'withdraw' AND status = 'pending'; diff --git a/apps/backend/src/repositories/worker-platform/after-sales-recovery.test.ts b/apps/backend/src/repositories/worker-platform/after-sales-recovery.test.ts new file mode 100644 index 00000000..ec3d2512 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/after-sales-recovery.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveAfterSalesRecoveryAllocation } from './after-sales-recovery.js' + +test('追缴顺序:待解冻押金 → 提现冻结 → 可用余额 → 欠款', () => { + const allocation = resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 1000, + pendingDepositAmount: 200, + frozenWithdrawAmount: 300, + availableAmount: 100, + }) + assert.deepEqual(allocation, { + pendingDepositDeductionAmount: 200, + withdrawDeductionAmount: 300, + availableDeductionAmount: 100, + debtAmount: 400, + }) +}) + +test('提现冻结足够时不用动可用余额,也不产生欠款', () => { + const allocation = resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 7515, + pendingDepositAmount: 0, + frozenWithdrawAmount: 7500, + availableAmount: 35, + }) + assert.deepEqual(allocation, { + pendingDepositDeductionAmount: 0, + withdrawDeductionAmount: 7500, + availableDeductionAmount: 15, + debtAmount: 0, + }) +}) + +test('提现冻结多余的部分不会被动用', () => { + const allocation = resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 500, + pendingDepositAmount: 0, + frozenWithdrawAmount: 7500, + availableAmount: 0, + }) + assert.deepEqual(allocation, { + pendingDepositDeductionAmount: 0, + withdrawDeductionAmount: 500, + availableDeductionAmount: 0, + debtAmount: 0, + }) +}) + +test('各项都为 0 时全额转欠款', () => { + const allocation = resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 7480, + pendingDepositAmount: 0, + frozenWithdrawAmount: 0, + availableAmount: 0, + }) + assert.deepEqual(allocation, { + pendingDepositDeductionAmount: 0, + withdrawDeductionAmount: 0, + availableDeductionAmount: 0, + debtAmount: 7480, + }) +}) + +test('负数与空值按 0 处理', () => { + const allocation = resolveAfterSalesRecoveryAllocation({ + recoveryAmount: -100, + pendingDepositAmount: -1, + frozenWithdrawAmount: -1, + availableAmount: -1, + }) + assert.deepEqual(allocation, { + pendingDepositDeductionAmount: 0, + withdrawDeductionAmount: 0, + availableDeductionAmount: 0, + debtAmount: 0, + }) +}) diff --git a/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts b/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts index 607d2d78..657937da 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts @@ -1,12 +1,21 @@ export type AfterSalesRecoveryAllocation = { pendingDepositDeductionAmount: number + withdrawDeductionAmount: number availableDeductionAmount: number debtAmount: number } +/** + * 追缴分配顺序:待解冻押金 → 提现冻结 → 可用余额 → 欠款。 + * + * 提现冻结排在可用余额之前,是因为它的钱本来就是从可用余额转过去的: + * 打手在验收后立刻提现,追缴时如果只看可用余额,就只能扣到零头, + * 剩下的挂成欠款;把提现冻结一并算进来,才能当场收齐。 + */ export function resolveAfterSalesRecoveryAllocation(input: { recoveryAmount: number pendingDepositAmount: number + frozenWithdrawAmount: number availableAmount: number }): AfterSalesRecoveryAllocation { let remaining = Math.max(0, Number(input.recoveryAmount) || 0) @@ -15,6 +24,11 @@ export function resolveAfterSalesRecoveryAllocation(input: { Math.max(0, Number(input.pendingDepositAmount) || 0), ) remaining -= pendingDepositDeductionAmount + const withdrawDeductionAmount = Math.min( + remaining, + Math.max(0, Number(input.frozenWithdrawAmount) || 0), + ) + remaining -= withdrawDeductionAmount const availableDeductionAmount = Math.min( remaining, Math.max(0, Number(input.availableAmount) || 0), @@ -22,6 +36,7 @@ export function resolveAfterSalesRecoveryAllocation(input: { remaining -= availableDeductionAmount return { pendingDepositDeductionAmount, + withdrawDeductionAmount, availableDeductionAmount, debtAmount: remaining, } diff --git a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts index cbb9b63c..4ca8bfcb 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -9,6 +9,7 @@ import type { WorkerAfterSalesCaseRow, WorkerAfterSalesDebtRow, WorkerDepositUnfreezeRow, + WorkerFinanceRequestRow, WorkerWalletRow, WorkOrderRow, WorkOrderShareRow, @@ -314,6 +315,7 @@ export async function resolveAfterSalesCase(input: { } let pendingDepositDeductionAmount = 0 + let withdrawDeductionAmount = 0 let availableDeductionAmount = 0 let debtAmount = 0 let nextStatus = 'closed' @@ -327,6 +329,7 @@ export async function resolveAfterSalesCase(input: { now: input.now, }) pendingDepositDeductionAmount = recovery.pendingDepositDeductionAmount + withdrawDeductionAmount = recovery.withdrawDeductionAmount availableDeductionAmount = recovery.availableDeductionAmount debtAmount = recovery.debtAmount nextStatus = debtAmount > 0 ? 'recovery_pending' : 'closed' @@ -341,12 +344,13 @@ export async function resolveAfterSalesCase(input: { resolution_note = $3, recovery_amount = $4, pending_deposit_deduction_amount = $5, - available_deduction_amount = $6, - debt_amount = $7, - resolved_by = $8, - resolved_at = $9, - updated_at = $9 - WHERE id = $10 + withdraw_deduction_amount = $6, + available_deduction_amount = $7, + debt_amount = $8, + resolved_by = $9, + resolved_at = $10, + updated_at = $10 + WHERE id = $11 RETURNING * `, [ @@ -355,6 +359,7 @@ export async function resolveAfterSalesCase(input: { input.resolutionNote, input.action === 'upheld' ? input.recoveryAmount : 0, pendingDepositDeductionAmount, + withdrawDeductionAmount, availableDeductionAmount, debtAmount, input.resolvedBy, @@ -372,6 +377,7 @@ export async function resolveAfterSalesCase(input: { payloadJson: JSON.stringify({ recoveryAmount: input.action === 'upheld' ? input.recoveryAmount : 0, pendingDepositDeductionAmount, + withdrawDeductionAmount, availableDeductionAmount, debtAmount, resolutionNote: input.resolutionNote, @@ -538,8 +544,13 @@ export async function offsetWorkerAfterSalesDebtsWithClient( await client.query( ` UPDATE worker_after_sales_cases - SET debt_amount = $1, - status = CASE WHEN $1 = 0 AND status = 'recovery_pending' THEN 'closed' ELSE status END, + SET debt_amount = $1::bigint, + -- 必须显式 ::bigint:与下面和 0(integer)比较时,Postgres 会报 + -- inconsistent types deduced for parameter,导致整笔抵扣失败。 + status = CASE + WHEN $1::bigint = 0 AND status = 'recovery_pending' THEN 'closed' + ELSE status + END, updated_at = $2::timestamptz WHERE id = $3 `, @@ -728,6 +739,7 @@ async function recoverAfterSalesAmountWithClient( }, ): Promise<{ pendingDepositDeductionAmount: number + withdrawDeductionAmount: number availableDeductionAmount: number debtAmount: number }> { @@ -740,6 +752,20 @@ async function recoverAfterSalesAmountWithClient( `, [input.workOrderId, input.workerId], ) + // 提现冻结中的钱同属「已被扣住」的资金:追缴时直接把待审核提现驳回并扣走, + // 不能等它变成可用余额(打手往往在验收后立刻发起提现,等不到驳回)。 + const pendingWithdrawResult = await client.query( + ` + SELECT * FROM worker_finance_requests + WHERE worker_id = $1 + AND request_type = 'withdraw' + AND status = 'pending' + AND frozen_amount > 0 + ORDER BY created_at ASC, id ASC + FOR UPDATE + `, + [input.workerId], + ) await ensureWorkerWalletWithClient(client, input.workerId, input.now) let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId) const allocation = resolveAfterSalesRecoveryAllocation({ @@ -748,6 +774,10 @@ async function recoverAfterSalesAmountWithClient( (total, row) => total + Math.max(0, Number(row.amount) || 0), 0, ), + frozenWithdrawAmount: pendingWithdrawResult.rows.reduce( + (total, row) => total + Math.max(0, Number(row.frozen_amount) || 0), + 0, + ), availableAmount: Number(wallet?.available_amount || 0), }) let pendingRemaining = allocation.pendingDepositDeductionAmount @@ -793,6 +823,88 @@ async function recoverAfterSalesAmountWithClient( pendingRemaining -= deduction } + // 提现冻结:整笔驳回申请,扣走需要的部分,剩余额度退回可用余额(随后仍会被下面的 + // 「扣可用余额」按需扣掉)。frozen_amount 保留原值作为历史申请记录。 + let withdrawRemaining = allocation.withdrawDeductionAmount + for (const row of pendingWithdrawResult.rows) { + if (withdrawRemaining <= 0) break + const frozen = Math.max(0, Number(row.frozen_amount) || 0) + if (frozen <= 0) continue + const deduction = Math.min(frozen, withdrawRemaining) + const released = frozen - deduction + wallet = await getWorkerWalletWithClient(client, input.workerId) + const nextAvailable = Number(wallet?.available_amount || 0) + released + const nextFrozenWithdraw = Math.max(0, Number(wallet?.frozen_withdraw_amount || 0) - frozen) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_withdraw_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozenWithdraw, input.now, input.workerId], + ) + await client.query( + `UPDATE worker_finance_requests + SET status = 'rejected', + reviewed_note = $1, + reviewed_at = $2, + updated_at = $2, + payload_json = payload_json || $3::jsonb + WHERE id = $4`, + [ + '售后追缴自动驳回提现', + input.now, + JSON.stringify({ + review: { + at: input.now, + by: 'system:after_sales', + note: '售后追缴自动驳回提现', + status: 'rejected', + source: 'after_sales_recovery', + }, + }), + row.id, + ], + ) + if (deduction > 0) { + 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, 'after_sales_withdraw_deduction', $2, $3, $4, $5, '售后问题单扣减提现冻结资金', $6::jsonb, $7) + `, + [ + input.workerId, + -deduction, + nextAvailable, + nextFrozenWithdraw, + input.workOrderId, + JSON.stringify({ caseId: input.caseId, note: input.note, requestId: Number(row.id) }), + input.now, + ], + ) + } + if (released > 0) { + 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, 'withdraw_release', $2, $3, $4, $5, '售后追缴驳回提现,扣除后余额退回', $6::jsonb, $7) + `, + [ + input.workerId, + released, + nextAvailable, + nextFrozenWithdraw, + input.workOrderId, + JSON.stringify({ caseId: input.caseId, requestId: Number(row.id) }), + input.now, + ], + ) + } + withdrawRemaining -= deduction + } + wallet = await getWorkerWalletWithClient(client, input.workerId) const availableDeductionAmount = allocation.availableDeductionAmount if (availableDeductionAmount > 0) { diff --git a/apps/backend/src/repositories/worker-platform/after-sales-withdraw-deduction.test.ts b/apps/backend/src/repositories/worker-platform/after-sales-withdraw-deduction.test.ts new file mode 100644 index 00000000..ebe8fae1 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/after-sales-withdraw-deduction.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +/** + * 追缴要能把「提现冻结中」的钱一并扣掉。 + * + * 事故背景:case AS4f0566f0a192 追缴 75.15 时余额只剩 0.35,其余 75.00 在提现冻结里, + * 追缴只扣到 0.35;1 分 27 秒后提现被驳回,钱回到可用余额却不再抵扣。 + */ +test('追缴 SQL 必须锁定并驳回 pending 提现申请', async () => { + const source = await readFile(new URL('./after-sales-repo.ts', import.meta.url), 'utf8') + const fn = source + .split('async function recoverAfterSalesAmountWithClient')[1] + ?.split('async function createAfterSalesCaseEventWithClient')[0] + assert.ok(fn, '没找到 recoverAfterSalesAmountWithClient') + + assert.ok( + fn.includes("request_type = 'withdraw'") && fn.includes("status = 'pending'"), + '追缴前必须捞出待审核提现申请', + ) + assert.ok(fn.includes('FOR UPDATE'), '待审核提现申请必须加行锁') + assert.ok(fn.includes("SET status = 'rejected'"), '追缴动到提现冻结时必须驳回该提现申请') + assert.ok( + fn.includes('after_sales_withdraw_deduction'), + '扣提现冻结的钱要落独立流水类型,便于对账', + ) + assert.ok(fn.includes('withdraw_release'), '扣不完的部分要退回可用余额并落 withdraw_release 流水') +}) + +test('售后单要记录从提现冻结里扣走的金额', async () => { + const migration = await readFile( + new URL('../../db/migrations/109_after_sales_withdraw_deduction.sql', import.meta.url), + 'utf8', + ) + assert.ok(migration.includes('withdraw_deduction_amount')) + + const source = await readFile(new URL('./after-sales-repo.ts', import.meta.url), 'utf8') + assert.ok(source.includes('withdraw_deduction_amount = $6')) +}) diff --git a/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts b/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts index 5d1ed41f..2811b96f 100644 --- a/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts +++ b/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts @@ -12,6 +12,9 @@ import test from 'node:test' const TIMESTAMP_ASSIGNMENT = /(?:^|\s)(updated_at|created_at|accepted_at|deadline_at|submitted_at|friend_added_at|gift_available_at|consumed_at|revoked_at|archived_at|assigned_at|published_at|pinned_at)\s*=\s*\$(\d+)(?!\s*::)/g const TEXT_CAST = /\$(\d+)::text/g +const NUMERIC_UNCAST_ASSIGNMENT = + /(?:^|[\s,(])([a-z_]*(?:amount|quantity|count))\s*=\s*\$(\d+)(?!\s*::)/g +const NUMERIC_LITERAL_COMPARE = /\$(\d+)(?!\s*::)\s*(?:<=|>=|<>|!=|=|<|>)\s*-?\d+/g test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async () => { const offenders: string[] = [] @@ -34,6 +37,42 @@ test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async assert.deepEqual(offenders, []) }) +test('仓库 SQL 不许让同一个数字占位符既赋值给金额列又和数字常量比较', async () => { + // 真实事故:`SET debt_amount = $1, status = CASE WHEN $1 = 0 ...` + // Postgres 从 $1=0 推断成 integer、从 debt_amount 推断成 bigint, + // 直接报 `inconsistent types deduced for parameter $1 (integer versus bigint)`, + // 导致手动偿还、验收报酬抵扣、充值入账抵扣全部 500。 + const offenders: string[] = [] + for await (const file of glob('src/**/*.ts', { cwd: new URL('../../../', import.meta.url) })) { + if (file.includes('.test.')) continue + const source = await readFile(new URL(`../../../${file}`, import.meta.url), 'utf8') + for (const chunk of source.split('`')) { + if (!chunk.includes('UPDATE') && !chunk.includes('INSERT')) continue + const numericAssignments = new Set( + [...chunk.matchAll(NUMERIC_UNCAST_ASSIGNMENT)].map((match) => match[2]), + ) + if (numericAssignments.size === 0) continue + for (const match of chunk.matchAll(NUMERIC_LITERAL_COMPARE)) { + const param = match[1] + if (param && numericAssignments.has(param)) { + offenders.push( + `${file}: $${param} 既赋值金额列又与数字常量比较(需 ::bigint 等显式转换)`, + ) + } + } + } + } + assert.deepEqual(offenders, []) +}) + +test('售后欠款回写 SQL 显式把 debt_amount 参数转成 bigint', async () => { + const source = await readFile(new URL('./after-sales-repo.ts', import.meta.url), 'utf8') + const statement = source.split('`').find((chunk) => chunk.includes('debt_amount = $1')) + assert.ok(statement, '没找到回写 debt_amount 的 SQL') + assert.ok(statement.includes('debt_amount = $1::bigint')) + assert.ok(statement.includes('$1::bigint = 0')) +}) + test('摘寄售标记的 SQL 显式把时间戳参数转回 timestamptz', async () => { const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const detachStatement = source diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index b667f865..82d44b51 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -183,6 +183,7 @@ export type WorkerAfterSalesCaseRow = { resolution_note: string recovery_amount: number pending_deposit_deduction_amount: number + withdraw_deduction_amount: number available_deduction_amount: number debt_amount: number refunded_amount?: number diff --git a/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts b/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts index e143acce..931421f1 100644 --- a/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts @@ -547,6 +547,15 @@ export async function reviewWorkerFinanceRequest(input: { input.now, ], ) + // 驳回退回来的钱同样属于「入账」,和其他入账路径保持一致:先抵扣未结清售后欠款, + // 否则会出现「追缴时钱在提现冻结里扣不到、驳回后钱回到余额却不再抵扣」的漏收。 + await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, { + workerId: Number(current.worker_id), + now: input.now, + note: '提现驳回退回自动偿还售后欠款', + actorType: 'system', + actorId: 'withdraw_release', + }) } await client.query( diff --git a/apps/backend/src/services/admin/admin-finance-service.ts b/apps/backend/src/services/admin/admin-finance-service.ts index d873bc52..856d9465 100644 --- a/apps/backend/src/services/admin/admin-finance-service.ts +++ b/apps/backend/src/services/admin/admin-finance-service.ts @@ -84,7 +84,7 @@ const FINANCE_EVENTS_CTE = ` FROM worker_wallet_ledgers wl WHERE wl.ledger_type IN ( 'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction', - 'after_sales_balance_deduction', 'after_sales_debt_offset' + 'after_sales_withdraw_deduction', 'after_sales_balance_deduction', 'after_sales_debt_offset' ) AND wl.amount <> 0 diff --git a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts index c7531be3..0142e7ec 100644 --- a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts +++ b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts @@ -209,6 +209,7 @@ export function mapWorkerAfterSalesCase(afterSalesCase: { resolution_note: string recovery_amount: number pending_deposit_deduction_amount: number + withdraw_deduction_amount: number available_deduction_amount: number debt_amount: number refunded_amount?: number @@ -259,6 +260,7 @@ export function mapWorkerAfterSalesCase(afterSalesCase: { resolutionNote: String(afterSalesCase.resolution_note || ''), recoveryAmount: Number(afterSalesCase.recovery_amount || 0), pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0), + withdrawDeductionAmount: Number(afterSalesCase.withdraw_deduction_amount || 0), availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0), debtAmount: Number(afterSalesCase.debt_amount || 0), refundedAmount: Number(afterSalesCase.refunded_amount || 0), diff --git a/apps/backend/src/services/worker-platform/worker-platform-normalizers.ts b/apps/backend/src/services/worker-platform/worker-platform-normalizers.ts index 26cd754a..14fa6f3a 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-normalizers.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-normalizers.ts @@ -80,6 +80,7 @@ export function normalizeWalletLedgerType(value: unknown) { 'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction', + 'after_sales_withdraw_deduction', 'after_sales_balance_deduction', 'after_sales_debt_offset', 'after_sales_refund', 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 c98b697b..d383f12b 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 @@ -623,10 +623,12 @@ test('售后追缴优先扣减待解冻押金', () => { resolveAfterSalesRecoveryAllocation({ recoveryAmount: 10_000, pendingDepositAmount: 12_000, + frozenWithdrawAmount: 0, availableAmount: 8_000, }), { pendingDepositDeductionAmount: 10_000, + withdrawDeductionAmount: 0, availableDeductionAmount: 0, debtAmount: 0, }, @@ -638,10 +640,12 @@ test('售后追缴在押金不足时继续扣减可用余额', () => { resolveAfterSalesRecoveryAllocation({ recoveryAmount: 10_000, pendingDepositAmount: 3_000, + frozenWithdrawAmount: 0, availableAmount: 8_000, }), { pendingDepositDeductionAmount: 3_000, + withdrawDeductionAmount: 0, availableDeductionAmount: 7_000, debtAmount: 0, }, @@ -653,16 +657,35 @@ test('售后追缴在余额不足时形成欠款', () => { resolveAfterSalesRecoveryAllocation({ recoveryAmount: 10_000, pendingDepositAmount: 3_000, + frozenWithdrawAmount: 0, availableAmount: 2_000, }), { pendingDepositDeductionAmount: 3_000, + withdrawDeductionAmount: 0, availableDeductionAmount: 2_000, debtAmount: 5_000, }, ) }) +test('售后追缴先扣待解冻押金、再扣提现冻结、最后扣可用余额', () => { + assert.deepEqual( + resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 10_000, + pendingDepositAmount: 3_000, + frozenWithdrawAmount: 5_000, + availableAmount: 8_000, + }), + { + pendingDepositDeductionAmount: 3_000, + withdrawDeductionAmount: 5_000, + availableDeductionAmount: 2_000, + debtAmount: 0, + }, + ) +}) + function buildOrderItemRow(): OrderItemRow { return { id: 1, diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index b7ed4dea..345655d2 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -371,6 +371,9 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan {item.resolutionAction === 'upheld' ? ( 0 ? 'danger' : undefined}> 追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)} + {Number(item.withdrawDeductionAmount || 0) > 0 + ? `(含提现冻结 ${formatMoney(item.withdrawDeductionAmount)},提现已驳回)` + : ''} ) : null} {item.refundedAmount > 0 ? ( @@ -518,7 +521,7 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan label="追缴金额" name="recoveryAmount" rules={[{ required: true, message: '请输入追缴金额' }]} - extra="系统依次扣减该责任打手的本单待解冻金额、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。" + extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。" > diff --git a/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx b/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx index 5d9d1952..db00ef93 100644 --- a/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkerDetailDrawer.tsx @@ -36,6 +36,7 @@ const LEDGER_TYPE_LABELS: Record = { reward_settlement: '结算报酬', sharing_reward: '拼单报酬', after_sales_deposit_deduction: '售后扣减押金', + after_sales_withdraw_deduction: '售后扣减提现', after_sales_balance_deduction: '售后扣减余额', after_sales_debt_offset: '抵扣售后欠款', after_sales_refund: '售后追缴退还', @@ -65,6 +66,7 @@ function resolveLedgerTagColor(ledgerType: string) { [ 'deposit_deduction', 'after_sales_deposit_deduction', + 'after_sales_withdraw_deduction', 'after_sales_balance_deduction', ].includes(ledgerType) ) diff --git a/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx b/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx index 69e593e7..5a7ca313 100644 --- a/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerAfterSalesPage.tsx @@ -420,6 +420,7 @@ function WorkerAfterSalesItem({ const hasRecoveryDetail = Number(item.recoveryAmount || 0) > 0 || Number(item.pendingDepositDeductionAmount || 0) > 0 || + Number(item.withdrawDeductionAmount || 0) > 0 || Number(item.availableDeductionAmount || 0) > 0 || Number(item.debtAmount || 0) > 0 || Number(item.refundedAmount || 0) > 0 @@ -720,6 +721,15 @@ function WorkerAfterSalesItem({ )} + {Number(item.withdrawDeductionAmount || 0) > 0 && ( + + 提现冻结抵扣 + + {formatMoney(item.withdrawDeductionAmount)} + + (提现申请已驳回) + + )} {Number(item.availableDeductionAmount || 0) > 0 && ( 余额抵扣 diff --git a/apps/frontend/src/pages/worker/WorkerProfileRecords.tsx b/apps/frontend/src/pages/worker/WorkerProfileRecords.tsx index d204833e..d65c6805 100644 --- a/apps/frontend/src/pages/worker/WorkerProfileRecords.tsx +++ b/apps/frontend/src/pages/worker/WorkerProfileRecords.tsx @@ -40,6 +40,7 @@ const LEDGER_TYPE_OPTIONS = [ { value: 'reward_settlement', label: '结算报酬' }, { value: 'sharing_reward', label: '拼单报酬' }, { value: 'after_sales_deposit_deduction', label: '售后扣减押金' }, + { value: 'after_sales_withdraw_deduction', label: '售后扣减提现' }, { value: 'after_sales_balance_deduction', label: '售后扣减余额' }, { value: 'after_sales_debt_offset', label: '抵扣售后欠款' }, { value: 'after_sales_refund', label: '售后追缴退还' }, diff --git a/apps/frontend/src/pages/worker/worker-profile-view-utils.tsx b/apps/frontend/src/pages/worker/worker-profile-view-utils.tsx index cfb973e4..4181dce0 100644 --- a/apps/frontend/src/pages/worker/worker-profile-view-utils.tsx +++ b/apps/frontend/src/pages/worker/worker-profile-view-utils.tsx @@ -60,6 +60,7 @@ export function formatLedgerType(ledgerType: string) { reward_settlement: '结算报酬', sharing_reward: '拼单报酬', after_sales_deposit_deduction: '售后扣减押金', + after_sales_withdraw_deduction: '售后扣减提现', after_sales_balance_deduction: '售后扣减余额', after_sales_debt_offset: '抵扣售后欠款', after_sales_refund: '售后追缴退还', @@ -82,6 +83,7 @@ export function resolveLedgerTagColor(ledgerType: string) { if (ledgerType === 'reward_settlement') return 'cyan' if (ledgerType === 'sharing_reward') return 'cyan' if (ledgerType === 'after_sales_deposit_deduction') return 'red' + if (ledgerType === 'after_sales_withdraw_deduction') return 'red' if (ledgerType === 'after_sales_balance_deduction') return 'red' if (ledgerType === 'after_sales_debt_offset') return 'orange' if (ledgerType === 'after_sales_refund') return 'green' diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index d2a91336..8f208087 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -247,6 +247,7 @@ export type WorkerAfterSalesCase = { resolutionNote: string recoveryAmount: number pendingDepositDeductionAmount: number + withdrawDeductionAmount: number availableDeductionAmount: number debtAmount: number refundedAmount: number