feat(admin): 收口人工履约入口并修复履约筛选

This commit is contained in:
yml2213
2026-10-24 17:42:07 +08:00
parent faf56e567e
commit 790de676c6
27 changed files with 495 additions and 1327 deletions
-2
View File
@@ -23,7 +23,6 @@ const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminManualFulfillmentPage = lazy(() => import('@/pages/admin/AdminManualFulfillmentPage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
@@ -145,7 +144,6 @@ function AdminApplication() {
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
<Route path="tasks" element={<AdminTasksPage />} />
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
<Route path="manual-fulfillment" element={<AdminManualFulfillmentPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
<Route path="finance" element={<AdminFinancePage />} />
@@ -0,0 +1,222 @@
/**
* 确认人工履约弹窗(订单列表 / 订单详情共用)。
*
* 口径(2026-10-24):
* * 渠道与消耗积分在成本中心提前配好,客服不选、不填;
* * 固定成本 → 直接确认;
* * 成本不固定(消耗积分 = 0)→ 只填本次实际成本;
* * 确认后扣对应平台积分/备用金并写成本快照。
*
* 原「人工履约待办」页面已下线,入口收敛到订单界面。
*/
import { CheckOutlined } from '@ant-design/icons'
import {
Alert,
App,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
import type { ManualFulfillmentPendingItem } from '@/types/admin'
type ConfirmFormValues = {
costYuan?: number
payeeName?: string
note?: string
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
lewan: 'purple',
rock: 'cyan',
fund_cash: 'orange',
}
export type ManualFulfillmentConfirmModalProps = {
open: boolean
orderId: number | null
initialOrderItemId?: number | null
onClose: () => void
onConfirmed?: () => void
}
export function ManualFulfillmentConfirmModal({
open,
orderId,
initialOrderItemId = null,
onClose,
onConfirmed,
}: ManualFulfillmentConfirmModalProps) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfirmFormValues>()
const [selectedId, setSelectedId] = useState<number | null>(null)
const pendingQuery = useQuery({
queryKey: ['admin-manual-fulfillment-pending', 'order', orderId],
queryFn: () =>
fetchAdminManualFulfillmentPending({
orderId: orderId || undefined,
status: 'pending',
page: 1,
pageSize: 20,
}),
enabled: open && Boolean(orderId),
})
const items = pendingQuery.data?.data.items || []
const selected =
items.find((item) => item.orderItemId === selectedId) ||
items.find((item) => item.orderItemId === initialOrderItemId) ||
items[0] ||
null
useEffect(() => {
if (!open) return
setSelectedId(initialOrderItemId || null)
form.resetFields()
}, [open, initialOrderItemId, form])
const confirmMutation = useMutation({
mutationFn: (values: ConfirmFormValues) =>
confirmAdminManualFulfillment({
orderItemId: selected?.orderItemId || 0,
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
payeeName: values.payeeName || '',
note: values.note || '',
}),
onSuccess: (response) => {
const result = response.data
message.success(
`履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
)
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
onConfirmed?.()
onClose()
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '确认履约失败')
},
})
const showCostInput = selected?.costFen === null
return (
<Modal
open={open}
title="确认人工履约"
okText="确认履约"
okButtonProps={{ icon: <CheckOutlined />, disabled: !selected }}
cancelText="取消"
confirmLoading={confirmMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => confirmMutation.mutate(values))
}}
onCancel={onClose}
destroyOnHidden
width={560}
>
{pendingQuery.isLoading ? (
<Typography.Text type="secondary">正在读取待履约商品…</Typography.Text>
) : items.length === 0 ? (
<Alert
type="warning"
showIcon
message="该订单没有待确认的人工履约项"
description="可能已履约,或该商品还没有在「商品与成本 → 人工 SKU 配置」里配置履约渠道。"
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{items.length > 1 ? (
<Select
value={selected?.orderItemId}
onChange={(value) => setSelectedId(value)}
options={items.map((item) => ({
value: item.orderItemId,
label: `${item.skuName || item.skuCode} · ${item.internalSku || item.skuCode}`,
}))}
style={{ width: '100%' }}
/>
) : null}
<div>
<Typography.Text type="secondary">商品:</Typography.Text>
<Typography.Text strong>
{selected?.skuName || selected?.skuCode || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">内部 SKU:</Typography.Text>
<Typography.Text copyable={{ text: selected?.internalSku || '' }}>
{selected?.internalSku || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">履约渠道:</Typography.Text>
<Tag color={PLATFORM_TAG_COLORS[selected?.platformKey || ''] || 'default'}>
{selected?.platformLabel || '-'}
</Tag>
{selected && !selected.isFundCash ? (
<Typography.Text>消耗 {selected.creditQuantity} 积分</Typography.Text>
) : null}
</div>
<div>
<Typography.Text type="secondary">订单金额:</Typography.Text>
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
</div>
{showCostInput ? (
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
) : (
<Statistic
title="本次成本"
value={(selected?.costFen || 0) / 100}
precision={2}
prefix="¥"
/>
)}
<Form<ConfirmFormValues> form={form} layout="vertical">
{showCostInput ? (
<Form.Item
name="costYuan"
label="实际成本(元)"
rules={[{ required: true, message: '请填写实际成本' }]}
>
<InputNumber
min={0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
) : null}
<Form.Item name="payeeName" label="收款方 / 处理人">
<Input placeholder="例如:客服小王、上游商家" maxLength={120} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Space>
)}
</Modal>
)
}
@@ -1,271 +0,0 @@
import { App, Alert, Form, Input, InputNumber, Modal, Select } from 'antd'
import type { FormInstance } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
import { getAdminRole } from '@/utils/admin-auth'
export type ManualOrderCostItem = {
orderItemId: number
matchSku?: string
skuName?: string
skuCode?: string
quantity?: number
}
type ManualOrderCostModalProps = {
open: boolean
orderId: number
orderNo: string
items: ManualOrderCostItem[]
onClose: () => void
}
type ManualOrderCostFormValues = {
orderItemId: number
fundAccountId: number
amountYuan: number
expenseCategory: string
payeeName?: string
paymentMethod?: string
evidence?: string
note?: string
}
const CATEGORY_OPTIONS = [
{ value: 'service_fee', label: '人工服务费' },
{ value: 'goods_purchase', label: '商品采购' },
{ value: 'compensation', label: '赔付 / 补偿' },
{ value: 'refund_advance', label: '垫付退款' },
{ value: 'freight', label: '运费' },
{ value: 'other', label: '其他' },
]
const PAYMENT_METHOD_OPTIONS = [
{ value: 'wechat', label: '微信' },
{ value: 'alipay', label: '支付宝' },
{ value: 'bank', label: '银行卡' },
{ value: 'cash', label: '现金' },
{ value: 'platform_balance', label: '平台余额' },
{ value: 'other', label: '其他' },
]
function itemLabel(item: ManualOrderCostItem) {
// 只展示平台判断用的真实 SKU,引流长标题不进录单选项。
const sku = item.matchSku || item.skuName || item.skuCode || `订单项 #${item.orderItemId}`
return item.quantity && item.quantity > 1 ? `${sku} ×${item.quantity}` : sku
}
export default function ManualOrderCostModal({
open,
orderId,
orderNo,
items,
onClose,
}: ManualOrderCostModalProps) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ManualOrderCostFormValues>()
const fundAccountsQuery = useQuery({
queryKey: ['admin-fund-accounts', 'agent'],
queryFn: () =>
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
enabled: open,
})
const fundAccounts = fundAccountsQuery.data?.data.items || []
const soleFundAccountId = fundAccounts.length === 1 ? fundAccounts[0]?.id : undefined
const isSupport = getAdminRole() === 'support'
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
useEffect(() => {
if (!open) return
form.resetFields()
form.setFieldsValue({
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
fundAccountId: soleFundAccountId,
amountYuan: undefined,
expenseCategory: 'service_fee',
paymentMethod: 'other',
})
}, [form, items, open, soleFundAccountId])
const mutation = useMutation({
mutationFn: (values: ManualOrderCostFormValues) =>
createAdminManualOrderCost({
orderId,
orderItemId: values.orderItemId,
fundAccountId: values.fundAccountId,
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
expenseCategory: values.expenseCategory,
payeeName: values.payeeName || '内部人工',
paymentMethod: values.paymentMethod || 'other',
note: values.note || '',
evidenceJson: values.evidence
? { reference: values.evidence }
: { note: values.note || '订单页人工录单' },
}),
onSuccess: (response) => {
const result = response.data
if (result.warning) {
message.warning(result.warning)
} else {
message.success(`已计入订单成本,支出单号 ${result.expenseNo}`)
}
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
onClose()
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '录入人工成本失败')
},
})
async function submit() {
const values = await form.validateFields()
mutation.mutate(values)
}
return (
<Modal
open={open}
title="人工录单 / 录入订单成本"
okText="确认录入并计入成本"
cancelText="取消"
confirmLoading={mutation.isPending}
onOk={() => void submit()}
onCancel={onClose}
destroyOnHidden
width={620}
>
<Form<ManualOrderCostFormValues>
form={form as FormInstance<ManualOrderCostFormValues>}
layout="vertical"
initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="每一单单独录实际金额,不按小时、不按单量套模板"
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。录入即写入成本快照并计入销售毛利,没有审批与报销环节。"
/>
<Form.Item label="订单号">
<Input value={orderNo} readOnly />
</Form.Item>
{isSupport ? (
<>
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
<Input
readOnly
value={
ownFundAccount
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ¥${(
ownFundAccount.balanceFen / 100
).toFixed(2)}`
: '当前账号还没有备用金账户'
}
/>
</Form.Item>
<Form.Item name="fundAccountId" hidden>
<Input />
</Form.Item>
</>
) : (
<Form.Item
name="fundAccountId"
label="备用金账户"
rules={[{ required: true, message: '请选择备用金账户' }]}
extra="人工支出会直接从该客服的备用金余额扣减"
>
<Select
showSearch
optionFilterProp="label"
loading={fundAccountsQuery.isLoading}
placeholder="请选择这笔支出从哪个客服备用金账户出"
options={fundAccounts.map((account) => ({
value: account.id,
label: `${account.ownerName} · ${account.accountNo} · 余额 ¥${(
account.balanceFen / 100
).toFixed(2)}`,
}))}
/>
</Form.Item>
)}
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 14 }}
message="没有可用的客服备用金账户"
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
/>
) : null}
{items.length === 1 ? (
<>
<Form.Item label="计入订单项">
<Input value={itemLabel(items[0])} readOnly />
</Form.Item>
<Form.Item name="orderItemId" hidden>
<Input />
</Form.Item>
</>
) : (
<Form.Item
name="orderItemId"
label="计入订单项"
rules={[{ required: true, message: '请选择订单项' }]}
>
<Select
placeholder="订单有多个商品,请选择计入哪一项"
options={items.map((item) => ({
value: item.orderItemId,
label: itemLabel(item),
}))}
/>
</Form.Item>
)}
<Form.Item
name="amountYuan"
label="人工成本金额(元)"
rules={[{ required: true, message: '请输入实际金额' }]}
>
<InputNumber
min={0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
<Form.Item
name="expenseCategory"
label="费用类型"
rules={[{ required: true, message: '请选择费用类型' }]}
>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
<Form.Item name="payeeName" label="处理人 / 收款方">
<Input placeholder="例如:客服小王、上游商家;留空记内部人工" maxLength={120} />
</Form.Item>
<Form.Item name="paymentMethod" label="付款方式">
<Select options={PAYMENT_METHOD_OPTIONS} />
</Form.Item>
<Form.Item name="evidence" label="凭证链接 / 流水号(可选)">
<Input placeholder="转账截图链接、流水号等;留空则用备注作为凭证" maxLength={300} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="这一单实际做了什么、为什么是这个金额"
/>
</Form.Item>
</Form>
</Modal>
)
}
@@ -1,5 +1,4 @@
import {
CheckCircleOutlined,
AuditOutlined,
AccountBookOutlined,
ApartmentOutlined,
@@ -163,11 +162,6 @@ export default function AdminLayout() {
},
]),
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
{
key: '/admin/manual-fulfillment',
icon: <CheckCircleOutlined />,
label: '人工履约',
},
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
{
key: '/admin/kuaishou-industry',
@@ -1,343 +0,0 @@
/**
* 人工履约待办页:客服确认履约。
*
* 口径(2026-10-24 确认):
* * 渠道与消耗积分在成本中心提前配好,客服不选、不填;
* * 固定成本 → 点「确认履约」零输入;
* * 成本不固定(消耗积分 = 0)→ 只填本次实际成本;
* * 确认后自动扣对应平台的积分并写成本快照。
*/
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
import type { ManualFulfillmentPendingItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
type ConfirmFormValues = {
costYuan?: number
payeeName?: string
note?: string
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
lewan: 'purple',
rock: 'cyan',
fund_cash: 'orange',
}
export default function AdminManualFulfillmentPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfirmFormValues>()
const [status, setStatus] = useState<'pending' | 'fulfilled' | ''>('pending')
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [confirming, setConfirming] = useState<ManualFulfillmentPendingItem | null>(null)
const [showCostInput, setShowCostInput] = useState(false)
const listQuery = useQuery({
queryKey: ['admin-manual-fulfillment-pending', status, keyword, page, pageSize],
queryFn: () =>
fetchAdminManualFulfillmentPending({
status: status || undefined,
keyword: keyword || undefined,
page,
pageSize,
}),
})
const confirmMutation = useMutation({
mutationFn: (values: ConfirmFormValues) =>
confirmAdminManualFulfillment({
orderItemId: confirming?.orderItemId || 0,
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
payeeName: values.payeeName || '',
note: values.note || '',
}),
onSuccess: (response) => {
const result = response.data
message.success(
`履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
)
setConfirming(null)
setShowCostInput(false)
void queryClient.invalidateQueries({
queryKey: ['admin-manual-fulfillment-pending'],
})
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '确认履约失败')
},
})
function openConfirm(item: ManualFulfillmentPendingItem) {
setConfirming(item)
setShowCostInput(item.costFen === null)
form.resetFields()
}
const columns: TableColumnsType<ManualFulfillmentPendingItem> = [
{
title: '商品',
key: 'sku',
width: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong ellipsis={{ tooltip: row.skuName }}>
{row.skuName || row.skuCode}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.internalSku || row.skuCode}
</Typography.Text>
</div>
),
},
{
title: '平台单号',
dataIndex: 'platformOrderId',
width: 170,
render: (value: string) => (
<Typography.Text copyable={{ text: value }} style={{ fontSize: 12 }}>
{value}
</Typography.Text>
),
},
{
title: '履约渠道',
key: 'platform',
width: 110,
render: (_, row) => (
<Tag color={PLATFORM_TAG_COLORS[row.platformKey] || 'default'}>{row.platformLabel}</Tag>
),
},
{
title: '消耗',
key: 'credit',
width: 120,
render: (_, row) =>
row.isFundCash ? (
<Typography.Text type="secondary">—</Typography.Text>
) : (
<Typography.Text>{row.creditQuantity} 积分</Typography.Text>
),
},
{
title: '成本',
key: 'cost',
width: 120,
align: 'right',
render: (_, row) =>
row.costFen === null ? (
<Tag color="orange">待填写</Tag>
) : (
<Typography.Text strong>¥{(row.costFen / 100).toFixed(2)}</Typography.Text>
),
},
{
title: '订单金额',
key: 'revenue',
width: 100,
align: 'right',
render: (_, row) => `¥${(row.revenueFen / 100).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 150,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 110,
fixed: 'right',
render: (_, row) =>
row.fulfilled ? (
<Tag color="green">已履约</Tag>
) : (
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={() => openConfirm(row)}
>
确认履约
</Button>
),
},
]
const total = listQuery.data?.data.pagination.total ?? 0
return (
<section>
<PageHeader
title="人工履约待办"
description="渠道与消耗积分已提前配好,确认即扣额度并计入成本。"
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="确认履约会从对应平台的积分钱包扣减,并写入成本快照"
description="固定成本的商品直接确认即可;成本不固定的商品需要填写本次实际成本。"
/>
<Space wrap style={{ marginBottom: 12 }}>
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
刷新
</Button>
<Button
type={status === 'pending' ? 'primary' : 'default'}
onClick={() => {
setStatus('pending')
setPage(1)
}}
>
待确认
</Button>
<Button
type={status === 'fulfilled' ? 'primary' : 'default'}
onClick={() => {
setStatus('fulfilled')
setPage(1)
}}
>
已履约
</Button>
<Button
type={status === '' ? 'primary' : 'default'}
onClick={() => {
setStatus('')
setPage(1)
}}
>
全部
</Button>
</Space>
<Card bordered={false}>
<PercentTable
rowKey="orderItemId"
size="small"
columns={columns}
dataSource={listQuery.data?.data.items || []}
loading={listQuery.isLoading}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
if (nextPageSize !== pageSize) setPageSize(nextPageSize)
},
})}
/>
</Card>
<Modal
open={Boolean(confirming)}
title="确认履约"
okText="确认"
cancelText="取消"
confirmLoading={confirmMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => confirmMutation.mutate(values))
}}
onCancel={() => {
setConfirming(null)
setShowCostInput(false)
}}
destroyOnHidden
width={520}
>
{confirming ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary">商品:</Typography.Text>
<Typography.Text strong>{confirming.skuName || confirming.skuCode}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">内部 SKU:</Typography.Text>
<Typography.Text copyable={{ text: confirming.internalSku }}>
{confirming.internalSku}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">履约渠道:</Typography.Text>
<Tag color={PLATFORM_TAG_COLORS[confirming.platformKey] || 'default'}>
{confirming.platformLabel}
</Tag>
{!confirming.isFundCash ? (
<Typography.Text>消耗 {confirming.creditQuantity} 积分</Typography.Text>
) : null}
</div>
{showCostInput ? (
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
) : (
<Statistic
title="本次成本"
value={(confirming.costFen || 0) / 100}
precision={2}
prefix="¥"
/>
)}
</Space>
) : null}
<Form<ConfirmFormValues> form={form} layout="vertical">
{showCostInput ? (
<Form.Item
name="costYuan"
label="实际成本(元)"
rules={[{ required: true, message: '请填写实际成本' }]}
>
<InputNumber
min={0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
) : null}
<Form.Item name="payeeName" label="收款方 / 处理人">
<Input placeholder="例如:客服小王、上游商家" maxLength={120} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</section>
)
}
@@ -1,7 +1,7 @@
import {
ArrowLeftOutlined,
CheckOutlined,
CopyOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
RollbackOutlined,
@@ -23,19 +23,20 @@ import {
Skeleton,
Space,
Tag,
Tooltip,
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 { useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import dayjs from '@/lib/dayjs'
import JsonPreview from '@/components/admin/JsonPreview'
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
import { ManualFulfillmentConfirmModal } from '@/components/admin/ManualFulfillmentConfirmModal'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import {
@@ -57,9 +58,8 @@ export default function AdminOrderDetailPage() {
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 [fulfillOpen, setFulfillOpen] = useState(false)
const [costPreviewItemId, setCostPreviewItemId] = useState<number | null>(null)
const [refundModalOpen, setRefundModalOpen] = useState(false)
const [refundScope, setRefundScope] = useState<'order' | 'item'>('order')
@@ -75,21 +75,6 @@ export default function AdminOrderDetailPage() {
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),
@@ -314,9 +299,19 @@ export default function AdminOrderDetailPage() {
退款登记
</Button>
) : null}
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
人工录单
</Button>
{Number(detail.order.manualPendingCount || 0) > 0 ? (
<Button icon={<CheckOutlined />} onClick={() => setFulfillOpen(true)}>
确认履约
</Button>
) : Number(detail.order.manualUnconfiguredCount || 0) > 0 ? (
<Tooltip
title={`有 ${detail.order.manualUnconfiguredCount} 个人工商品未配置履约渠道,请先到「商品与成本 → 人工 SKU 配置」补全`}
>
<Tag color="orange" style={{ margin: 0 }}>
待补全履约
</Tag>
</Tooltip>
) : null}
{isAutoClaimOrder ? (
<>
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
@@ -620,18 +615,13 @@ export default function AdminOrderDetailPage() {
</div>
</Modal>
<ManualOrderCostModal
open={manualCostOpen}
<ManualFulfillmentConfirmModal
open={fulfillOpen}
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}
onClose={() => setFulfillOpen(false)}
onConfirmed={() =>
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
}
/>
<Modal
@@ -1,6 +1,6 @@
import {
CheckOutlined,
CopyOutlined,
DollarOutlined,
EyeOutlined,
LinkOutlined,
ReloadOutlined,
@@ -23,10 +23,11 @@ import {
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import { ManualFulfillmentConfirmModal } from '@/components/admin/ManualFulfillmentConfirmModal'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import dayjs from '@/lib/dayjs'
@@ -38,7 +39,7 @@ import {
resolveVoucherStateLabel,
} from '@/utils/admin-display'
import {
adminOrderExecutorOptions,
adminOrderFulfillmentModeOptions,
adminOrderPlatformSegmentedOptions,
adminOrderProviderOptions,
adminPayStatusOptions,
@@ -54,7 +55,7 @@ import { formatAdminDateTime } from '@/utils/admin-time'
type OrderFilterForm = {
keyword?: string
provider?: string
executorKey?: string
fulfillmentMode?: string
payStatus?: string
taskStatus?: string
dateRange?: unknown
@@ -62,6 +63,8 @@ type OrderFilterForm = {
export default function AdminOrdersPage() {
const navigate = useNavigate()
const [fulfillOpen, setFulfillOpen] = useState(false)
const [fulfillOrderId, setFulfillOrderId] = useState<number | null>(null)
const { message } = App.useApp()
const [form] = Form.useForm<OrderFilterForm>()
const [searchParams, setSearchParams] = useSearchParams()
@@ -71,7 +74,7 @@ export default function AdminOrdersPage() {
const filters = {
keyword: searchParams.get('keyword') || '',
provider: searchParams.get('provider') || '',
executorKey: searchParams.get('executorKey') || '',
fulfillmentMode: searchParams.get('fulfillmentMode') || '',
payStatus: searchParams.get('payStatus') || '',
taskStatus: searchParams.get('taskStatus') || '',
dateFrom: searchParams.get('dateFrom') || '',
@@ -83,7 +86,7 @@ export default function AdminOrdersPage() {
[
filters.dateFrom,
filters.dateTo,
filters.executorKey,
filters.fulfillmentMode,
filters.keyword,
filters.payStatus,
filters.provider,
@@ -103,7 +106,7 @@ export default function AdminOrdersPage() {
form.setFieldsValue({
keyword: filters.keyword || undefined,
provider: filters.provider || undefined,
executorKey: filters.executorKey || undefined,
fulfillmentMode: filters.fulfillmentMode || undefined,
payStatus: filters.payStatus || undefined,
taskStatus: filters.taskStatus || undefined,
dateRange:
@@ -114,7 +117,7 @@ export default function AdminOrdersPage() {
}, [
filters.dateFrom,
filters.dateTo,
filters.executorKey,
filters.fulfillmentMode,
filters.keyword,
filters.payStatus,
filters.provider,
@@ -328,7 +331,8 @@ export default function AdminOrdersPage() {
render: (_, row) => {
const orderNo = row.platformOrderId || String(row.orderId)
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
const isManualOrder = Number(row.manualItemCount || 0) > 0
const manualPending = Number(row.manualPendingCount || 0)
const manualUnconfigured = Number(row.manualUnconfiguredCount || 0)
return (
<Space size={6} wrap>
@@ -341,16 +345,27 @@ export default function AdminOrdersPage() {
>
详情
</Button>
{isManualOrder ? (
<Tooltip title="人工录单:录入这一单的实际人工成本">
{manualPending > 0 ? (
<Tooltip title="人工履约:确认后扣积分/备用金并写入成本">
<Button
size="small"
icon={<DollarOutlined />}
onClick={() => navigate(`/admin/orders/${row.orderId}?manualCost=1`)}
icon={<CheckOutlined />}
onClick={() => {
setFulfillOrderId(row.orderId)
setFulfillOpen(true)
}}
>
人工录单
确认履约
</Button>
</Tooltip>
) : manualUnconfigured > 0 ? (
<Tooltip
title={`有 ${manualUnconfigured} 个人工商品未配置履约渠道,请先到「商品与成本 → 人工 SKU 配置」补全`}
>
<Tag color="orange" style={{ margin: 0 }}>
待补全履约
</Tag>
</Tooltip>
) : null}
{row.canClaimH5 ? (
<Tooltip title="复制自动直充 H5 领取链接">
@@ -398,7 +413,7 @@ export default function AdminOrdersPage() {
form.setFieldsValue({
keyword: undefined,
provider: undefined,
executorKey: undefined,
fulfillmentMode: undefined,
payStatus: undefined,
taskStatus: undefined,
dateRange: undefined,
@@ -459,11 +474,11 @@ export default function AdminOrdersPage() {
}}
/>
</Form.Item>
<Form.Item name="executorKey">
<Form.Item name="fulfillmentMode">
<Select
allowClear
placeholder="履约模式"
options={adminOrderExecutorOptions}
options={adminOrderFulfillmentModeOptions}
style={{ minWidth: 130 }}
/>
</Form.Item>
@@ -525,6 +540,13 @@ export default function AdminOrdersPage() {
})}
/>
</Card>
<ManualFulfillmentConfirmModal
open={fulfillOpen}
orderId={fulfillOrderId}
onClose={() => setFulfillOpen(false)}
onConfirmed={() => void query.refetch()}
/>
</section>
)
}
@@ -390,7 +390,13 @@ export function adjustAdminPlatformCredit(payload: {
* 成本不固定时只填金额。
*/
export function fetchAdminManualFulfillmentPending(
params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {},
params: {
page?: number
pageSize?: number
keyword?: string
status?: string
orderId?: number
} = {},
) {
return apiGet<ManualFulfillmentPendingListResult>(
'/api/v1/admin/finance/manual-fulfillment/pending',
@@ -1,7 +1,6 @@
export * from './auth'
export * from './dashboard'
export * from './finance'
export * from './manual-order-cost'
export * from './cost-center'
export * from './fund'
export * from './users'
@@ -1,35 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
/**
* 人工录单:直接扣客服备用金 + 直接写成本快照。
* 没有登记单、没有审批、没有报销环节。
*/
export type AdminManualOrderCostResult = {
expenseNo: string
fundExpenseId: number
fundAccountId: number
fundAccountNo: string
amountFen: number
orderId: number
orderItemId: number
costSnapshotId: number | null
fulfillmentMode: string
warning: string
}
export function fetchAdminManualOrderCostItems() {
return apiGet<{
items: Array<{
id: number
code: string
name: string
costModel: string
pricingMode: string
}>
}>('/api/v1/admin/finance/manual-order-cost-items')
}
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
return apiPost<AdminManualOrderCostResult>('/api/v1/admin/finance/manual-order-costs', payload)
}
+8
View File
@@ -63,6 +63,10 @@ export interface AdminOrderListItem {
fulfillmentMode: AdminOrderFulfillmentMode
manualItemCount: number
pendingItemCount: number
/** 可确认履约的人工项数量 */
manualPendingCount: number
/** 未配置履约渠道的人工项数量 */
manualUnconfiguredCount: number
hasVoucher: boolean
voucherState: AdminVoucherState
workOrderCount?: number
@@ -85,6 +89,10 @@ export interface AdminOrderDetail {
fulfillmentMode: AdminOrderFulfillmentMode
manualItemCount: number
pendingItemCount: number
/** 可确认履约的人工项数量 */
manualPendingCount: number
/** 未配置履约渠道的人工项数量 */
manualUnconfiguredCount: number
hasVoucher: boolean
voucherState: AdminVoucherState
workOrderCount?: number
+10
View File
@@ -28,6 +28,16 @@ export const adminOrderExecutorOptions = [
{ label: '人工履约', value: 'manual_dispatch' },
]
/** 订单列表按 order_items.fulfillment_mode 聚合后的履约方式筛选。 */
export const adminOrderFulfillmentModeOptions = [
{ label: '自动发货', value: 'affiliate_dash' },
{ label: '接单平台', value: 'work_order' },
{ label: '历史平台', value: 'legacy_platform' },
{ label: '人工履约', value: 'manual' },
{ label: '混合履约', value: 'mixed' },
{ label: '待判定', value: 'pending' },
]
export const adminTaskStatusOptions = [
{ label: '全部', value: '' },
{ label: '待支付', value: 'pending_payment' },