feat(finance): 完成成本快照运行层、销售事实与人工支出登记

迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
This commit is contained in:
yml2213
2026-10-22 16:41:03 +08:00
parent 15989af87c
commit 0889235870
39 changed files with 4824 additions and 143 deletions
+4
View File
@@ -21,6 +21,8 @@ import WorkerLayout from '@/layouts/WorkerLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminManualExpensePage = lazy(() => import('@/pages/admin/AdminManualExpensePage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
@@ -146,10 +148,12 @@ function AdminApplication() {
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
<Route path="finance" element={<AdminFinancePage />} />
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
<Route path="cost-center" element={<AdminCostCenterPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="fund" element={<AdminFundPage />} />
<Route path="manual-expenses" element={<AdminManualExpensePage />} />
</Route>
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
+14
View File
@@ -6,7 +6,9 @@ import {
BugOutlined,
DashboardOutlined,
FileSearchOutlined,
FileTextOutlined,
LogoutOutlined,
LineChartOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
OrderedListOutlined,
@@ -132,6 +134,8 @@ export default function AdminLayout() {
role === 'finance'
? [
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
{ key: '/admin/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
{ key: '/admin/manual-expenses', icon: <FileTextOutlined />, label: '人工支出' },
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
@@ -144,10 +148,20 @@ export default function AdminLayout() {
icon: <WalletOutlined />,
label: role === 'support' ? '我的备用金' : '备用金',
},
{
key: '/admin/manual-expenses',
icon: <FileTextOutlined />,
label: '人工支出',
},
...(role === 'support'
? []
: [
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
{
key: '/admin/sales-profit',
icon: <LineChartOutlined />,
label: '销售毛利',
},
{
key: '/admin/cost-center',
icon: <ApartmentOutlined />,
@@ -0,0 +1,692 @@
import {
CheckOutlined,
CloseOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import type { Dayjs } from 'dayjs'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import dayjs from '@/lib/dayjs'
import {
approveAdminManualExpense,
createAdminManualExpense,
fetchAdminManualExpenseCostItems,
fetchAdminManualExpenseOverview,
fetchAdminManualExpenses,
rejectAdminManualExpense,
reimburseAdminManualExpense,
} from '@/services/admin'
import type { AdminManualExpense, AdminManualExpenseStatus } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './panels/shared'
type FilterForm = { dateRange?: unknown; status?: string; keyword?: string }
type ExpenseForm = {
orderId?: number
orderItemId?: number
costItemId?: number
amountYuan?: number
expenseCategory: string
payeeType: string
payeeName: string
payeeAccount?: string
paymentMethod: string
paidAt?: unknown
evidenceReference: string
note?: string
}
const STATUS_META: Record<AdminManualExpenseStatus, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
submitted: { label: '待审核', color: 'orange' },
approved: { label: '已通过', color: 'green' },
rejected: { label: '已驳回', color: 'red' },
reimbursed: { label: '已报销', color: 'blue' },
reversed: { label: '已冲正', color: 'purple' },
}
const CATEGORY_OPTIONS = [
{ value: 'goods_purchase', label: '采购拿货' },
{ value: 'compensation', label: '客户赔偿' },
{ value: 'refund_advance', label: '退款垫付' },
{ value: 'service_fee', label: '服务费' },
{ value: 'freight', label: '运费' },
{ value: 'recharge', label: '平台充值' },
{ value: 'other', label: '其他' },
]
const PAYEE_TYPE_OPTIONS = [
{ value: 'worker', label: '打手' },
{ value: 'upstream', label: '上游/供货' },
{ value: 'customer', label: '客户' },
{ value: 'platform', 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: '其他' },
]
export default function AdminManualExpensePage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const [filterForm] = Form.useForm<FilterForm>()
const [expenseForm] = Form.useForm<ExpenseForm>()
const canReview = hasAdminPermission('finance.review')
const canCreate = hasAdminPermission('fund.expense') || hasAdminPermission('finance.config')
const [filters, setFilters] = useState({
dateFrom: '',
dateTo: '',
status: '',
keyword: '',
})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [actionId, setActionId] = useState<number | null>(null)
const [rejectTarget, setRejectTarget] = useState<AdminManualExpense | null>(null)
const [rejectReason, setRejectReason] = useState('')
const listQuery = useQuery({
queryKey: ['admin-manual-expenses', filters, page, pageSize],
queryFn: () => fetchAdminManualExpenses({ ...filters, page, pageSize }),
})
const overviewQuery = useQuery({
queryKey: ['admin-manual-expense-overview', filters],
queryFn: () => fetchAdminManualExpenseOverview(filters),
})
const costItemsQuery = useQuery({
queryKey: ['admin-manual-expense-cost-items'],
queryFn: fetchAdminManualExpenseCostItems,
})
const list = listQuery.data?.data
const overview = overviewQuery.data?.data?.overview
const costItems = costItemsQuery.data?.data.items || []
useEffect(() => {
const orderId = Number(searchParams.get('orderId') || 0)
if (Number.isInteger(orderId) && orderId > 0) {
setCreateOpen(true)
expenseForm.setFieldsValue({ orderId })
}
}, [expenseForm, searchParams])
useEffect(() => {
if (!createOpen) return
const current = expenseForm.getFieldValue('costItemId')
if (current) return
const systemItem = costItems.find(
(item: { code: string; id: number }) => item.code === 'sys_manual_service',
)
if (systemItem) expenseForm.setFieldValue('costItemId', systemItem.id)
}, [costItems, createOpen, expenseForm])
function refreshAll() {
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expense-overview'] })
}
function applyFilters(values: FilterForm) {
const range = Array.isArray(values.dateRange) ? values.dateRange : []
setPage(1)
setFilters({
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
status: values.status || '',
keyword: String(values.keyword || '').trim(),
})
}
function resetFilters() {
filterForm.resetFields()
setPage(1)
setFilters({ dateFrom: '', dateTo: '', status: '', keyword: '' })
}
async function submitExpense() {
const values = await expenseForm.validateFields()
setCreating(true)
try {
const amountFen = Math.round(Number(values.amountYuan || 0) * 100)
const paidAt = values.paidAt
? dayjs(values.paidAt as Dayjs).format('YYYY-MM-DD HH:mm:ss')
: dayjs().format('YYYY-MM-DD HH:mm:ss')
const response = await createAdminManualExpense({
orderId: values.orderId || undefined,
orderItemId: values.orderItemId || undefined,
costItemId: values.costItemId || undefined,
amountFen,
expenseCategory: values.expenseCategory,
payeeType: values.payeeType,
payeeName: values.payeeName,
payeeAccount: values.payeeAccount || '',
paymentMethod: values.paymentMethod,
paidAt,
evidenceJson: { reference: values.evidenceReference },
note: values.note || '',
})
message.success(`已登记 ${response.data.expenseNo}`)
setCreateOpen(false)
expenseForm.resetFields()
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '登记人工支出失败')
} finally {
setCreating(false)
}
}
function approve(row: AdminManualExpense) {
modal.confirm({
title: '通过人工支出',
content: `确认通过 ${row.expenseNo},金额 ${formatMoney(row.amountFen)}?通过后会计入成本。`,
okText: '通过',
cancelText: '取消',
onOk: async () => {
setActionId(row.id)
try {
await approveAdminManualExpense(row.id)
message.success('人工支出已通过')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '审核失败')
} finally {
setActionId(null)
}
},
})
}
function openReject(row: AdminManualExpense) {
setRejectTarget(row)
setRejectReason('')
}
async function submitReject() {
if (!rejectTarget) return
if (!rejectReason.trim()) {
message.warning('请填写驳回原因')
return
}
setActionId(rejectTarget.id)
try {
await rejectAdminManualExpense(rejectTarget.id, rejectReason.trim())
message.success('人工支出已驳回')
setRejectTarget(null)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '驳回失败')
} finally {
setActionId(null)
}
}
function reimburse(row: AdminManualExpense) {
modal.confirm({
title: '标记已报销',
content: `确认 ${row.expenseNo} 已完成报销?`,
okText: '标记已报销',
cancelText: '取消',
onOk: async () => {
setActionId(row.id)
try {
await reimburseAdminManualExpense(row.id)
message.success('已标记报销')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '标记报销失败')
} finally {
setActionId(null)
}
},
})
}
const columns: TableColumnsType<AdminManualExpense> = [
{
title: '登记号 / 状态',
dataIndex: 'expenseNo',
width: 190,
render: (value: string, row) => {
const meta = STATUS_META[row.status]
return (
<Space direction="vertical" size={2}>
<Typography.Text copyable>{value}</Typography.Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
)
},
},
{
title: '付款时间',
dataIndex: 'paidAt',
width: 165,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '订单 / 商品',
key: 'order',
width: 260,
render: (_, row) => (
<Space direction="vertical" size={2}>
<span>{row.platformOrderId || (row.orderId ? `#${row.orderId}` : '无关联订单')}</span>
<Typography.Text type="secondary" ellipsis>
{row.skuName || row.workOrderNo || '-'}
</Typography.Text>
</Space>
),
},
{
title: '付款项目',
key: 'costItem',
width: 180,
render: (_, row) => (
<Space direction="vertical" size={2}>
<span>{row.costItemName || '未归类'}</span>
<Typography.Text type="secondary">
{CATEGORY_OPTIONS.find((item) => item.value === row.expenseCategory)?.label ||
row.expenseCategory}
</Typography.Text>
</Space>
),
},
{
title: '金额',
dataIndex: 'amountFen',
width: 120,
render: (value: number) => <Typography.Text strong>{formatMoney(value)}</Typography.Text>,
},
{
title: '付款人 → 收款人',
key: 'parties',
width: 190,
render: (_, row) => (
<Space direction="vertical" size={2}>
<span>{row.paidByName || '-'}</span>
<Typography.Text type="secondary">→ {row.payeeName || '-'}</Typography.Text>
</Space>
),
},
{
title: '凭证',
dataIndex: 'evidenceJson',
width: 160,
ellipsis: true,
render: (value: Record<string, unknown>) => {
const reference = typeof value?.reference === 'string' ? value.reference : ''
return (
<Typography.Text ellipsis title={reference}>
{reference || '-'}
</Typography.Text>
)
},
},
{
title: '成本快照',
dataIndex: 'costSnapshotId',
width: 100,
render: (value: number | null) =>
value ? <Tag color="green">#{value}</Tag> : <Tag color="orange">未生成</Tag>,
},
{
title: '操作',
key: 'actions',
width: 190,
fixed: 'right',
render: (_, row) => {
if (!canReview) return <Typography.Text type="secondary">仅查看</Typography.Text>
return (
<Space size={4}>
{row.status === 'submitted' ? (
<>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
loading={actionId === row.id}
onClick={() => approve(row)}
>
通过
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
disabled={actionId === row.id}
onClick={() => openReject(row)}
>
驳回
</Button>
</>
) : null}
{row.status === 'approved' ? (
<Button
size="small"
icon={<WalletOutlined />}
loading={actionId === row.id}
onClick={() => reimburse(row)}
>
已报销
</Button>
) : null}
{row.status === 'reimbursed' ? (
<Typography.Text type="secondary">已完成</Typography.Text>
) : null}
</Space>
)
},
},
]
return (
<section className="page-stack">
<PageHeader
title="人工支出"
description="登记公司实际付出的人工/垫付支出:项目、金额、订单、付款人、收款人与凭证。"
/>
<Card>
<Form<FilterForm>
form={filterForm}
layout="inline"
className="filter-form"
onFinish={applyFilters}
initialValues={{
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
status: '',
keyword: '',
}}
>
<Form.Item name="dateRange" label="付款日期">
<AdminRangePicker format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
...Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
})),
]}
/>
</Form.Item>
<Form.Item name="keyword" label="关键词">
<Input allowClear placeholder="登记号 / 订单号 / 收款人" style={{ width: 220 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button onClick={resetFilters}>重置</Button>
{canCreate ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
登记人工支出
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
刷新
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{overview && overview.unclassifiedCount > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${overview.unclassifiedCount} 条支出未归类,金额 ${formatMoney(overview.unclassifiedAmountFen)}`}
description="未归类的支出无法进入成本报表,请补齐付款项目。"
/>
) : null}
{overview && overview.noOrderCount > 0 ? (
<Alert
type="info"
showIcon
message={`有 ${overview.noOrderCount} 条支出没有关联订单,金额 ${formatMoney(overview.noOrderAmountFen)}`}
description="平台充值、批量拿货、办公支出可以没有订单,但建议在备注里写清用途。"
/>
) : null}
<Row gutter={[12, 12]}>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="待审核"
value={overview?.submittedAmountFen || 0}
formatter={() => formatMoney(overview?.submittedAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic
title="已通过"
value={overview?.approvedAmountFen || 0}
formatter={() => formatMoney(overview?.approvedAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic
title="已报销"
value={overview?.reimbursedAmountFen || 0}
formatter={() => formatMoney(overview?.reimbursedAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic title="待审核笔数" value={overview?.submittedCount || 0} />
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic title="已通过笔数" value={overview?.approvedCount || 0} />
</Card>
</Col>
</Row>
<Card>
<Table<AdminManualExpense>
rowKey="id"
size="small"
columns={columns}
dataSource={list?.items || []}
loading={listQuery.isLoading}
scroll={{ x: 1650 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: list?.pagination.total || 0,
showTotal: (total) => `共 ${total} 条支出`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
<Modal
title="登记人工支出"
open={createOpen}
onCancel={() => {
setCreateOpen(false)
expenseForm.resetFields()
}}
onOk={submitExpense}
confirmLoading={creating}
okText="提交登记"
width={720}
>
<Form<ExpenseForm>
form={expenseForm}
layout="vertical"
initialValues={{
expenseCategory: 'service_fee',
payeeType: 'upstream',
paymentMethod: 'wechat',
paidAt: dayjs(),
}}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="orderId" label="订单 ID(可选)">
<InputNumber min={1} style={{ width: '100%' }} placeholder="有订单时填写" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="orderItemId" label="订单项 ID(可选)">
<InputNumber
min={1}
style={{ width: '100%' }}
placeholder="按订单项归集成本时填写"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="costItemId"
label="付款项目"
rules={[{ required: true, message: '请选择付款项目' }]}
>
<Select
showSearch
optionFilterProp="label"
loading={costItemsQuery.isLoading}
options={costItems.map((item: { id: number; code: string; name: string }) => ({
value: item.id,
label: `${item.name}(${item.code})`,
}))}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="amountYuan"
label="金额(元)"
rules={[{ required: true, message: '请填写金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="expenseCategory" label="支出分类" rules={[{ required: true }]}>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={6}>
<Form.Item name="payeeType" label="收款方类型" rules={[{ required: true }]}>
<Select options={PAYEE_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item
name="payeeName"
label="收款方"
rules={[{ required: true, message: '请填写收款方' }]}
>
<Input placeholder="微信号 / 支付宝 / 姓名" />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item name="payeeAccount" label="收款账号(可选)">
<Input placeholder="账号、手机号或备注" />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={6}>
<Form.Item name="paymentMethod" label="付款方式" rules={[{ required: true }]}>
<Select options={PAYMENT_METHOD_OPTIONS} />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item name="paidAt" label="付款时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item
name="evidenceReference"
label="凭证链接 / 流水号"
rules={[{ required: true, message: '至少留一项可追溯凭证' }]}
>
<Input placeholder="转账截图链接、微信流水号或聊天记录说明" />
</Form.Item>
</Col>
</Row>
<Form.Item name="note" label="备注 / 原因">
<Input.TextArea rows={3} placeholder="赔偿、加急、补差价等必须写清原因" />
</Form.Item>
</Form>
</Modal>
<Modal
title={`驳回 ${rejectTarget?.expenseNo || ''}`}
open={Boolean(rejectTarget)}
onCancel={() => setRejectTarget(null)}
onOk={submitReject}
confirmLoading={Boolean(rejectTarget && actionId === rejectTarget.id)}
okText="确认驳回"
okButtonProps={{ danger: true }}
>
<Input.TextArea
rows={3}
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder="必须填写驳回原因"
/>
</Modal>
</section>
)
}
@@ -1,6 +1,7 @@
import {
ArrowLeftOutlined,
CopyOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
StopOutlined,
@@ -181,42 +182,50 @@ export default function AdminOrderDetailPage() {
<Card
title="基础信息"
extra={
isAutoClaimOrder ? (
<Space>
<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}
>
恢复启用
<Space>
<Button
icon={<FileTextOutlined />}
onClick={() => navigate(`/admin/manual-expenses?orderId=${detail.order.orderId}`)}
>
登记人工支出
</Button>
{isAutoClaimOrder ? (
<>
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
复制 H5 领取链接
</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}
</Space>
) : null
{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">
@@ -0,0 +1,458 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
Form,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import dayjs from '@/lib/dayjs'
import {
fetchAdminSalesDailyFacts,
fetchAdminSalesProfitOverview,
rebuildAdminSalesDailyFacts,
} from '@/services/admin'
import type { AdminSalesDailyFact } from '@/types/admin/finance'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatMoney } from './panels/shared'
type FilterForm = { dateRange?: unknown; provider?: string; carrierType?: string }
const CARRIER_OPTIONS = [
{ value: '', label: '全部载体' },
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'work_order', label: '接单平台' },
{ value: 'manual', label: '人工客服' },
{ value: 'shared', label: '共用' },
]
const CARRIER_LABEL: Record<string, string> = {
affiliate_dash: '自动发货',
work_order: '接单平台',
manual: '人工客服',
shared: '共用',
unknown: '未知',
}
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
missing: { label: '无成本', color: 'red' },
partial: { label: '部分缺失', color: 'orange' },
estimated: { label: '预估成本', color: 'blue' },
actual: { label: '实际成本', color: 'green' },
}
export default function AdminSalesProfitPage() {
const { message } = App.useApp()
const [form] = Form.useForm<FilterForm>()
const canConfig = hasAdminPermission('finance.config')
const [filters, setFilters] = useState<{
dateFrom: string
dateTo: string
provider: string
carrierType: string
}>({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [rebuilding, setRebuilding] = useState(false)
const overviewQuery = useQuery({
queryKey: ['admin-sales-profit-overview', filters],
queryFn: () => fetchAdminSalesProfitOverview(filters),
})
const factsQuery = useQuery({
queryKey: ['admin-sales-daily-facts', filters, page, pageSize],
queryFn: () => fetchAdminSalesDailyFacts({ ...filters, page, pageSize }),
})
const overview = overviewQuery.data?.data
const facts = factsQuery.data?.data
function applyFilters(values: FilterForm) {
const range = Array.isArray(values.dateRange) ? values.dateRange : []
setPage(1)
setFilters({
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
provider: values.provider || '',
carrierType: values.carrierType || '',
})
}
function resetFilters() {
form.resetFields()
setPage(1)
setFilters({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
}
async function rebuildFacts() {
setRebuilding(true)
try {
const response = await rebuildAdminSalesDailyFacts({
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
})
message.success(
`销售事实已重算:${response.data.insertedCount} 行,范围 ${response.data.range.fromDate} ~ ${response.data.range.toDate}`,
)
await Promise.all([overviewQuery.refetch(), factsQuery.refetch()])
} catch (error) {
message.error(error instanceof Error ? error.message : '重算销售事实失败')
} finally {
setRebuilding(false)
}
}
const summary = overview?.overview
const dailyColumns: TableColumnsType<NonNullable<typeof overview>['daily'][number]> = [
{ title: '日期', dataIndex: 'statDate', width: 120 },
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
{ title: '退款', dataIndex: 'refundAmountFen', render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
{
title: '毛利',
dataIndex: 'grossProfitFen',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const productColumns: TableColumnsType<NonNullable<typeof overview>['byProduct'][number]> = [
{
title: '标准商品',
dataIndex: 'canonicalName',
width: 320,
ellipsis: true,
render: (value: string, row) => (
<Space size={4}>
<span>{value}</span>
{row.canonicalProductId ? (
<Tag>{row.canonicalSku}</Tag>
) : (
<Tag color="orange">未归一</Tag>
)}
</Space>
),
},
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
{
title: '毛利',
dataIndex: 'grossProfitFen',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const carrierColumns: TableColumnsType<NonNullable<typeof overview>['byCarrier'][number]> = [
{
title: '履约载体',
dataIndex: 'carrierType',
render: (value: string) => CARRIER_LABEL[value] || value,
},
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
{
title: '毛利',
dataIndex: 'grossProfitFen',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const factColumns: TableColumnsType<AdminSalesDailyFact> = [
{ title: '日期', dataIndex: 'statDate', width: 110 },
{ title: '来源', dataIndex: 'provider', width: 120 },
{
title: '店铺',
dataIndex: 'shopId',
width: 140,
ellipsis: true,
render: (value) => value || '全局',
},
{
title: '商品',
dataIndex: 'canonicalName',
width: 260,
ellipsis: true,
render: (value: string, row) =>
row.canonicalProductId ? value : <Tag color="orange">未归一</Tag>,
},
{
title: '载体',
dataIndex: 'carrierType',
width: 100,
render: (value: string) => CARRIER_LABEL[value] || value,
},
{ title: '订单项', dataIndex: 'itemCount', width: 80 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', width: 120, render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', width: 120, render: formatMoney },
{
title: '成本状态',
dataIndex: 'costStage',
width: 110,
render: (value: string) => {
const meta = COST_STAGE_LABEL[value] || { label: value, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '毛利',
dataIndex: 'grossProfitFen',
width: 130,
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
return (
<section className="page-stack">
<PageHeader
title="销售毛利"
description="按订单项归集收入与成本;未归一商品单独成行,成本缺失时毛利不直接用于决策。"
/>
<Card>
<Form<FilterForm>
form={form}
layout="inline"
className="filter-form"
onFinish={applyFilters}
initialValues={{
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
provider: '',
carrierType: '',
}}
>
<Form.Item name="dateRange" label="销售日期">
<AdminRangePicker format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="provider" label="来源">
<Select
style={{ width: 150 }}
options={[
{ value: '', label: '全部来源' },
{ value: '91kaquan', label: '91卡券' },
{ value: 'pdd', label: '拼多多' },
{ value: 'douyin_mashida', label: '抖音码速达' },
]}
/>
</Form.Item>
<Form.Item name="carrierType" label="载体">
<Select style={{ width: 150 }} options={CARRIER_OPTIONS} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button onClick={resetFilters}>重置</Button>
{canConfig ? (
<Button icon={<ReloadOutlined />} loading={rebuilding} onClick={rebuildFacts}>
重算销售事实
</Button>
) : null}
</Space>
</Form.Item>
</Form>
</Card>
{overviewQuery.error ? (
<Alert
type="error"
showIcon
message="读取销售毛利失败"
description={
overviewQuery.error instanceof Error ? overviewQuery.error.message : '请稍后重试'
}
/>
) : null}
{summary && summary.costMissingCount > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${summary.costMissingCount} 个订单项没有成本快照,当前毛利偏高`}
description="请先在「商品与成本」中补齐成本项/版本并回填快照,再使用毛利数据做经营决策。"
/>
) : null}
{summary && summary.unmappedRevenuePct > 0 ? (
<Alert
type="info"
showIcon
message={`未归一商品收入占 ${summary.unmappedRevenuePct.toFixed(2)}%`}
description={`未归一收入 ${formatMoney(summary.unmappedRevenueFen)},已单独成行;建议补渠道 SKU 映射。`}
/>
) : null}
{summary && summary.ordersWithoutItems > 0 ? (
<Alert
type="info"
showIcon
message={`有 ${summary.ordersWithoutItems} 个已支付订单没有订单项`}
description={`涉及 ${formatMoney(summary.revenueWithoutItemsFen)},无法归集到商品与成本,未进入销售事实。`}
/>
) : null}
<Row gutter={[12, 12]}>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="销售收入"
value={summary?.grossRevenueFen || 0}
formatter={() => formatMoney(summary?.grossRevenueFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="净收入"
value={summary?.netRevenueFen || 0}
formatter={() => formatMoney(summary?.netRevenueFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="成本"
value={summary?.costAmountFen || 0}
formatter={() => formatMoney(summary?.costAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="毛利"
value={summary?.grossProfitFen || 0}
valueStyle={{ color: (summary?.grossProfitFen || 0) >= 0 ? '#3f8600' : '#cf1322' }}
formatter={() => formatMoney(summary?.grossProfitFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic title="订单数" value={summary?.orderCount || 0} />
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic title="订单项" value={summary?.itemCount || 0} />
</Card>
</Col>
</Row>
<Card>
<Tabs
items={[
{
key: 'daily',
label: '日报',
children: (
<Table
rowKey="statDate"
size="small"
columns={dailyColumns}
dataSource={overview?.daily || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'product',
label: '商品',
children: (
<Table
rowKey={(row) => `${row.canonicalProductId || 'unmapped'}`}
size="small"
columns={productColumns}
dataSource={overview?.byProduct || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'carrier',
label: '载体',
children: (
<Table
rowKey="carrierType"
size="small"
columns={carrierColumns}
dataSource={overview?.byCarrier || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 800 }}
/>
),
},
{
key: 'facts',
label: '事实明细',
children: (
<Table
rowKey="id"
size="small"
columns={factColumns}
dataSource={facts?.items || []}
loading={factsQuery.isLoading}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: facts?.pagination.total || 0,
showTotal: (total) => `共 ${total} 条事实`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1400 }}
/>
),
},
]}
/>
</Card>
</section>
)
}
+33 -2
View File
@@ -1,5 +1,10 @@
import { apiGet } from '@/lib/http'
import type { AdminFinanceSummary, AdminFinanceTransactionResult } from '@/types/admin/finance'
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminFinanceSummary,
AdminFinanceTransactionResult,
AdminSalesFactsResult,
AdminSalesProfitOverview,
} from '@/types/admin/finance'
export type AdminFinanceDateType = 'occurred' | 'created' | 'reviewed'
@@ -22,3 +27,29 @@ export function fetchAdminFinanceTransactions(
) {
return apiGet<AdminFinanceTransactionResult>('/api/v1/admin/finance/transactions', params)
}
export type AdminSalesProfitParams = {
dateFrom?: string
dateTo?: string
provider?: string
carrierType?: string
page?: number
pageSize?: number
}
export function fetchAdminSalesProfitOverview(params: AdminSalesProfitParams = {}) {
return apiGet<AdminSalesProfitOverview>('/api/v1/admin/finance/sales-profit/overview', params)
}
export function fetchAdminSalesDailyFacts(params: AdminSalesProfitParams = {}) {
return apiGet<AdminSalesFactsResult>('/api/v1/admin/finance/sales-facts', params)
}
export function rebuildAdminSalesDailyFacts(payload: { dateFrom?: string; dateTo?: string }) {
return apiPost<{
range: { fromDate: string; toDate: string }
deletedCount: number
insertedCount: number
computedAt: string
}>('/api/v1/admin/finance/sales-facts/rebuild', payload)
}
@@ -1,6 +1,7 @@
export * from './auth'
export * from './dashboard'
export * from './finance'
export * from './manual-expense'
export * from './cost-center'
export * from './fund'
export * from './users'
@@ -0,0 +1,70 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminManualExpense,
AdminManualExpenseListResult,
AdminManualExpenseOverview,
} from '@/types/admin/manual-expense'
export type AdminManualExpenseParams = {
dateFrom?: string
dateTo?: string
status?: string
keyword?: string
costItemId?: number
paidByAdminUserId?: number
orderId?: number
page?: number
pageSize?: number
}
export function fetchAdminManualExpenses(params: AdminManualExpenseParams = {}) {
return apiGet<AdminManualExpenseListResult>('/api/v1/admin/finance/manual-expenses', params)
}
export function fetchAdminManualExpenseOverview(params: AdminManualExpenseParams = {}) {
return apiGet<AdminManualExpenseOverview>(
'/api/v1/admin/finance/manual-expenses/overview',
params,
)
}
export function fetchAdminManualExpenseCostItems() {
return apiGet<{
items: Array<{
id: number
code: string
name: string
costType: string
carrierType: string
pricingMode: string
}>
}>('/api/v1/admin/finance/manual-expenses/cost-items')
}
export function fetchAdminManualExpense(expenseId: number) {
return apiGet<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}`)
}
export function createAdminManualExpense(payload: Record<string, unknown>) {
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
}
export function approveAdminManualExpense(
expenseId: number,
payload: Record<string, unknown> = {},
) {
return apiPost<AdminManualExpense>(
`/api/v1/admin/finance/manual-expenses/${expenseId}/approve`,
payload,
)
}
export function rejectAdminManualExpense(expenseId: number, rejectReason: string) {
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reject`, {
rejectReason,
})
}
export function reimburseAdminManualExpense(expenseId: number) {
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reimburse`)
}
+80
View File
@@ -60,3 +60,83 @@ export type AdminFinanceTransactionResult = {
pagination: { page: number; pageSize: number; total: number }
range: { from: string; to: string }
}
export type AdminSalesProfitRange = {
fromDate: string
toDate: string
}
export type AdminSalesProfitOverview = {
range: AdminSalesProfitRange
overview: {
orderCount: number
ordersWithoutItems: number
revenueWithoutItemsFen: number
itemCount: number
grossRevenueFen: number
refundAmountFen: number
netRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
unmappedRevenueFen: number
unmappedRevenuePct: number
}
daily: Array<{
statDate: string
itemCount: number
grossRevenueFen: number
refundAmountFen: number
netRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
}>
byCarrier: Array<{
carrierType: string
itemCount: number
grossRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
}>
byProduct: Array<{
canonicalProductId: number | null
canonicalSku: string
canonicalName: string
itemCount: number
grossRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
}>
}
export type AdminSalesDailyFact = {
id: number
statDate: string
provider: string
shopId: string
canonicalProductId: number | null
canonicalSku: string
canonicalName: string
carrierType: string
orderCount: number
itemCount: number
quantity: number
grossRevenueFen: number
refundAmountFen: number
netRevenueFen: number
costAmountFen: number
costMissingCount: number
costStage: 'missing' | 'partial' | 'estimated' | 'actual'
grossProfitFen: number
computedAt: string
}
export type AdminSalesFactsResult = {
range: AdminSalesProfitRange
items: AdminSalesDailyFact[]
pagination: { page: number; pageSize: number; total: number }
computedAt: string
}
+12
View File
@@ -26,8 +26,20 @@ export type {
AdminFinanceTransaction,
AdminFinanceSummary,
AdminFinanceTransactionResult,
AdminSalesDailyFact,
AdminSalesFactsResult,
AdminSalesProfitOverview,
AdminSalesProfitRange,
} from './finance'
// Manual expense types
export type {
AdminManualExpense,
AdminManualExpenseListResult,
AdminManualExpenseOverview,
AdminManualExpenseStatus,
} from './manual-expense'
// Fund / imprest account types
export type {
AdminFundAccount,
@@ -0,0 +1,82 @@
export type AdminManualExpenseStatus =
| 'draft'
| 'submitted'
| 'approved'
| 'rejected'
| 'reimbursed'
| 'reversed'
export type AdminManualExpense = {
id: number
expenseNo: string
orderId: number | null
orderItemId: number | null
workOrderId: number | null
fundExpenseId: number | null
costItemId: number | null
costItemName: string
costItemCode: string
expenseCategory: string
amountFen: number
currency: string
paidByAdminUserId: number | null
paidByName: string
payeeType: string
payeeName: string
payeeAccount: string
paymentMethod: string
paidAt: string
status: AdminManualExpenseStatus
approvedBy: string
approvedAt: string | null
rejectedBy: string
rejectedAt: string | null
rejectReason: string
reimbursedBy: string
reimbursedAt: string | null
evidenceJson: Record<string, unknown>
costSnapshotId: number | null
reversalOfId: number | null
expenseBatchNo: string
note: string
createdAt: string
updatedAt: string
platformOrderId: string
provider: string
shopId: string
skuCode: string
skuName: string
workOrderNo: string
}
export type AdminManualExpenseListResult = {
range: { fromDate: string; toDate: string }
items: AdminManualExpense[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminManualExpenseOverview = {
range: { fromDate: string; toDate: string }
overview: {
submittedCount: number
submittedAmountFen: number
approvedCount: number
approvedAmountFen: number
reimbursedCount: number
reimbursedAmountFen: number
rejectedCount: number
rejectedAmountFen: number
noOrderCount: number
noOrderAmountFen: number
unclassifiedCount: number
unclassifiedAmountFen: number
}
byPayer: Array<{ paidByName: string; recordCount: number; amountFen: number }>
byCostItem: Array<{
costItemId: number | null
costItemName: string
expenseCategory: string
recordCount: number
amountFen: number
}>
}