Files
order_site/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx
T
yml2213 9d5af359fa feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛
- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度
- 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment
- 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单
- 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径
- 文档:同步迁移基线、合并评估、代码复核与上线说明
2026-10-27 16:08:16 +08:00

469 lines
15 KiB
TypeScript

import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
Form,
Row,
Select,
Space,
Statistic,
Tabs,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
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; fulfillmentMode?: string }
const FULFILLMENT_MODE_OPTIONS = [
{ value: '', label: '全部履约方式' },
{ value: 'work_order', label: '接单平台' },
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'legacy_platform', label: '历史平台' },
{ value: 'manual', label: '人工客服' },
]
const FULFILLMENT_MODE_LABEL: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
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.post')
const defaultDateFrom = dayjs().subtract(29, 'day').format('YYYY-MM-DD')
const defaultDateTo = dayjs().format('YYYY-MM-DD')
const [filters, setFilters] = useState<{
dateFrom: string
dateTo: string
provider: string
fulfillmentMode: string
}>({ dateFrom: defaultDateFrom, dateTo: defaultDateTo, provider: '', fulfillmentMode: '' })
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 || '',
fulfillmentMode: values.fulfillmentMode || '',
})
}
function resetFilters() {
form.resetFields()
setPage(1)
setFilters({
dateFrom: defaultDateFrom,
dateTo: defaultDateTo,
provider: '',
fulfillmentMode: '',
})
}
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 fulfillmentModeColumns: TableColumnsType<
NonNullable<typeof overview>['byFulfillmentMode'][number]
> = [
{
title: '履约方式',
dataIndex: 'fulfillmentMode',
render: (value: string) => FULFILLMENT_MODE_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: 'fulfillmentMode',
width: 100,
render: (value: string) => FULFILLMENT_MODE_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">
<FinanceSubNav current="/admin/sales-profit" />
<PageHeader
title="销售毛利"
description="按订单项归集收入与成本;未归一商品单独成行,成本缺失时毛利不直接用于决策。"
/>
<Card>
<Form<FilterForm>
form={form}
layout="inline"
className="filter-form"
onFinish={applyFilters}
initialValues={{
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
provider: '',
fulfillmentMode: '',
}}
>
<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="fulfillmentMode" label="履约方式">
<Select style={{ width: 150 }} options={FULFILLMENT_MODE_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} 个订单项没有成本快照,涉及收入 ${formatMoney(summary.costMissingRevenueFen)},当前毛利偏高`}
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: (
<PercentTable
rowKey="statDate"
size="small"
columns={dailyColumns}
dataSource={overview?.daily || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'product',
label: '商品',
children: (
<PercentTable
rowKey={(row) => `${row.canonicalProductId || 'unmapped'}`}
size="small"
columns={productColumns}
dataSource={overview?.byProduct || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'fulfillment-mode',
label: '履约方式',
children: (
<PercentTable
rowKey="fulfillmentMode"
size="small"
columns={fulfillmentModeColumns}
dataSource={overview?.byFulfillmentMode || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 800 }}
/>
),
},
{
key: 'facts',
label: '事实明细',
children: (
<PercentTable
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>
)
}