diff --git a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts index e71cfdc6..74aa0a72 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -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> { 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( ` 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( + `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( + `${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: { diff --git a/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts index 79144bb5..6ccfa613 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts @@ -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 { diff --git a/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts b/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts index 3d129bb4..e143acce 100644 --- a/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-finance-repo.ts @@ -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' && diff --git a/apps/backend/src/repositories/worker-platform/worker-wallet-repo.ts b/apps/backend/src/repositories/worker-platform/worker-wallet-repo.ts index 06fe2a0b..37687fb5 100644 --- a/apps/backend/src/repositories/worker-platform/worker-wallet-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-wallet-repo.ts @@ -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) }) } diff --git a/apps/backend/src/routes/admin/worker-platform/after-sales.ts b/apps/backend/src/routes/admin/worker-platform/after-sales.ts index 97a48326..811bead8 100644 --- a/apps/backend/src/routes/admin/worker-platform/after-sales.ts +++ b/apps/backend/src/routes/admin/worker-platform/after-sales.ts @@ -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) : {}, + }), + }, + ), +) + export default router diff --git a/apps/backend/src/services/worker-platform/after-sales-service.ts b/apps/backend/src/services/worker-platform/after-sales-service.ts index 6219a069..5cf9a763 100644 --- a/apps/backend/src/services/worker-platform/after-sales-service.ts +++ b/apps/backend/src/services/worker-platform/after-sales-service.ts @@ -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) } diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index 23540b9f..7d493da0 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -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(function AfterSalesPan const queryClient = useQueryClient() const [afterSalesOrder, setAfterSalesOrder] = useState(null) const [afterSalesCase, setAfterSalesCase] = useState(null) + const [repayCase, setRepayCase] = useState(null) const [afterSalesFiles, setAfterSalesFiles] = useState([]) const [afterSalesPage, setAfterSalesPage] = useState(1) const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [afterSalesCreateForm] = Form.useForm() const [afterSalesResolutionForm] = Form.useForm() + const [afterSalesRepayForm] = Form.useForm() const afterSalesSharingQuery = useQuery({ queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId], @@ -147,6 +155,31 @@ const AfterSalesPanel = forwardRef(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 ( <> (function AfterSalesPan }, { title: '操作', - width: 120, + width: 160, render: (_, item) => ['awaiting_worker_response', 'under_review'].includes(item.status) ? ( + ) : item.status === 'recovery_pending' && Number(item.debtAmount || 0) > 0 ? ( + ) : ( 已完成 ), @@ -403,7 +440,7 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan label="追缴金额" name="recoveryAmount" rules={[{ required: true, message: '请输入追缴金额' }]} - extra="系统依次扣减该责任打手的本单待解冻金额、可用余额;不足部分转为欠款,并从后续验收报酬自动抵扣。" + extra="系统依次扣减该责任打手的本单待解冻金额、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。" > @@ -424,6 +461,50 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan ) : null} + + { + setRepayCase(null) + afterSalesRepayForm.resetFields() + }} + onOk={() => afterSalesRepayForm.submit()} + okText="确认偿还" + destroyOnHidden + > + {repayCase ? ( +
+ + + + + + + + + ) : null} +
) }) diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 419ee73f..3ed02648 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -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`, diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 865d2bb9..0590245b 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -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: '接单分类保存', diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 545b0fa4..16b576c0 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -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' },