Files
order_site/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx
T
yml2213 faf56e567e feat(finance): P2 手动退款登记与成本异常闭环
- 迁移 112:order_refunds(订单项级退款,整单拆多条共享 batch_no,source/external_ref 预留平台 API)+ cost_exceptions.resolution_note
- 新服务 order-refund-service:整单/指定商品退款、可退金额校验、撤销、全额退完同步 pay_status
- 销售事实改为按有效退款记录冲减;无记录时仍兼容 pay_status=refunded 的历史全额退款
- 订单详情新增退款登记与退款记录表,商品明细显示收入/已退/可退
- 全额退款自动关闭该订单项成本异常并写处理备注;成本异常处理改为带备注弹窗
- 财务事件 UNION 接入退款记录,文档同步 P2 状态与 API
2026-10-24 17:18:51 +08:00

771 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeftOutlined,
CopyOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
RollbackOutlined,
StopOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
DatePicker,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Skeleton,
Space,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Dayjs } from 'dayjs'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import dayjs from '@/lib/dayjs'
import JsonPreview from '@/components/admin/JsonPreview'
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import {
createAdminOrderRefund,
fetchAdminOrderDetail,
updateAdminOrderClaimStatus,
voidAdminOrderRefund,
} from '@/services/admin'
import type { AdminOrderDetail, AdminOrderRefund, AdminTaskListItem } from '@/types/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display'
import { CostPreviewModal } from './cost-center/CostPreviewModal'
import { formatMoney } from './panels/shared'
export default function AdminOrderDetailPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { message, modal } = App.useApp()
const { orderId = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [manualCostOpen, setManualCostOpen] = useState(false)
const [costPreviewItemId, setCostPreviewItemId] = useState<number | null>(null)
const [refundModalOpen, setRefundModalOpen] = useState(false)
const [refundScope, setRefundScope] = useState<'order' | 'item'>('order')
const [refundItemId, setRefundItemId] = useState<number | null>(null)
const [refundAmountYuan, setRefundAmountYuan] = useState<number | null>(null)
const [refundReason, setRefundReason] = useState('买家退款')
const [refundNote, setRefundNote] = useState('')
const [refundedAt, setRefundedAt] = useState<Dayjs>(dayjs())
const [refunding, setRefunding] = useState(false)
const [voidTarget, setVoidTarget] = useState<AdminOrderRefund | null>(null)
const [voidReason, setVoidReason] = useState('')
const [voiding, setVoiding] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
useEffect(() => {
if (searchParams.get('manualCost') === '1') {
setManualCostOpen(true)
}
}, [searchParams])
function closeManualCost() {
setManualCostOpen(false)
if (searchParams.get('manualCost')) {
const next = new URLSearchParams(searchParams)
next.delete('manualCost')
setSearchParams(next, { replace: true })
}
}
const query = useQuery({
queryKey: ['admin-order-detail', orderId],
queryFn: () => fetchAdminOrderDetail(orderId),
enabled: Boolean(orderId),
})
const detail = query.data?.data
const statusMutation = useMutation({
mutationFn: async (data: { status: 'active' | 'paused' | 'closed'; reason?: string }) => {
const targetOrderId = orderId || detail?.order?.orderId
if (!targetOrderId) throw new Error('订单 ID 缺失')
return updateAdminOrderClaimStatus(targetOrderId, data)
},
onSuccess: () => {
message.success('订单兑换状态已更新')
setStatusModalVisible(false)
setStatusReason('')
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
},
onError: (err: unknown) => {
message.error(err instanceof Error ? err.message : '更新状态失败')
},
})
const handleCopyH5Link = () => {
if (!detail?.order.platformOrderId) return
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(detail.order.platformOrderId)}`
navigator.clipboard
.writeText(h5Url)
.then(() => message.success('已复制 H5 领取链接: ' + h5Url))
.catch(() => message.error('复制失败,请手动复制'))
}
const openStatusModal = (status: 'active' | 'paused' | 'closed') => {
if (status === 'active') {
modal.confirm({
title: '恢复启用订单兑换',
content: '恢复后,买家可正常进入 H5 页面提交 UID 领取,确认恢复吗?',
onOk: async () => {
await statusMutation.mutateAsync({ status: 'active', reason: '管理员恢复启用' })
},
})
return
}
setTargetStatus(status)
setStatusReason('')
setStatusModalVisible(true)
}
function openRefundModal(itemId?: number) {
const items = detail?.items || []
const target = itemId ? items.find((item) => item.orderItemId === itemId) : null
const fallback = items.find((item) => item.refundableFen > 0) || null
const focus = target || fallback
setRefundScope(target ? 'item' : 'order')
setRefundItemId(focus?.orderItemId ?? null)
setRefundAmountYuan(focus ? focus.refundableFen / 100 : 0)
setRefundReason('买家退款')
setRefundNote('')
setRefundedAt(dayjs())
setRefundModalOpen(true)
}
async function submitRefund() {
if (!detail) return
if (refundScope === 'item' && !refundItemId) {
message.warning('请选择要退款的商品')
return
}
const amountFen = Math.round(Number(refundAmountYuan || 0) * 100)
if (refundScope === 'item' && amountFen <= 0) {
message.warning('请输入退款金额')
return
}
setRefunding(true)
try {
const response = await createAdminOrderRefund(detail.order.orderId, {
orderItemId: refundScope === 'item' ? refundItemId || undefined : undefined,
amountFen: refundScope === 'item' ? amountFen : undefined,
reason: refundReason.trim() || '手动退款',
note: refundNote.trim(),
refundedAt: refundedAt.format('YYYY-MM-DD HH:mm:ss'),
})
const result = response.data
message.success(
`已登记 ${result.refunds.length} 条退款${result.fullRefunded ? '(订单已全额退款)' : ''}${
result.closedExceptionCount > 0
? `,自动关闭 ${result.closedExceptionCount} 条成本异常`
: ''
}`,
)
setRefundModalOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
await queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '登记退款失败')
} finally {
setRefunding(false)
}
}
async function submitVoidRefund() {
if (!detail || !voidTarget) return
setVoiding(true)
try {
await voidAdminOrderRefund(detail.order.orderId, voidTarget.id, {
reason: voidReason.trim() || '撤销退款',
})
message.success('退款已撤销')
setVoidTarget(null)
setVoidReason('')
await queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
await queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '撤销退款失败')
} finally {
setVoiding(false)
}
}
if (query.isLoading) {
return (
<section className="page-stack">
<PageHeader title="订单详情" extra={<BackButton />} />
<Card>
<Skeleton active paragraph={{ rows: 10 }} />
</Card>
</section>
)
}
if (query.error || !detail) {
return (
<section className="page-stack">
<PageHeader title="订单详情" extra={<BackButton />} />
<Alert
type="error"
showIcon
message={query.error instanceof Error ? query.error.message : '订单不存在或读取失败'}
/>
</section>
)
}
const taskColumns: TableColumnsType<AdminTaskListItem> = [
{
title: '任务号',
dataIndex: 'taskNo',
minWidth: 180,
render: (_, row) => (
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
{row.taskNo}
</Typography.Link>
),
},
{
title: '状态',
minWidth: 220,
render: (_, row) => (
<Space size={[0, 6]} wrap>
<StatusTag value={row.status} kind="task" />
<StatusTag value={row.resourceStatus} kind="resource" />
<StatusTag value={row.customerStatus} kind="customer" />
</Space>
),
},
{
title: '角色',
minWidth: 160,
render: (_, row) => (
<div className="cell-stack">
<span>{row.roleName || '-'}</span>
<span className="muted">{row.roleId || '-'}</span>
</div>
),
},
{
title: '错误',
dataIndex: 'lastError',
minWidth: 220,
render: (value) => <span className="muted">{value || '-'}</span>,
},
]
const allTasksCompleted =
detail.tasks.length > 0 &&
detail.tasks.every(
(t) =>
['completed', 'redeemed', 'delivered', 'success'].includes(t.status) ||
t.deliveryStatus === 'delivered',
)
const isOrderFulfilled = detail.order.orderStatus === 'completed' || allTasksCompleted
const isOrderRefundedOrCancelled =
detail.order.payStatus === 'refunded' || detail.order.orderStatus === 'cancelled'
const canPauseOrClose = !isOrderFulfilled && !isOrderRefundedOrCancelled
const isAutoClaimOrder = Boolean(
detail.order.fulfillmentMode === 'affiliate_dash' ||
(detail.order.fulfillmentMode === 'mixed' && detail.order.canClaimH5),
)
const fulfillmentMode = resolveOrderFulfillmentMode(detail.order)
const voucherLabel = resolveVoucherStateLabel(detail.order.voucherState)
return (
<section className="page-stack">
<PageHeader
title="订单详情"
description={detail.order.platformOrderId}
extra={<BackButton />}
/>
<Card
title="基础信息"
extra={
<Space>
{hasAdminPermission('finance.config') ? (
<Button
danger
icon={<RollbackOutlined />}
disabled={!detail.items.some((item) => item.refundableFen > 0)}
onClick={() => openRefundModal()}
>
退款登记
</Button>
) : null}
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
人工录单
</Button>
{isAutoClaimOrder ? (
<>
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
复制 H5 领取链接
</Button>
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => openStatusModal('active')}
loading={statusMutation.isPending}
>
恢复启用
</Button>
) : canPauseOrClose ? (
<Button
danger
icon={<PauseCircleOutlined />}
onClick={() => openStatusModal('paused')}
loading={statusMutation.isPending}
>
暂停兑换
</Button>
) : null}
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => openStatusModal('closed')}
loading={statusMutation.isPending}
>
关闭兑换
</Button>
) : null}
</>
) : null}
</Space>
}
>
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
<Descriptions.Item label="订单 ID">{detail.order.orderId}</Descriptions.Item>
<Descriptions.Item label="店铺">
{detail.order.shopName || detail.order.shopId || '-'}
</Descriptions.Item>
<Descriptions.Item label="平台订单号">{detail.order.platformOrderId}</Descriptions.Item>
<Descriptions.Item label="履约模式">
<Tag color={fulfillmentMode.color} style={{ margin: 0 }}>
{fulfillmentMode.label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="核销码">
{detail.order.hasVoucher && voucherLabel ? (
<Tag color="gold" style={{ margin: 0 }}>
🎫 {voucherLabel}
</Tag>
) : (
<span className="muted">无</span>
)}
</Descriptions.Item>
<Descriptions.Item label="订单状态">
<StatusTag value={detail.order.orderStatus} />
</Descriptions.Item>
<Descriptions.Item label="支付状态">
<StatusTag value={detail.order.payStatus} kind="pay" />
</Descriptions.Item>
{isAutoClaimOrder ? (
<Descriptions.Item label="H5 兑换状态">
<Space wrap>
{detail.order.claimStatus === 'paused' ? (
<Tag color="warning" icon={<PauseCircleOutlined />}>
已暂停兑换
</Tag>
) : detail.order.claimStatus === 'closed' ? (
<Tag color="default" icon={<StopOutlined />}>
已关闭兑换
</Tag>
) : (
<Tag color="success" icon={<PlayCircleOutlined />}>
正常有效
</Tag>
)}
{detail.order.claimStatusReason ? (
<span className="muted" style={{ fontSize: 12 }}>
({detail.order.claimStatusReason})
</span>
) : null}
</Space>
</Descriptions.Item>
) : null}
<Descriptions.Item label="金额">
{detail.order.totalAmount} {detail.order.currency}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatAdminDateTime(detail.order.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatAdminDateTime(detail.order.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label="商品摘要">
<div className="cell-stack" style={{ gap: 4 }}>
<span>{detail.order.itemTitle || detail.order.itemSummary || '-'}</span>
{detail.order.skuNick ? (
<Tag color="blue" style={{ margin: 0, width: 'fit-content' }}>
{detail.order.skuNick}
</Tag>
) : null}
</div>
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="商品明细">
<PercentTable<AdminOrderDetail['items'][number]>
rowKey="orderItemId"
pagination={false}
dataSource={detail.items}
scroll={{ x: 760 }}
columns={[
{
title: '商品',
dataIndex: 'itemTitle',
minWidth: 240,
render: (value) => <strong>{value || '-'}</strong>,
},
{
title: 'SKU / 规格',
minWidth: 180,
render: (_, item) => (
<div className="cell-stack" style={{ gap: 4 }}>
{item.skuNick ? (
<Tag color="blue" style={{ margin: 0, width: 'fit-content' }}>
{item.skuNick}
</Tag>
) : null}
{/* 商品列已是内部 SKU 时不再重复展示同一串 */}
{item.skuCode && item.skuCode !== item.itemTitle ? (
<span className="muted">{item.skuCode}</span>
) : null}
</div>
),
},
{ title: '数量', dataIndex: 'quantity', width: 90 },
{
title: '收入 / 已退',
width: 190,
render: (_, item) => (
<div className="cell-stack" style={{ gap: 4 }}>
<span>{formatMoney(item.revenueFen)}</span>
<span className="muted">
已退 {formatMoney(item.refundedFen)} · 可退 {formatMoney(item.refundableFen)}
</span>
</div>
),
},
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
{
title: '成本',
width: 110,
render: (_, item) => (
<Button size="small" onClick={() => setCostPreviewItemId(item.orderItemId)}>
查成本
</Button>
),
},
]}
/>
</Card>
{detail.refunds.length > 0 ? (
<Card
title={
<Space size={6}>
<Typography.Text strong>退款记录</Typography.Text>
<Tag color="red">
{formatMoney(
detail.refunds
.filter((item) => item.status === 'confirmed')
.reduce((sum, item) => sum + item.amountFen, 0),
)}
</Tag>
</Space>
}
>
<PercentTable<AdminOrderRefund>
rowKey="id"
pagination={false}
dataSource={detail.refunds}
scroll={{ x: 980 }}
columns={[
{
title: '退款单号',
dataIndex: 'refundNo',
width: 190,
render: (value: string, row) => (
<div className="cell-stack" style={{ gap: 2 }}>
<span>{value}</span>
<span className="muted">{row.batchNo}</span>
</div>
),
},
{
title: '商品',
dataIndex: 'skuName',
width: 240,
render: (value: string) => value || '-',
},
{
title: '金额',
dataIndex: 'amountFen',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '原因',
dataIndex: 'reason',
width: 220,
render: (value: string, row) => (
<div className="cell-stack" style={{ gap: 2 }}>
<span>{value || '-'}</span>
{row.note ? <span className="muted">{row.note}</span> : null}
</div>
),
},
{
title: '退款时间',
dataIndex: 'refundedAt',
width: 180,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作人',
dataIndex: 'operatorName',
width: 120,
render: (value: string) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (value: string, row) =>
value === 'confirmed' ? (
<Tag color="green">已确认</Tag>
) : (
<Tag color="default">已撤销{row.voidReason ? `:${row.voidReason}` : ''}</Tag>
),
},
{
title: '操作',
width: 90,
render: (_, row) =>
hasAdminPermission('finance.config') && row.status === 'confirmed' ? (
<Button size="small" danger onClick={() => setVoidTarget(row)}>
撤销
</Button>
) : null,
},
]}
/>
</Card>
) : null}
<Card title="任务">
<PercentTable<AdminTaskListItem>
rowKey="taskId"
pagination={false}
dataSource={detail.tasks}
columns={taskColumns}
scroll={{ x: 900 }}
/>
</Card>
<Card title="原始载荷">
<JsonPreview value={detail.order.rawPayload} />
</Card>
<Modal
title={targetStatus === 'paused' ? '暂停订单兑换' : '关闭订单兑换'}
open={statusModalVisible}
onCancel={() => setStatusModalVisible(false)}
confirmLoading={statusMutation.isPending}
onOk={() => {
if (!statusReason.trim()) {
message.warning('请填写原因或备注(如:买家申请退款)')
return
}
statusMutation.mutate({ status: targetStatus, reason: statusReason.trim() })
}}
>
<div style={{ padding: '12px 0' }}>
<p style={{ color: '#64748b', marginBottom: 8 }}>
{targetStatus === 'paused'
? '暂停后,买家访问 H5 链接将被拦截并提示“兑换已暂停,请联系客服”,后续可随时恢复启用。'
: '关闭后,买家将无法继续进入 H5 页面兑换,适用于已退款或订单作废。'}
</p>
<Input.TextArea
rows={3}
placeholder="请输入暂停/关闭原因(必填,如:买家退款、售后争议排查)"
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
/>
</div>
</Modal>
<ManualOrderCostModal
open={manualCostOpen}
orderId={detail.order.orderId}
orderNo={detail.order.platformOrderId || String(detail.order.orderId)}
items={detail.items.map((item) => ({
orderItemId: item.orderItemId,
matchSku: item.matchSku,
skuName: item.skuName,
skuCode: item.skuCode,
quantity: item.quantity,
}))}
onClose={closeManualCost}
/>
<Modal
title="退款登记"
open={refundModalOpen}
onCancel={() => setRefundModalOpen(false)}
onOk={() => void submitRefund()}
confirmLoading={refunding}
okText="确认退款"
cancelText="取消"
width={560}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={`当前可退合计 ${formatMoney(
detail.items.reduce((sum, item) => sum + item.refundableFen, 0),
)}`}
description="整单退款会按每个商品剩余可退金额拆成多条记录;登记后当天销售事实立即重算。"
/>
<Radio.Group
value={refundScope}
onChange={(event) => {
const next = event.target.value as 'order' | 'item'
setRefundScope(next)
if (next === 'item') {
const focus = detail.items.find((item) => item.refundableFen > 0) || null
setRefundItemId(focus?.orderItemId ?? null)
setRefundAmountYuan(focus ? focus.refundableFen / 100 : 0)
}
}}
>
<Radio.Button value="order">整单退款</Radio.Button>
<Radio.Button value="item">指定商品</Radio.Button>
</Radio.Group>
{refundScope === 'item' ? (
<>
<Select
value={refundItemId ?? undefined}
onChange={(value) => {
setRefundItemId(value)
const item = detail.items.find((row) => row.orderItemId === value)
setRefundAmountYuan(item ? item.refundableFen / 100 : 0)
}}
options={detail.items
.filter((item) => item.refundableFen > 0)
.map((item) => ({
value: item.orderItemId,
label: `${item.itemTitle || item.skuName} · 可退 ${formatMoney(item.refundableFen)}`,
}))}
style={{ width: '100%' }}
/>
<InputNumber
addonBefore="¥"
min={0.01}
precision={2}
value={refundAmountYuan}
onChange={(value) => setRefundAmountYuan(value ?? 0)}
style={{ width: '100%' }}
/>
</>
) : (
<Typography.Text type="secondary">
将按 {detail.items.filter((item) => item.refundableFen > 0).length}{' '}
个有可退金额的商品逐条登记。
</Typography.Text>
)}
<Input
value={refundReason}
onChange={(event) => setRefundReason(event.target.value)}
placeholder="退款原因,如:买家退款 / 售后补偿 / 发错货"
/>
<DatePicker
showTime
value={refundedAt}
onChange={(value) => {
if (value) setRefundedAt(value)
}}
style={{ width: '100%' }}
/>
<Input.TextArea
rows={2}
value={refundNote}
onChange={(event) => setRefundNote(event.target.value)}
placeholder="备注(可选,如平台退款单号、沟通记录)"
/>
</Space>
</Modal>
<Modal
title="撤销退款"
open={voidTarget !== null}
onCancel={() => {
setVoidTarget(null)
setVoidReason('')
}}
onOk={() => void submitVoidRefund()}
confirmLoading={voiding}
okText="确认撤销"
okButtonProps={{ danger: true }}
cancelText="取消"
width={520}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={`撤销 ${voidTarget?.refundNo || ''}(${formatMoney(voidTarget?.amountFen || 0)})`}
description="撤销后销售事实会按剩余有效退款重算;已置为「已退款」的订单支付状态不会自动回退。"
/>
<Input
value={voidReason}
onChange={(event) => setVoidReason(event.target.value)}
placeholder="撤销原因(可选)"
/>
</Space>
</Modal>
<CostPreviewModal
open={costPreviewItemId !== null}
orderItemId={costPreviewItemId}
onClose={() => setCostPreviewItemId(null)}
/>
</section>
)
}
function BackButton() {
const navigate = useNavigate()
return (
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/orders')}>
返回订单
</Button>
)
}