fix(worker-platform): 修复售后欠款抵扣导致验收失败,并支持余额偿还

有待还欠款时结算写入 settled_at 发生 timestamptz/text 类型错误会整单 500;顺带支持充值/解冻自动偿还与后台手动偿还。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-13 20:44:35 +08:00
co-authored by Cursor
parent aba508f4f6
commit a49db84771
10 changed files with 409 additions and 11 deletions
@@ -393,7 +393,8 @@ export async function resolveAfterSalesCase(input: {
await client.query(
`
UPDATE worker_after_sales_debts
SET outstanding_amount = 0, status = 'settled', settled_at = $1, updated_at = $1
SET outstanding_amount = 0, status = 'settled',
settled_at = $1::timestamptz, updated_at = $1::timestamptz
WHERE case_id = $2 AND status = 'open'
`,
[input.now, Number(priorCase.id)],
@@ -470,21 +471,38 @@ export async function resolveAfterSalesCase(input: {
/** 在验收结算同一事务中抵扣未结清售后欠款,返回本次各售后单的抵扣明细。 */
export async function offsetWorkerAfterSalesDebtsWithClient(
client: PoolClient,
input: { workerId: number; amount: number; workOrderId: number; now: string },
input: {
workerId: number
amount: number
workOrderId: number | null
now: string
caseId?: number
actorType?: string
actorId?: string
},
): Promise<Array<{ caseId: number; amount: number }>> {
let remaining = Math.max(0, input.amount)
if (remaining <= 0) return []
const params: unknown[] = [input.workerId]
let caseFilter = ''
if (input.caseId && input.caseId > 0) {
params.push(input.caseId)
caseFilter = ` AND case_id = $${params.length}`
}
const debtsResult = await client.query<WorkerAfterSalesDebtRow>(
`
SELECT *
FROM worker_after_sales_debts
WHERE worker_id = $1 AND status = 'open' AND outstanding_amount > 0
${caseFilter}
ORDER BY created_at ASC, id ASC
FOR UPDATE
`,
[input.workerId],
params,
)
const offsets: Array<{ caseId: number; amount: number }> = []
const actorType = String(input.actorType || 'system').trim() || 'system'
const actorId = String(input.actorId || '').trim()
for (const debt of debtsResult.rows) {
if (remaining <= 0) break
const outstanding = Number(debt.outstanding_amount || 0)
@@ -495,8 +513,8 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
`
UPDATE worker_after_sales_debts
SET outstanding_amount = $1, status = $2,
settled_at = CASE WHEN $2 = 'settled' THEN $3 ELSE NULL END,
updated_at = $3
settled_at = CASE WHEN $2 = 'settled' THEN $3::timestamptz ELSE NULL END,
updated_at = $3::timestamptz
WHERE id = $4
`,
[nextOutstanding, settled ? 'settled' : 'open', input.now, debt.id],
@@ -506,17 +524,21 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
UPDATE worker_after_sales_cases
SET debt_amount = $1,
status = CASE WHEN $1 = 0 AND status = 'recovery_pending' THEN 'closed' ELSE status END,
updated_at = $2
updated_at = $2::timestamptz
WHERE id = $3
`,
[nextOutstanding, input.now, debt.case_id],
)
await createAfterSalesCaseEventWithClient(client, {
caseId: Number(debt.case_id),
actorType: 'system',
actorId: '',
actorType,
actorId,
eventType: settled ? 'debt_settled' : 'debt_offset',
payloadJson: JSON.stringify({ workOrderId: input.workOrderId, amount: offsetAmount }),
payloadJson: JSON.stringify({
workOrderId: input.workOrderId,
amount: offsetAmount,
source: actorType === 'admin' ? 'manual_repay' : 'settlement_offset',
}),
now: input.now,
})
offsets.push({ caseId: Number(debt.case_id), amount: offsetAmount })
@@ -525,6 +547,159 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
return offsets
}
/**
* 从打手可用余额偿还售后欠款(充值入账或后台手动偿还)。
* 不传 caseId 时按创建时间 FIFO 抵扣该打手全部 open 欠款。
*/
export async function repayWorkerAfterSalesDebtsFromAvailableWithClient(
client: PoolClient,
input: {
workerId: number
now: string
caseId?: number
maxAmount?: number
workOrderId?: number | null
note?: string
actorType?: string
actorId?: string
},
): Promise<{
repaidTotal: number
offsets: Array<{ caseId: number; amount: number }>
availableAfter: number
}> {
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, input.workerId)
const availableAmount = Math.max(0, Number(wallet?.available_amount || 0))
const budget = Math.min(
availableAmount,
input.maxAmount !== undefined && input.maxAmount !== null
? Math.max(0, Number(input.maxAmount) || 0)
: availableAmount,
)
if (budget <= 0) {
return { repaidTotal: 0, offsets: [], availableAfter: availableAmount }
}
const offsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
workerId: input.workerId,
amount: budget,
workOrderId: input.workOrderId ?? null,
now: input.now,
...(input.caseId ? { caseId: input.caseId } : {}),
...(input.actorType ? { actorType: input.actorType } : {}),
...(input.actorId ? { actorId: input.actorId } : {}),
})
const repaidTotal = offsets.reduce((total, item) => total + item.amount, 0)
if (repaidTotal <= 0) {
return { repaidTotal: 0, offsets: [], availableAfter: availableAmount }
}
const nextAvailable = availableAmount - repaidTotal
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0)
await client.query(
`UPDATE worker_wallets SET available_amount = $1, updated_at = $2::timestamptz WHERE worker_id = $3`,
[nextAvailable, input.now, input.workerId],
)
for (const debtOffset of offsets) {
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_debt_offset', $2, $3, $4, $5, $6, $7::jsonb, $8::timestamptz)
`,
[
input.workerId,
-debtOffset.amount,
nextAvailable,
nextFrozen,
input.workOrderId ?? null,
String(input.note || '可用余额偿还售后欠款').trim() || '可用余额偿还售后欠款',
JSON.stringify({
caseId: debtOffset.caseId,
workOrderId: input.workOrderId ?? null,
source: input.actorType === 'admin' ? 'manual_repay' : 'balance_repay',
}),
input.now,
],
)
}
return { repaidTotal, offsets, availableAfter: nextAvailable }
}
/** 后台按售后单手动从可用余额偿还欠款。 */
export async function repayAfterSalesCaseDebt(input: {
caseId: number
amount?: number | null
note: string
repaidBy: string
now: string
}): Promise<{
case: WorkerAfterSalesCaseRow | null
repaidAmount: number
availableAfter: number
failureReason: 'not_found' | 'no_open_debt' | 'insufficient_balance' | null
}> {
return withTransaction(async (client) => {
const caseResult = await client.query<WorkerAfterSalesCaseRow>(
`SELECT * FROM worker_after_sales_cases WHERE id = $1 FOR UPDATE`,
[input.caseId],
)
const afterSalesCase = caseResult.rows[0] || null
if (!afterSalesCase) {
return { case: null, repaidAmount: 0, availableAfter: 0, failureReason: 'not_found' }
}
const debtAmount = Math.max(0, Number(afterSalesCase.debt_amount || 0))
if (debtAmount <= 0 || afterSalesCase.status !== 'recovery_pending') {
return {
case: afterSalesCase,
repaidAmount: 0,
availableAfter: 0,
failureReason: 'no_open_debt',
}
}
const requestedAmount =
input.amount !== undefined && input.amount !== null
? Math.max(0, Number(input.amount) || 0)
: debtAmount
if (requestedAmount <= 0) {
return {
case: afterSalesCase,
repaidAmount: 0,
availableAfter: 0,
failureReason: 'no_open_debt',
}
}
const result = await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
workerId: Number(afterSalesCase.worker_id),
now: input.now,
caseId: Number(afterSalesCase.id),
maxAmount: Math.min(requestedAmount, debtAmount),
workOrderId: Number(afterSalesCase.work_order_id),
note: input.note,
actorType: 'admin',
actorId: input.repaidBy,
})
if (result.repaidTotal <= 0) {
return {
case: afterSalesCase,
repaidAmount: 0,
availableAfter: result.availableAfter,
failureReason: 'insufficient_balance',
}
}
const refreshed = await client.query<WorkerAfterSalesCaseRow>(
`${AFTER_SALES_CASE_SELECT} WHERE ac.id = $1`,
[input.caseId],
)
return {
case: refreshed.rows[0] || afterSalesCase,
repaidAmount: result.repaidTotal,
availableAfter: result.availableAfter,
failureReason: null,
}
})
}
async function recoverAfterSalesAmountWithClient(
client: PoolClient,
input: {
@@ -1,4 +1,5 @@
import { withTransaction } from '../../db/client.js'
import { repayWorkerAfterSalesDebtsFromAvailableWithClient } from './after-sales-repo.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import type { WorkerDepositUnfreezeRow } from './types.js'
import type { PoolClient } from 'pg'
@@ -232,6 +233,14 @@ export async function releaseDepositUnfreeze({
now,
],
)
await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
workerId: Number(unfreeze.worker_id),
now,
workOrderId: Number(unfreeze.work_order_id),
note: '解冻入账自动偿还售后欠款',
actorType: 'system',
actorId: 'deposit_unfreeze',
})
}
return {
@@ -1,4 +1,5 @@
import { query, withTransaction } from '../../db/client.js'
import { repayWorkerAfterSalesDebtsFromAvailableWithClient } from './after-sales-repo.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import type {
FinanceRequestListInput,
@@ -485,6 +486,13 @@ export async function reviewWorkerFinanceRequest(input: {
input.now,
],
)
await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
workerId: Number(current.worker_id),
now: input.now,
note: '充值审核入账自动偿还售后欠款',
actorType: 'system',
actorId: 'finance_recharge',
})
}
} else if (
input.status === 'rejected' &&
@@ -1,4 +1,5 @@
import { query, withTransaction } from '../../db/client.js'
import { repayWorkerAfterSalesDebtsFromAvailableWithClient } from './after-sales-repo.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import type { WalletLedgerListInput, WorkerWalletLedgerRow, WorkerWalletRow } from './types.js'
@@ -78,6 +79,14 @@ export async function addWorkerWalletCredit(input: {
input.now,
],
)
// 入账后自动偿还未结清售后欠款,避免余额够了却仍卡在追缴中。
await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
workerId: input.workerId,
now: input.now,
note: '充值入账自动偿还售后欠款',
actorType: 'system',
actorId: 'wallet_credit',
})
return getWorkerWalletWithClient(client, input.workerId)
})
}