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) }
@@ -23,6 +23,7 @@ import {
createAdminAfterSalesCase,
fetchAdminAfterSalesCases,
fetchAdminWorkOrderSharing,
repayAdminAfterSalesCaseDebt,
resolveAdminAfterSalesCase,
} from '@/services/admin'
import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform'
@@ -43,6 +44,11 @@ type AfterSalesResolutionFormValues = {
recoveryAmount?: number
}
type AfterSalesRepayFormValues = {
amount?: number
note?: string
}
export type AfterSalesPanelHandle = {
openCreateModal: (row: WorkOrder) => void
}
@@ -52,11 +58,13 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
const queryClient = useQueryClient()
const [afterSalesOrder, setAfterSalesOrder] = useState<WorkOrder | null>(null)
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
const [repayCase, setRepayCase] = useState<WorkerAfterSalesCase | null>(null)
const [afterSalesFiles, setAfterSalesFiles] = useState<UploadedFile[]>([])
const [afterSalesPage, setAfterSalesPage] = useState(1)
const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
const [afterSalesRepayForm] = Form.useForm<AfterSalesRepayFormValues>()
const afterSalesSharingQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
@@ -147,6 +155,31 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
afterSalesResolutionForm.resetFields()
}
function openAfterSalesRepayModal(item: WorkerAfterSalesCase) {
setRepayCase(item)
afterSalesRepayForm.setFieldsValue({
amount: Number(item.debtAmount || 0) / 100,
note: '后台手动偿还售后欠款',
})
}
async function submitAfterSalesRepay(values: AfterSalesRepayFormValues) {
if (!repayCase) return
const succeeded = await runAction(
() =>
repayAdminAfterSalesCaseDebt(repayCase.caseId, {
amount: Number(values.amount || 0),
note: String(values.note || '').trim(),
}),
(result) =>
`已偿还 ${formatMoney(result.data?.repaidAmount || 0)},余额剩余 ${formatMoney(result.data?.availableAfter || 0)}`,
)
if (!succeeded) return
setRepayCase(null)
afterSalesRepayForm.resetFields()
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-workers'] })
}
return (
<>
<Card
@@ -277,7 +310,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
},
{
title: '操作',
width: 120,
width: 160,
render: (_, item) =>
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
<Button
@@ -287,6 +320,10 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
>
处置
</Button>
) : item.status === 'recovery_pending' && Number(item.debtAmount || 0) > 0 ? (
<Button type="primary" size="small" danger onClick={() => openAfterSalesRepayModal(item)}>
手动偿还
</Button>
) : (
<Typography.Text type="secondary">已完成</Typography.Text>
),
@@ -403,7 +440,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>
@@ -424,6 +461,50 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
</Form>
) : null}
</Modal>
<Modal
title={`手动偿还欠款 · ${repayCase?.caseNo || ''}`}
open={Boolean(repayCase)}
onCancel={() => {
setRepayCase(null)
afterSalesRepayForm.resetFields()
}}
onOk={() => afterSalesRepayForm.submit()}
okText="确认偿还"
destroyOnHidden
>
{repayCase ? (
<Form form={afterSalesRepayForm} layout="vertical" onFinish={submitAfterSalesRepay}>
<Alert
showIcon
type="info"
style={{ marginBottom: 16 }}
message={`将从打手「${repayCase.workerName || '-'}」可用余额扣减,当前待还 ${formatMoney(repayCase.debtAmount)}。`}
/>
<Form.Item
label="偿还金额"
name="amount"
rules={[{ required: true, message: '请输入偿还金额' }]}
extra="默认偿还全部待还;也可填写部分金额。"
>
<InputNumber
min={0.01}
max={Number(repayCase.debtAmount || 0) / 100}
precision={2}
addonAfter="元"
className="full-width"
/>
</Form.Item>
<Form.Item
label="偿还备注"
name="note"
rules={[{ required: true, message: '请填写偿还备注' }]}
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
) : null}
</Modal>
</>
)
})
@@ -582,6 +582,20 @@ export function resolveAdminAfterSalesCase(
}>(`/api/v1/admin/worker-platform/after-sales-cases/${caseId}/resolve`, payload)
}
export function repayAdminAfterSalesCaseDebt(
caseId: number,
payload: {
amount?: number | null
note?: string
} = {},
) {
return apiPost<{
case: WorkerAfterSalesCase
repaidAmount: number
availableAfter: number
}>(`/api/v1/admin/worker-platform/after-sales-cases/${caseId}/repay`, payload)
}
export function fetchAdminWorkOrderEvents(workOrderId: number) {
return apiGet<{ items: WorkOrderEvent[] }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/events`,
+1
View File
@@ -53,6 +53,7 @@ export function formatAuditAction(action: string) {
work_order_pending_deposit_deducted: '押金扣除',
after_sales_case_created: '售后问题单创建',
after_sales_case_resolved: '售后问题单处置',
after_sales_case_debt_repaid: '售后欠款手动偿还',
worker_level_saved: '接单等级保存',
worker_level_deleted: '接单等级删除',
work_category_saved: '接单分类保存',
+1
View File
@@ -80,6 +80,7 @@ export const adminAuditActionOptions = [
{ label: '押金扣除', value: 'work_order_pending_deposit_deducted' },
{ label: '售后问题单创建', value: 'after_sales_case_created' },
{ label: '售后问题单处置', value: 'after_sales_case_resolved' },
{ label: '售后欠款手动偿还', value: 'after_sales_case_debt_repaid' },
{ label: '接单等级保存', value: 'worker_level_saved' },
{ label: '接单等级删除', value: 'worker_level_deleted' },
{ label: '接单分类保存', value: 'work_category_saved' },