- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明
469 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|