Files
order_site/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx
T
yml2213 c7d650b482 fix(worker): 售后追缴自动驳回提现并扣冻结资金,修复欠款抵扣 500
- 修复欠款抵扣 SQL 参数类型冲突(debt_amount = $1 与 $1 = 0 推断不一致),
  此前手动偿还、验收报酬抵扣、充值/押金解冻/寄卖自动抵扣都会 500
- 追缴顺序改为 待解冻押金 → 提现冻结 → 可用余额 → 欠款:
  驳回待审核提现并直接扣走冻结资金,扣不完的部分退回可用余额
- 迁移 109:售后单记录 withdraw_deduction_amount,补 pending 提现部分索引
- 新增流水类型 after_sales_withdraw_deduction,前后端展示与标签同步
- 提现驳回退回余额后补调欠款自动抵扣,与充值/结算/解冻/寄卖路径对齐
- 补追缴分配单测与 SQL 参数类型静态门禁
2026-10-29 19:05:37 +08:00

667 lines
25 KiB
TypeScript

import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Alert,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { forwardRef, useImperativeHandle, useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
createAdminAfterSalesCase,
fetchAdminAfterSalesCases,
fetchAdminWorkOrderSharing,
repayAdminAfterSalesCaseDebt,
resolveAdminAfterSalesCase,
} from '@/services/admin'
import type {
UploadedFile,
WorkerAcceptanceVideo,
WorkerAfterSalesCase,
WorkOrder,
} from '@/types/worker-platform'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { normalizeSearchText } from '@/utils/admin-realtime-work-orders'
import { formatAdminDateTime } from '@/utils/admin-time'
import { uniqueUploadedFiles } from '@/utils/image-identity'
import { asRecord, formatMoney } from './shared'
type AfterSalesCreateFormValues = {
shareId?: number
problemType?: string
complaintNote?: string
}
type AfterSalesResolutionFormValues = {
action?: 'not_upheld' | 'remedied' | 'upheld'
resolutionNote?: string
recoveryAmount?: number
}
type AfterSalesRepayFormValues = {
amount?: number
note?: string
}
/** 售后单状态筛选(与列表标签同一套文案)。 */
const AFTER_SALES_STATUS_OPTIONS = [
{ value: 'awaiting_worker_response', label: '待打手说明' },
{ value: 'under_review', label: '客服处理中' },
{ value: 'recovery_pending', label: '追缴中' },
{ value: 'closed', label: '已关闭' },
]
export type AfterSalesPanelHandle = {
openCreateModal: (row: WorkOrder) => void
}
const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPanel(_, ref) {
const { message } = App.useApp()
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)
/** 搜索框输入值;点搜索/回车后才写入 afterSalesKeyword,避免每敲一个字就请求。 */
const [afterSalesKeywordInput, setAfterSalesKeywordInput] = useState('')
const [afterSalesKeyword, setAfterSalesKeyword] = useState('')
const [afterSalesStatus, setAfterSalesStatus] = useState('')
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],
queryFn: () => fetchAdminWorkOrderSharing(Number(afterSalesOrder?.workOrderId)),
enabled: Boolean(afterSalesOrder?.sharing?.enabled),
})
const afterSalesCasesQuery = useQuery({
queryKey: [
'admin-worker-platform-after-sales-cases',
afterSalesPage,
afterSalesPageSize,
afterSalesKeyword,
afterSalesStatus,
],
queryFn: () =>
fetchAdminAfterSalesCases({
page: afterSalesPage,
pageSize: afterSalesPageSize,
...(afterSalesKeyword ? { keyword: afterSalesKeyword } : {}),
...(afterSalesStatus ? { status: afterSalesStatus } : {}),
}),
})
useImperativeHandle(ref, () => ({
openCreateModal: (row: WorkOrder) => {
setAfterSalesOrder(row)
setAfterSalesFiles([])
afterSalesCreateForm.setFieldsValue({
shareId: undefined,
problemType: 'fake_evidence',
complaintNote: '',
})
},
}))
async function runAction<T>(
action: () => Promise<T>,
successMessage: string | ((result: T) => string),
) {
try {
const result = await action()
message.success(
typeof successMessage === 'function' ? successMessage(result) : successMessage,
)
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-after-sales-cases'],
})
return true
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
return false
}
}
async function submitAfterSalesCreate(values: AfterSalesCreateFormValues) {
if (!afterSalesOrder) return
const succeeded = await runAction(
() =>
createAdminAfterSalesCase(afterSalesOrder.workOrderId, {
shareId: values.shareId || null,
problemType: values.problemType || 'fake_evidence',
complaintNote: String(values.complaintNote || '').trim(),
files: afterSalesFiles,
}),
'售后问题单已创建,已通知责任打手说明',
)
if (!succeeded) return
setAfterSalesOrder(null)
setAfterSalesFiles([])
afterSalesCreateForm.resetFields()
}
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
afterSalesResolutionForm.setFieldsValue({
action: 'upheld',
resolutionNote: '',
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
})
}
async function submitAfterSalesResolution(values: AfterSalesResolutionFormValues) {
const action = values.action
if (!afterSalesCase || !action) return
const succeeded = await runAction(
() =>
resolveAdminAfterSalesCase(afterSalesCase.caseId, {
action,
resolutionNote: String(values.resolutionNote || '').trim(),
recoveryAmount: action === 'upheld' ? Number(values.recoveryAmount || 0) : 0,
}),
(result) =>
(result.data?.refundedTotalAmount || 0) > 0
? `售后问题单已处置,已退还原追缴 ${formatMoney(result.data.refundedTotalAmount)} 至打手可用余额`
: '售后问题单已处置',
)
if (!succeeded) return
setAfterSalesCase(null)
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
title="验收后售后问题单"
extra={
<Button
icon={<ReloadOutlined />}
loading={afterSalesCasesQuery.isFetching}
onClick={() => afterSalesCasesQuery.refetch()}
>
刷新
</Button>
}
bordered={false}
>
<div style={{ marginBottom: 16 }}>
<Space wrap>
<Input.Search
allowClear
value={afterSalesKeywordInput}
placeholder="售后单号 / 工单号 / 平台号 / 责任打手"
style={{ width: 340 }}
onChange={(event) => {
const nextValue = event.target.value
setAfterSalesKeywordInput(nextValue)
// 清空输入立即恢复全量,不用再点一次搜索。
if (!nextValue.trim()) {
setAfterSalesKeyword('')
setAfterSalesPage(1)
}
}}
onSearch={(value) => {
// 归一化(全角→半角、去零宽字符):粘贴来的单号常夹带不可见字符。
setAfterSalesKeyword(normalizeSearchText(value))
setAfterSalesPage(1)
}}
/>
<Select
allowClear
value={afterSalesStatus || undefined}
placeholder="全部状态"
style={{ width: 160 }}
options={AFTER_SALES_STATUS_OPTIONS}
onChange={(value) => {
setAfterSalesStatus(String(value || ''))
setAfterSalesPage(1)
}}
/>
</Space>
</div>
<PercentTable<WorkerAfterSalesCase>
rowKey="caseId"
loading={afterSalesCasesQuery.isLoading}
dataSource={afterSalesCasesQuery.data?.data.items || []}
pagination={buildAdminTablePagination({
current: afterSalesCasesQuery.data?.data.pagination?.page || afterSalesPage,
pageSize: afterSalesCasesQuery.data?.data.pagination?.pageSize || afterSalesPageSize,
total: afterSalesCasesQuery.data?.data.pagination?.total || 0,
onChange: (nextPage, nextPageSize) => {
setAfterSalesPage(nextPage)
setAfterSalesPageSize(nextPageSize)
},
})}
scroll={{ x: 1160 }}
columns={[
{
title: '售后单',
width: 190,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text strong>{item.caseNo}</Typography.Text>
<Typography.Text type="secondary">{item.workOrderNo || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatAdminDateTime(item.createdAt)}
</Typography.Text>
</div>
),
},
{
title: '责任打手',
width: 150,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
{item.shareQuantity ? (
<Tag color="purple">拼单 {item.shareQuantity} 份</Tag>
) : null}
</div>
),
},
{
title: '投诉与证据',
width: 260,
render: (_, item) => (
<div className="cell-stack">
<Tag color="red">{formatAfterSalesProblemType(item.problemType)}</Tag>
<Typography.Text ellipsis={{ tooltip: item.complaintNote }}>
{item.complaintNote || '-'}
</Typography.Text>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={36} maxVisible={3} />
) : null}
{getAfterSalesSnapshotFiles(item).length > 0 ? (
<Space size={6} align="center">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
固化验收图
</Typography.Text>
<ImagePreviewList
files={getAfterSalesSnapshotFiles(item)}
size={36}
maxVisible={3}
/>
</Space>
) : null}
{getAfterSalesSnapshotVideo(item) ? (
<a
href={getAfterSalesSnapshotVideo(item)?.url}
target="_blank"
rel="noreferrer"
>
<VideoCameraOutlined /> 查看固化验收视频
</a>
) : null}
</div>
),
},
{
title: '打手说明',
width: 250,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text ellipsis={{ tooltip: item.workerResponse }}>
{item.workerResponse || '暂未提交'}
</Typography.Text>
{item.workerResponseFiles.length > 0 ? (
<ImagePreviewList files={item.workerResponseFiles} size={36} maxVisible={3} />
) : null}
{item.respondedAt ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatAdminDateTime(item.respondedAt)}
</Typography.Text>
) : null}
</div>
),
},
{
title: '处置与追缴',
width: 240,
render: (_, item) => (
<div className="cell-stack">
<Tag color={resolveAfterSalesStatusColor(item.status)}>
{formatAfterSalesStatus(item.status)}
</Tag>
{item.resolutionAction ? (
<Typography.Text type="secondary" ellipsis={{ tooltip: item.resolutionNote }}>
{formatAfterSalesResolution(item.resolutionAction)} ·{' '}
{item.resolutionNote || '-'}
</Typography.Text>
) : null}
{item.resolutionAction === 'upheld' ? (
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
{Number(item.withdrawDeductionAmount || 0) > 0
? `(含提现冻结 ${formatMoney(item.withdrawDeductionAmount)},提现已驳回)`
: ''}
</Typography.Text>
) : null}
{item.refundedAmount > 0 ? (
<Typography.Text type="success">
已退还 {formatMoney(item.refundedAmount)}
</Typography.Text>
) : null}
</div>
),
},
{
title: '操作',
width: 160,
render: (_, item) =>
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
<Button
type="primary"
size="small"
onClick={() => openAfterSalesResolutionModal(item)}
>
处置
</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>
),
},
]}
/>
</Card>
<Modal
title={`发起验收后售后 · ${afterSalesOrder ? String(afterSalesOrder.platformOrderId || '').trim() || '-' : ''}`}
open={Boolean(afterSalesOrder)}
onCancel={() => {
setAfterSalesOrder(null)
setAfterSalesFiles([])
afterSalesCreateForm.resetFields()
}}
onOk={() => afterSalesCreateForm.submit()}
destroyOnHidden
>
{afterSalesOrder ? (
<Form form={afterSalesCreateForm} layout="vertical" onFinish={submitAfterSalesCreate}>
<Alert
showIcon
type="warning"
message="售后不会回退已验收工单;原验收资料会在创建时固化。"
style={{ marginBottom: 16 }}
/>
{(afterSalesSharingQuery.data?.data.shares || []).some(
(share) => share.status === 'accepted',
) ? (
<Form.Item
label="责任拼单份额"
name="shareId"
rules={[{ required: true, message: '请选择责任打手的已验收份额' }]}
>
<Select
loading={afterSalesSharingQuery.isLoading}
placeholder="请选择责任拼单份额"
options={(afterSalesSharingQuery.data?.data.shares || [])
.filter((share) => share.status === 'accepted')
.map((share) => ({
value: share.shareId,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="问题类型" name="problemType" rules={[{ required: true }]}>
<Select
options={[
{ value: 'fake_evidence', label: '疑似 P 图 / 伪造验收' },
{ value: 'result_not_met', label: '实际结果未达标' },
{ value: 'other', label: '其他售后问题' },
]}
/>
</Form.Item>
<Form.Item
label="用户投诉说明"
name="complaintNote"
rules={[{ required: true, message: '请填写用户投诉说明' }]}
>
<Input.TextArea rows={4} placeholder="记录用户反馈、核查结论及需打手说明的事项" />
</Form.Item>
<Form.Item label="用户证据图片">
<ImageUpload
scene="collect-material"
scope="admin"
value={afterSalesFiles}
onChange={setAfterSalesFiles}
maxCount={10}
showPasteHint
/>
</Form.Item>
</Form>
) : null}
</Modal>
<Modal
title={`处置售后问题单 · ${afterSalesCase?.caseNo || ''}`}
open={Boolean(afterSalesCase)}
onCancel={() => {
setAfterSalesCase(null)
afterSalesResolutionForm.resetFields()
}}
onOk={() => afterSalesResolutionForm.submit()}
destroyOnHidden
>
{afterSalesCase ? (
<Form
form={afterSalesResolutionForm}
layout="vertical"
onFinish={submitAfterSalesResolution}
>
<Form.Item
label="处置结论"
name="action"
rules={[{ required: true, message: '请选择处置结论' }]}
>
<Select
options={[
{ value: 'upheld', label: '问题成立,执行追缴' },
{ value: 'remedied', label: '已补救完成,不追缴' },
{ value: 'not_upheld', label: '问题不成立,关闭售后' },
]}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(previous, current) => previous.action !== current.action}
>
{({ getFieldValue }) =>
getFieldValue('action') === 'upheld' ? (
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
) : (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
选择补救完成或问题不成立时,系统自动将该工单同一责任打手此前已追缴且未退还的金额退还至其可用余额。
</Typography.Paragraph>
)
}
</Form.Item>
<Form.Item
label="处置备注"
name="resolutionNote"
rules={[{ required: true, message: '请填写处置备注' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
</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>
</>
)
})
function formatAfterSalesProblemType(value: string): string {
if (value === 'fake_evidence') return '疑似 P 图 / 伪造验收'
if (value === 'result_not_met') return '实际结果未达标'
return '其他售后问题'
}
function formatAfterSalesStatus(value: string): string {
if (value === 'awaiting_worker_response') return '待打手说明'
if (value === 'under_review') return '客服处理中'
if (value === 'recovery_pending') return '追缴中'
return '已关闭'
}
function resolveAfterSalesStatusColor(value: string): string {
if (value === 'awaiting_worker_response') return 'orange'
if (value === 'under_review') return 'blue'
if (value === 'recovery_pending') return 'red'
return 'green'
}
function formatAfterSalesResolution(value: string): string {
if (value === 'upheld') return '问题成立'
if (value === 'remedied') return '补救完成'
if (value === 'not_upheld') return '问题不成立'
return value || '-'
}
function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] {
const snapshot = asRecord(item.acceptanceSnapshot)
const acceptance = asRecord(snapshot.acceptance)
const files = Array.isArray(acceptance.files) ? acceptance.files : []
const imageUrls = Array.isArray(acceptance.imageUrls) ? acceptance.imageUrls : []
// 验收资料里同一张图会同时存在于 files 与 imageUrls(历史冗余表示),
// 必须按图片身份去重,否则固化验收图会把同一张图重复渲染多次。
return uniqueUploadedFiles(
[
...files.map((value) => asRecord(value)),
...imageUrls.map((url) => asRecord({ url: String(url || '') })),
]
.map((file) => {
const url = String(file.url || '').trim()
if (!url) return null
return {
objectKey: String(file.objectKey || file.object_key || ''),
url,
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || url),
mediumUrl: String(file.mediumUrl || file.medium_url || url),
filename: String(file.filename || '验收图片'),
contentType: String(file.contentType || ''),
size: Number(file.size || 0),
} satisfies UploadedFile
})
.filter((file): file is UploadedFile => file !== null),
)
}
/** 售后固化快照里的验收视频(有则给出可点击链接)。 */
function getAfterSalesSnapshotVideo(item: WorkerAfterSalesCase): WorkerAcceptanceVideo | null {
const snapshot = asRecord(item.acceptanceSnapshot)
const acceptance = asRecord(snapshot.acceptance)
const video = asRecord(acceptance.video)
const url = String(video.url || '').trim()
if (!url) return null
return {
objectKey: String(video.objectKey || video.object_key || ''),
url,
filename: String(video.filename || ''),
contentType: String(video.contentType || video.content_type || ''),
size: Number(video.size || 0),
durationSeconds: Number(video.durationSeconds || video.duration_seconds || 0),
}
}
export default AfterSalesPanel