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)
})
}
@@ -4,6 +4,7 @@ import {
createAdminAfterSalesCase,
getAdminAfterSalesCaseEvents,
listAdminAfterSalesCases,
repayAdminAfterSalesCaseDebt,
resolveAdminAfterSalesCase,
} from '../../../services/worker-platform/index.js'
import { createJsonHandler, requireAdminRoles } from '../session.js'
@@ -78,4 +79,28 @@ router.post(
),
)
router.post(
'/worker-platform/after-sales-cases/:caseId/repay',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(
(req) =>
repayAdminAfterSalesCaseDebt(
String(req.params.caseId || ''),
req.body || {},
req.adminSession?.username || '',
),
{
successMessage: '售后欠款已偿还',
errorMessage: '偿还售后欠款失败',
scope: '[admin/worker-platform/after-sales-cases/:caseId/repay]',
audit: (req, data) => ({
action: 'after_sales_case_debt_repaid',
targetType: 'after_sales_case',
targetId: String(req.params.caseId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
export default router
@@ -5,6 +5,7 @@ import {
listAfterSalesCaseEvents,
listAfterSalesCases,
listWorkerAfterSalesCases,
repayAfterSalesCaseDebt,
resolveAfterSalesCase,
submitWorkerAfterSalesResponse,
} from '../../repositories/worker-platform/after-sales-repo.js'
@@ -230,6 +231,80 @@ export async function resolveAdminAfterSalesCase(
}
}
export async function repayAdminAfterSalesCaseDebt(
caseId: number | string,
payload: JsonObject = {},
actorName = '',
) {
const afterSalesCase = await getAfterSalesCaseById(caseId)
if (!afterSalesCase) {
throw createHttpError('售后问题单不存在', {
statusCode: 404,
errorCode: 'after_sales_case_not_found',
})
}
const note = String(payload.note || payload.resolutionNote || '后台手动偿还售后欠款').trim()
if (!note) {
throw createHttpError('请填写偿还备注', {
statusCode: 400,
errorCode: 'after_sales_case_repay_note_required',
})
}
if (note.length > 2_000) {
throw createHttpError('偿还备注不能超过 2000 个字符', {
statusCode: 400,
errorCode: 'after_sales_case_repay_note_too_long',
})
}
const amountRaw = payload.amount ?? payload.repayAmount
const amount =
amountRaw === undefined || amountRaw === null || String(amountRaw).trim() === ''
? null
: normalizeAmountFen(amountRaw, 0)
if (amount !== null && amount <= 0) {
throw createHttpError('偿还金额必须大于 0', {
statusCode: 400,
errorCode: 'after_sales_case_repay_amount_invalid',
})
}
const result = await repayAfterSalesCaseDebt({
caseId: Number(caseId),
amount,
note,
repaidBy: actorName,
now: nowIso(),
})
if (result.failureReason === 'not_found' || !result.case) {
throw createHttpError('售后问题单不存在', {
statusCode: 404,
errorCode: 'after_sales_case_not_found',
})
}
if (result.failureReason === 'no_open_debt') {
throw createHttpError('该售后问题单没有待还欠款', {
statusCode: 409,
errorCode: 'after_sales_case_no_open_debt',
})
}
if (result.failureReason === 'insufficient_balance') {
throw createHttpError('打手可用余额不足,无法偿还欠款', {
statusCode: 409,
errorCode: 'after_sales_case_repay_insufficient_balance',
})
}
const workerId = Number(result.case.worker_id)
publishWorkOrderRealtimeChange({
workOrderId: Number(result.case.work_order_id),
workerIds: workerId > 0 ? [workerId] : [],
})
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
return {
case: mapWorkerAfterSalesCase(result.case),
repaidAmount: result.repaidAmount,
availableAfter: result.availableAfter,
}
}
export async function getWorkerAfterSalesCaseSummary(session: WorkerSession) {
requireActiveWorkerSession(session)
return { items: (await listWorkerAfterSalesCases(session.workerId)).map(mapWorkerAfterSalesCase) }