fix(worker): 售后追缴自动驳回提现并扣冻结资金,修复欠款抵扣 500
- 修复欠款抵扣 SQL 参数类型冲突(debt_amount = $1 与 $1 = 0 推断不一致), 此前手动偿还、验收报酬抵扣、充值/押金解冻/寄卖自动抵扣都会 500 - 追缴顺序改为 待解冻押金 → 提现冻结 → 可用余额 → 欠款: 驳回待审核提现并直接扣走冻结资金,扣不完的部分退回可用余额 - 迁移 109:售后单记录 withdraw_deduction_amount,补 pending 提现部分索引 - 新增流水类型 after_sales_withdraw_deduction,前后端展示与标签同步 - 提现驳回退回余额后补调欠款自动抵扣,与充值/结算/解冻/寄卖路径对齐 - 补追缴分配单测与 SQL 参数类型静态门禁
This commit is contained in:
@@ -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';
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<WorkerFinanceRequestRow>(
|
||||
`
|
||||
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) {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -371,6 +371,9 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
||||
{item.resolutionAction === 'upheld' ? (
|
||||
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
||||
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
||||
{Number(item.withdrawDeductionAmount || 0) > 0
|
||||
? `(含提现冻结 ${formatMoney(item.withdrawDeductionAmount)},提现已驳回)`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{item.refundedAmount > 0 ? (
|
||||
@@ -518,7 +521,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
||||
label="追缴金额"
|
||||
name="recoveryAmount"
|
||||
rules={[{ required: true, message: '请输入追缴金额' }]}
|
||||
extra="系统依次扣减该责任打手的本单待解冻金额、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
|
||||
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
|
||||
>
|
||||
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -36,6 +36,7 @@ const LEDGER_TYPE_LABELS: Record<string, string> = {
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{Number(item.withdrawDeductionAmount || 0) > 0 && (
|
||||
<span className="financial-chip is-available">
|
||||
<span className="chip-name">提现冻结抵扣</span>
|
||||
<span className="chip-amount">
|
||||
{formatMoney(item.withdrawDeductionAmount)}
|
||||
</span>
|
||||
<span className="chip-hint">(提现申请已驳回)</span>
|
||||
</span>
|
||||
)}
|
||||
{Number(item.availableDeductionAmount || 0) > 0 && (
|
||||
<span className="financial-chip is-available">
|
||||
<span className="chip-name">余额抵扣</span>
|
||||
|
||||
@@ -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: '售后追缴退还' },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -247,6 +247,7 @@ export type WorkerAfterSalesCase = {
|
||||
resolutionNote: string
|
||||
recoveryAmount: number
|
||||
pendingDepositDeductionAmount: number
|
||||
withdrawDeductionAmount: number
|
||||
availableDeductionAmount: number
|
||||
debtAmount: number
|
||||
refundedAmount: number
|
||||
|
||||
Reference in New Issue
Block a user