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
This commit is contained in:
yml2213
2026-10-24 17:18:51 +08:00
parent a78cc1f984
commit faf56e567e
18 changed files with 1127 additions and 48 deletions
@@ -4,6 +4,7 @@ import {
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
RollbackOutlined,
StopOutlined,
} from '@ant-design/icons'
import {
@@ -11,9 +12,14 @@ import {
App,
Button,
Card,
DatePicker,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Skeleton,
Space,
Tag,
@@ -22,19 +28,29 @@ import {
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 { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
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()
@@ -45,6 +61,17 @@ export default function AdminOrderDetailPage() {
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('')
@@ -112,6 +139,77 @@ export default function AdminOrderDetailPage() {
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">
@@ -206,6 +304,16 @@ export default function AdminOrderDetailPage() {
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>
@@ -351,6 +459,18 @@ export default function AdminOrderDetailPage() {
),
},
{ 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: '成本',
@@ -365,6 +485,99 @@ export default function AdminOrderDetailPage() {
/>
</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"
@@ -421,6 +634,123 @@ export default function AdminOrderDetailPage() {
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}
@@ -5,7 +5,21 @@
* 页面先按原因聚合,默认只看近 24 小时,历史存量需要显式切换时间范围。
* 每行可直接打开「成本解析」定位是哪个 SKU 没归一、哪条成本没命中。
*/
import { App, Button, Card, Col, Row, Select, Space, Statistic, Tag, Typography } from 'antd'
import {
Alert,
App,
Button,
Card,
Col,
Input,
Modal,
Row,
Select,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -50,7 +64,7 @@ const RANGE_OPTIONS = [
]
export function CostExceptionPanel() {
const { message, modal } = App.useApp()
const { message } = App.useApp()
const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter()
const [status, setStatus] = useState('open')
@@ -59,6 +73,12 @@ export function CostExceptionPanel() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [previewItemId, setPreviewItemId] = useState<number | null>(null)
const [resolveTarget, setResolveTarget] = useState<{
item: CostException
status: 'resolved' | 'ignored'
} | null>(null)
const [resolveNote, setResolveNote] = useState('')
const [resolving, setResolving] = useState(false)
// 时间下界在取数时计算,避免渲染期调用 Date.now(React 纯度规则)。
const summaryQuery = useQuery({
@@ -102,22 +122,28 @@ export function CostExceptionPanel() {
const exceptions = exceptionsQuery.data?.data.items || []
const total = exceptionsQuery.data?.data.pagination.total || 0
function resolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
modal.confirm({
title: nextStatus === 'resolved' ? '标记异常已处理' : '忽略异常',
content: `确认处理「${REASON_LABELS[item.reason] || item.reason}」?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await resolveAdminCostException(item.id, { status: nextStatus })
message.success('成本异常已处理')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '处理成本异常失败')
}
},
})
function openResolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
setResolveNote('')
setResolveTarget({ item, status: nextStatus })
}
async function submitResolve() {
if (!resolveTarget) return
setResolving(true)
try {
await resolveAdminCostException(resolveTarget.item.id, {
status: resolveTarget.status,
note: resolveNote.trim(),
})
message.success('成本异常已处理')
setResolveTarget(null)
setResolveNote('')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '处理成本异常失败')
} finally {
setResolving(false)
}
}
const columns: TableColumnsType<CostException> = [
@@ -131,6 +157,11 @@ export function CostExceptionPanel() {
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{String(row.detail?.message || '')}
</Typography.Text>
{row.status !== 'open' && row.resolutionNote ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
处理备注:{row.resolutionNote}
</Typography.Text>
) : null}
</Space>
),
},
@@ -183,10 +214,10 @@ export function CostExceptionPanel() {
</Button>
{canConfig && row.status === 'open' ? (
<>
<Button size="small" type="primary" onClick={() => resolve(row, 'resolved')}>
<Button size="small" type="primary" onClick={() => openResolve(row, 'resolved')}>
已处理
</Button>
<Button size="small" onClick={() => resolve(row, 'ignored')}>
<Button size="small" onClick={() => openResolve(row, 'ignored')}>
忽略
</Button>
</>
@@ -308,6 +339,39 @@ export function CostExceptionPanel() {
})}
/>
<Modal
title={resolveTarget?.status === 'ignored' ? '忽略成本异常' : '标记异常已处理'}
open={resolveTarget !== null}
onCancel={() => setResolveTarget(null)}
onOk={() => void submitResolve()}
confirmLoading={resolving}
okText="确认"
cancelText="取消"
width={520}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={
resolveTarget
? REASON_LABELS[resolveTarget.item.reason] || resolveTarget.item.reason
: ''
}
description={String(resolveTarget?.item.detail?.message || '')}
/>
<Input.TextArea
rows={3}
value={resolveNote}
onChange={(event) => setResolveNote(event.target.value)}
placeholder="处理备注(可选):怎么处理的、为什么忽略"
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
备注会随异常一起留档;退款导致的异常由系统自动写入备注。
</Typography.Text>
</Space>
</Modal>
<CostPreviewModal
open={previewItemId !== null}
orderItemId={previewItemId}
+35 -1
View File
@@ -1,5 +1,10 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminOrderDetail, AdminOrderListItem, AdminPagination } from '@/types/admin'
import type {
AdminOrderDetail,
AdminOrderListItem,
AdminOrderRefund,
AdminPagination,
} from '@/types/admin'
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>(
@@ -24,3 +29,32 @@ export function updateAdminOrderClaimStatus(
claimStatusUpdatedAt: string
}>(`/api/v1/admin/orders/${orderId}/claim-status`, payload)
}
export function createAdminOrderRefund(
orderId: number | string,
payload: {
orderItemId?: number
amountFen?: number
reason?: string
note?: string
refundedAt?: string
},
) {
return apiPost<{
batchNo: string
refunds: AdminOrderRefund[]
fullRefunded: boolean
closedExceptionCount: number
}>(`/api/v1/admin/orders/${orderId}/refunds`, payload)
}
export function voidAdminOrderRefund(
orderId: number | string,
refundId: number,
payload: { reason?: string },
) {
return apiPost<{ refund: AdminOrderRefund; rebuiltDate: string }>(
`/api/v1/admin/orders/${orderId}/refunds/${refundId}/void`,
payload,
)
}
@@ -138,6 +138,7 @@ export interface CostException {
createdAt: string
resolvedAt: string | null
resolvedByAdminUserId: number | null
resolutionNote: string
}
export interface CostCenterOptions {
+6 -1
View File
@@ -58,7 +58,12 @@ export type {
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
// Orders types
export type { AdminOrderFulfillmentProgress, AdminOrderListItem, AdminOrderDetail } from './orders'
export type {
AdminOrderFulfillmentProgress,
AdminOrderListItem,
AdminOrderDetail,
AdminOrderRefund,
} from './orders'
// Tasks types
export type {
+27
View File
@@ -112,9 +112,36 @@ export interface AdminOrderDetail {
skuNick?: string
matchSku?: string
quantity: number
/** 订单项收入(分) */
revenueFen: number
/** 已确认退款(分) */
refundedFen: number
/** 还可退金额(分) */
refundableFen: number
fulfillmentMode: AdminOrderItemFulfillmentMode
deliveryMode: string
spec: Record<string, unknown>
}>
refunds: AdminOrderRefund[]
tasks: AdminTaskListItem[]
}
export interface AdminOrderRefund {
id: number
refundNo: string
batchNo: string
orderId: number
orderItemId: number
skuName: string
amountFen: number
reason: string
note: string
source: string
status: 'confirmed' | 'voided'
refundedAt: string
operatorAdminUserId: number | null
operatorName: string
voidedAt: string | null
voidReason: string
createdAt: string
}