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 = {
|
export type AfterSalesRecoveryAllocation = {
|
||||||
pendingDepositDeductionAmount: number
|
pendingDepositDeductionAmount: number
|
||||||
|
withdrawDeductionAmount: number
|
||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追缴分配顺序:待解冻押金 → 提现冻结 → 可用余额 → 欠款。
|
||||||
|
*
|
||||||
|
* 提现冻结排在可用余额之前,是因为它的钱本来就是从可用余额转过去的:
|
||||||
|
* 打手在验收后立刻提现,追缴时如果只看可用余额,就只能扣到零头,
|
||||||
|
* 剩下的挂成欠款;把提现冻结一并算进来,才能当场收齐。
|
||||||
|
*/
|
||||||
export function resolveAfterSalesRecoveryAllocation(input: {
|
export function resolveAfterSalesRecoveryAllocation(input: {
|
||||||
recoveryAmount: number
|
recoveryAmount: number
|
||||||
pendingDepositAmount: number
|
pendingDepositAmount: number
|
||||||
|
frozenWithdrawAmount: number
|
||||||
availableAmount: number
|
availableAmount: number
|
||||||
}): AfterSalesRecoveryAllocation {
|
}): AfterSalesRecoveryAllocation {
|
||||||
let remaining = Math.max(0, Number(input.recoveryAmount) || 0)
|
let remaining = Math.max(0, Number(input.recoveryAmount) || 0)
|
||||||
@@ -15,6 +24,11 @@ export function resolveAfterSalesRecoveryAllocation(input: {
|
|||||||
Math.max(0, Number(input.pendingDepositAmount) || 0),
|
Math.max(0, Number(input.pendingDepositAmount) || 0),
|
||||||
)
|
)
|
||||||
remaining -= pendingDepositDeductionAmount
|
remaining -= pendingDepositDeductionAmount
|
||||||
|
const withdrawDeductionAmount = Math.min(
|
||||||
|
remaining,
|
||||||
|
Math.max(0, Number(input.frozenWithdrawAmount) || 0),
|
||||||
|
)
|
||||||
|
remaining -= withdrawDeductionAmount
|
||||||
const availableDeductionAmount = Math.min(
|
const availableDeductionAmount = Math.min(
|
||||||
remaining,
|
remaining,
|
||||||
Math.max(0, Number(input.availableAmount) || 0),
|
Math.max(0, Number(input.availableAmount) || 0),
|
||||||
@@ -22,6 +36,7 @@ export function resolveAfterSalesRecoveryAllocation(input: {
|
|||||||
remaining -= availableDeductionAmount
|
remaining -= availableDeductionAmount
|
||||||
return {
|
return {
|
||||||
pendingDepositDeductionAmount,
|
pendingDepositDeductionAmount,
|
||||||
|
withdrawDeductionAmount,
|
||||||
availableDeductionAmount,
|
availableDeductionAmount,
|
||||||
debtAmount: remaining,
|
debtAmount: remaining,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
WorkerAfterSalesCaseRow,
|
WorkerAfterSalesCaseRow,
|
||||||
WorkerAfterSalesDebtRow,
|
WorkerAfterSalesDebtRow,
|
||||||
WorkerDepositUnfreezeRow,
|
WorkerDepositUnfreezeRow,
|
||||||
|
WorkerFinanceRequestRow,
|
||||||
WorkerWalletRow,
|
WorkerWalletRow,
|
||||||
WorkOrderRow,
|
WorkOrderRow,
|
||||||
WorkOrderShareRow,
|
WorkOrderShareRow,
|
||||||
@@ -314,6 +315,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pendingDepositDeductionAmount = 0
|
let pendingDepositDeductionAmount = 0
|
||||||
|
let withdrawDeductionAmount = 0
|
||||||
let availableDeductionAmount = 0
|
let availableDeductionAmount = 0
|
||||||
let debtAmount = 0
|
let debtAmount = 0
|
||||||
let nextStatus = 'closed'
|
let nextStatus = 'closed'
|
||||||
@@ -327,6 +329,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
now: input.now,
|
now: input.now,
|
||||||
})
|
})
|
||||||
pendingDepositDeductionAmount = recovery.pendingDepositDeductionAmount
|
pendingDepositDeductionAmount = recovery.pendingDepositDeductionAmount
|
||||||
|
withdrawDeductionAmount = recovery.withdrawDeductionAmount
|
||||||
availableDeductionAmount = recovery.availableDeductionAmount
|
availableDeductionAmount = recovery.availableDeductionAmount
|
||||||
debtAmount = recovery.debtAmount
|
debtAmount = recovery.debtAmount
|
||||||
nextStatus = debtAmount > 0 ? 'recovery_pending' : 'closed'
|
nextStatus = debtAmount > 0 ? 'recovery_pending' : 'closed'
|
||||||
@@ -341,12 +344,13 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
resolution_note = $3,
|
resolution_note = $3,
|
||||||
recovery_amount = $4,
|
recovery_amount = $4,
|
||||||
pending_deposit_deduction_amount = $5,
|
pending_deposit_deduction_amount = $5,
|
||||||
available_deduction_amount = $6,
|
withdraw_deduction_amount = $6,
|
||||||
debt_amount = $7,
|
available_deduction_amount = $7,
|
||||||
resolved_by = $8,
|
debt_amount = $8,
|
||||||
resolved_at = $9,
|
resolved_by = $9,
|
||||||
updated_at = $9
|
resolved_at = $10,
|
||||||
WHERE id = $10
|
updated_at = $10
|
||||||
|
WHERE id = $11
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -355,6 +359,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
input.resolutionNote,
|
input.resolutionNote,
|
||||||
input.action === 'upheld' ? input.recoveryAmount : 0,
|
input.action === 'upheld' ? input.recoveryAmount : 0,
|
||||||
pendingDepositDeductionAmount,
|
pendingDepositDeductionAmount,
|
||||||
|
withdrawDeductionAmount,
|
||||||
availableDeductionAmount,
|
availableDeductionAmount,
|
||||||
debtAmount,
|
debtAmount,
|
||||||
input.resolvedBy,
|
input.resolvedBy,
|
||||||
@@ -372,6 +377,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
recoveryAmount: input.action === 'upheld' ? input.recoveryAmount : 0,
|
recoveryAmount: input.action === 'upheld' ? input.recoveryAmount : 0,
|
||||||
pendingDepositDeductionAmount,
|
pendingDepositDeductionAmount,
|
||||||
|
withdrawDeductionAmount,
|
||||||
availableDeductionAmount,
|
availableDeductionAmount,
|
||||||
debtAmount,
|
debtAmount,
|
||||||
resolutionNote: input.resolutionNote,
|
resolutionNote: input.resolutionNote,
|
||||||
@@ -538,8 +544,13 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
|
|||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE worker_after_sales_cases
|
UPDATE worker_after_sales_cases
|
||||||
SET debt_amount = $1,
|
SET debt_amount = $1::bigint,
|
||||||
status = CASE WHEN $1 = 0 AND status = 'recovery_pending' THEN 'closed' ELSE status END,
|
-- 必须显式 ::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
|
updated_at = $2::timestamptz
|
||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
`,
|
`,
|
||||||
@@ -728,6 +739,7 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
pendingDepositDeductionAmount: number
|
pendingDepositDeductionAmount: number
|
||||||
|
withdrawDeductionAmount: number
|
||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
}> {
|
}> {
|
||||||
@@ -740,6 +752,20 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
`,
|
`,
|
||||||
[input.workOrderId, input.workerId],
|
[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)
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||||
let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId)
|
let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
const allocation = resolveAfterSalesRecoveryAllocation({
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
@@ -748,6 +774,10 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
(total, row) => total + Math.max(0, Number(row.amount) || 0),
|
(total, row) => total + Math.max(0, Number(row.amount) || 0),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
|
frozenWithdrawAmount: pendingWithdrawResult.rows.reduce(
|
||||||
|
(total, row) => total + Math.max(0, Number(row.frozen_amount) || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
availableAmount: Number(wallet?.available_amount || 0),
|
availableAmount: Number(wallet?.available_amount || 0),
|
||||||
})
|
})
|
||||||
let pendingRemaining = allocation.pendingDepositDeductionAmount
|
let pendingRemaining = allocation.pendingDepositDeductionAmount
|
||||||
@@ -793,6 +823,88 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
pendingRemaining -= deduction
|
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)
|
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
const availableDeductionAmount = allocation.availableDeductionAmount
|
const availableDeductionAmount = allocation.availableDeductionAmount
|
||||||
if (availableDeductionAmount > 0) {
|
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 =
|
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
|
/(?:^|\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 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 () => {
|
test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async () => {
|
||||||
const offenders: string[] = []
|
const offenders: string[] = []
|
||||||
@@ -34,6 +37,42 @@ test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async
|
|||||||
assert.deepEqual(offenders, [])
|
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 () => {
|
test('摘寄售标记的 SQL 显式把时间戳参数转回 timestamptz', async () => {
|
||||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||||
const detachStatement = source
|
const detachStatement = source
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export type WorkerAfterSalesCaseRow = {
|
|||||||
resolution_note: string
|
resolution_note: string
|
||||||
recovery_amount: number
|
recovery_amount: number
|
||||||
pending_deposit_deduction_amount: number
|
pending_deposit_deduction_amount: number
|
||||||
|
withdraw_deduction_amount: number
|
||||||
available_deduction_amount: number
|
available_deduction_amount: number
|
||||||
debt_amount: number
|
debt_amount: number
|
||||||
refunded_amount?: number
|
refunded_amount?: number
|
||||||
|
|||||||
@@ -547,6 +547,15 @@ export async function reviewWorkerFinanceRequest(input: {
|
|||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
// 驳回退回来的钱同样属于「入账」,和其他入账路径保持一致:先抵扣未结清售后欠款,
|
||||||
|
// 否则会出现「追缴时钱在提现冻结里扣不到、驳回后钱回到余额却不再抵扣」的漏收。
|
||||||
|
await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
|
||||||
|
workerId: Number(current.worker_id),
|
||||||
|
now: input.now,
|
||||||
|
note: '提现驳回退回自动偿还售后欠款',
|
||||||
|
actorType: 'system',
|
||||||
|
actorId: 'withdraw_release',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const FINANCE_EVENTS_CTE = `
|
|||||||
FROM worker_wallet_ledgers wl
|
FROM worker_wallet_ledgers wl
|
||||||
WHERE wl.ledger_type IN (
|
WHERE wl.ledger_type IN (
|
||||||
'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction',
|
'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
|
AND wl.amount <> 0
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export function mapWorkerAfterSalesCase(afterSalesCase: {
|
|||||||
resolution_note: string
|
resolution_note: string
|
||||||
recovery_amount: number
|
recovery_amount: number
|
||||||
pending_deposit_deduction_amount: number
|
pending_deposit_deduction_amount: number
|
||||||
|
withdraw_deduction_amount: number
|
||||||
available_deduction_amount: number
|
available_deduction_amount: number
|
||||||
debt_amount: number
|
debt_amount: number
|
||||||
refunded_amount?: number
|
refunded_amount?: number
|
||||||
@@ -259,6 +260,7 @@ export function mapWorkerAfterSalesCase(afterSalesCase: {
|
|||||||
resolutionNote: String(afterSalesCase.resolution_note || ''),
|
resolutionNote: String(afterSalesCase.resolution_note || ''),
|
||||||
recoveryAmount: Number(afterSalesCase.recovery_amount || 0),
|
recoveryAmount: Number(afterSalesCase.recovery_amount || 0),
|
||||||
pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0),
|
pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0),
|
||||||
|
withdrawDeductionAmount: Number(afterSalesCase.withdraw_deduction_amount || 0),
|
||||||
availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0),
|
availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0),
|
||||||
debtAmount: Number(afterSalesCase.debt_amount || 0),
|
debtAmount: Number(afterSalesCase.debt_amount || 0),
|
||||||
refundedAmount: Number(afterSalesCase.refunded_amount || 0),
|
refundedAmount: Number(afterSalesCase.refunded_amount || 0),
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function normalizeWalletLedgerType(value: unknown) {
|
|||||||
'reward_settlement',
|
'reward_settlement',
|
||||||
'sharing_reward',
|
'sharing_reward',
|
||||||
'after_sales_deposit_deduction',
|
'after_sales_deposit_deduction',
|
||||||
|
'after_sales_withdraw_deduction',
|
||||||
'after_sales_balance_deduction',
|
'after_sales_balance_deduction',
|
||||||
'after_sales_debt_offset',
|
'after_sales_debt_offset',
|
||||||
'after_sales_refund',
|
'after_sales_refund',
|
||||||
|
|||||||
@@ -623,10 +623,12 @@ test('售后追缴优先扣减待解冻押金', () => {
|
|||||||
resolveAfterSalesRecoveryAllocation({
|
resolveAfterSalesRecoveryAllocation({
|
||||||
recoveryAmount: 10_000,
|
recoveryAmount: 10_000,
|
||||||
pendingDepositAmount: 12_000,
|
pendingDepositAmount: 12_000,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
availableAmount: 8_000,
|
availableAmount: 8_000,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
pendingDepositDeductionAmount: 10_000,
|
pendingDepositDeductionAmount: 10_000,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
availableDeductionAmount: 0,
|
availableDeductionAmount: 0,
|
||||||
debtAmount: 0,
|
debtAmount: 0,
|
||||||
},
|
},
|
||||||
@@ -638,10 +640,12 @@ test('售后追缴在押金不足时继续扣减可用余额', () => {
|
|||||||
resolveAfterSalesRecoveryAllocation({
|
resolveAfterSalesRecoveryAllocation({
|
||||||
recoveryAmount: 10_000,
|
recoveryAmount: 10_000,
|
||||||
pendingDepositAmount: 3_000,
|
pendingDepositAmount: 3_000,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
availableAmount: 8_000,
|
availableAmount: 8_000,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
pendingDepositDeductionAmount: 3_000,
|
pendingDepositDeductionAmount: 3_000,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
availableDeductionAmount: 7_000,
|
availableDeductionAmount: 7_000,
|
||||||
debtAmount: 0,
|
debtAmount: 0,
|
||||||
},
|
},
|
||||||
@@ -653,16 +657,35 @@ test('售后追缴在余额不足时形成欠款', () => {
|
|||||||
resolveAfterSalesRecoveryAllocation({
|
resolveAfterSalesRecoveryAllocation({
|
||||||
recoveryAmount: 10_000,
|
recoveryAmount: 10_000,
|
||||||
pendingDepositAmount: 3_000,
|
pendingDepositAmount: 3_000,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
availableAmount: 2_000,
|
availableAmount: 2_000,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
pendingDepositDeductionAmount: 3_000,
|
pendingDepositDeductionAmount: 3_000,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
availableDeductionAmount: 2_000,
|
availableDeductionAmount: 2_000,
|
||||||
debtAmount: 5_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 {
|
function buildOrderItemRow(): OrderItemRow {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -371,6 +371,9 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
|||||||
{item.resolutionAction === 'upheld' ? (
|
{item.resolutionAction === 'upheld' ? (
|
||||||
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
||||||
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
||||||
|
{Number(item.withdrawDeductionAmount || 0) > 0
|
||||||
|
? `(含提现冻结 ${formatMoney(item.withdrawDeductionAmount)},提现已驳回)`
|
||||||
|
: ''}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
{item.refundedAmount > 0 ? (
|
{item.refundedAmount > 0 ? (
|
||||||
@@ -518,7 +521,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
|||||||
label="追缴金额"
|
label="追缴金额"
|
||||||
name="recoveryAmount"
|
name="recoveryAmount"
|
||||||
rules={[{ required: true, message: '请输入追缴金额' }]}
|
rules={[{ required: true, message: '请输入追缴金额' }]}
|
||||||
extra="系统依次扣减该责任打手的本单待解冻金额、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
|
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
|
||||||
>
|
>
|
||||||
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const LEDGER_TYPE_LABELS: Record<string, string> = {
|
|||||||
reward_settlement: '结算报酬',
|
reward_settlement: '结算报酬',
|
||||||
sharing_reward: '拼单报酬',
|
sharing_reward: '拼单报酬',
|
||||||
after_sales_deposit_deduction: '售后扣减押金',
|
after_sales_deposit_deduction: '售后扣减押金',
|
||||||
|
after_sales_withdraw_deduction: '售后扣减提现',
|
||||||
after_sales_balance_deduction: '售后扣减余额',
|
after_sales_balance_deduction: '售后扣减余额',
|
||||||
after_sales_debt_offset: '抵扣售后欠款',
|
after_sales_debt_offset: '抵扣售后欠款',
|
||||||
after_sales_refund: '售后追缴退还',
|
after_sales_refund: '售后追缴退还',
|
||||||
@@ -65,6 +66,7 @@ function resolveLedgerTagColor(ledgerType: string) {
|
|||||||
[
|
[
|
||||||
'deposit_deduction',
|
'deposit_deduction',
|
||||||
'after_sales_deposit_deduction',
|
'after_sales_deposit_deduction',
|
||||||
|
'after_sales_withdraw_deduction',
|
||||||
'after_sales_balance_deduction',
|
'after_sales_balance_deduction',
|
||||||
].includes(ledgerType)
|
].includes(ledgerType)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ function WorkerAfterSalesItem({
|
|||||||
const hasRecoveryDetail =
|
const hasRecoveryDetail =
|
||||||
Number(item.recoveryAmount || 0) > 0 ||
|
Number(item.recoveryAmount || 0) > 0 ||
|
||||||
Number(item.pendingDepositDeductionAmount || 0) > 0 ||
|
Number(item.pendingDepositDeductionAmount || 0) > 0 ||
|
||||||
|
Number(item.withdrawDeductionAmount || 0) > 0 ||
|
||||||
Number(item.availableDeductionAmount || 0) > 0 ||
|
Number(item.availableDeductionAmount || 0) > 0 ||
|
||||||
Number(item.debtAmount || 0) > 0 ||
|
Number(item.debtAmount || 0) > 0 ||
|
||||||
Number(item.refundedAmount || 0) > 0
|
Number(item.refundedAmount || 0) > 0
|
||||||
@@ -720,6 +721,15 @@ function WorkerAfterSalesItem({
|
|||||||
</span>
|
</span>
|
||||||
</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 && (
|
{Number(item.availableDeductionAmount || 0) > 0 && (
|
||||||
<span className="financial-chip is-available">
|
<span className="financial-chip is-available">
|
||||||
<span className="chip-name">余额抵扣</span>
|
<span className="chip-name">余额抵扣</span>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const LEDGER_TYPE_OPTIONS = [
|
|||||||
{ value: 'reward_settlement', label: '结算报酬' },
|
{ value: 'reward_settlement', label: '结算报酬' },
|
||||||
{ value: 'sharing_reward', label: '拼单报酬' },
|
{ value: 'sharing_reward', label: '拼单报酬' },
|
||||||
{ value: 'after_sales_deposit_deduction', label: '售后扣减押金' },
|
{ value: 'after_sales_deposit_deduction', label: '售后扣减押金' },
|
||||||
|
{ value: 'after_sales_withdraw_deduction', label: '售后扣减提现' },
|
||||||
{ value: 'after_sales_balance_deduction', label: '售后扣减余额' },
|
{ value: 'after_sales_balance_deduction', label: '售后扣减余额' },
|
||||||
{ value: 'after_sales_debt_offset', label: '抵扣售后欠款' },
|
{ value: 'after_sales_debt_offset', label: '抵扣售后欠款' },
|
||||||
{ value: 'after_sales_refund', label: '售后追缴退还' },
|
{ value: 'after_sales_refund', label: '售后追缴退还' },
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function formatLedgerType(ledgerType: string) {
|
|||||||
reward_settlement: '结算报酬',
|
reward_settlement: '结算报酬',
|
||||||
sharing_reward: '拼单报酬',
|
sharing_reward: '拼单报酬',
|
||||||
after_sales_deposit_deduction: '售后扣减押金',
|
after_sales_deposit_deduction: '售后扣减押金',
|
||||||
|
after_sales_withdraw_deduction: '售后扣减提现',
|
||||||
after_sales_balance_deduction: '售后扣减余额',
|
after_sales_balance_deduction: '售后扣减余额',
|
||||||
after_sales_debt_offset: '抵扣售后欠款',
|
after_sales_debt_offset: '抵扣售后欠款',
|
||||||
after_sales_refund: '售后追缴退还',
|
after_sales_refund: '售后追缴退还',
|
||||||
@@ -82,6 +83,7 @@ export function resolveLedgerTagColor(ledgerType: string) {
|
|||||||
if (ledgerType === 'reward_settlement') return 'cyan'
|
if (ledgerType === 'reward_settlement') return 'cyan'
|
||||||
if (ledgerType === 'sharing_reward') return 'cyan'
|
if (ledgerType === 'sharing_reward') return 'cyan'
|
||||||
if (ledgerType === 'after_sales_deposit_deduction') return 'red'
|
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_balance_deduction') return 'red'
|
||||||
if (ledgerType === 'after_sales_debt_offset') return 'orange'
|
if (ledgerType === 'after_sales_debt_offset') return 'orange'
|
||||||
if (ledgerType === 'after_sales_refund') return 'green'
|
if (ledgerType === 'after_sales_refund') return 'green'
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export type WorkerAfterSalesCase = {
|
|||||||
resolutionNote: string
|
resolutionNote: string
|
||||||
recoveryAmount: number
|
recoveryAmount: number
|
||||||
pendingDepositDeductionAmount: number
|
pendingDepositDeductionAmount: number
|
||||||
|
withdrawDeductionAmount: number
|
||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
refundedAmount: number
|
refundedAmount: number
|
||||||
|
|||||||
Reference in New Issue
Block a user