4b3a48976d
新增 MonthPicker 的页面(6个): - CostPage: 成本与库存管理 - SKUPage: 商品SKU管理 - PlatformPage: 平台与优惠管理 - MemberPage: 会员与复购管理 - SiteSelectionPage: 门店选址分析 - DataQualityPage: 数据质量看板 容器+子组件添加 MonthPicker(2个页面, 11个子组件): - StoreExpensePage: 门店营业费用分析(10个子组件全部传month) - CostAnalysisPage/StoreTab: 门店成本tab 跳过(后端不支持month参数): - RiskPage: 风险管理(全量视图, 不按月过滤) - TimePage: 时间段分析(全量视图, 不按月过滤)
206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import api from '@/lib/api'
|
||
import { DataTable } from '@/components/DataTable'
|
||
import { Pagination } from '@/components/Pagination'
|
||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||
import { MetricCard } from '@/components/MetricCard'
|
||
import { Badge } from '@/components/Badge'
|
||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||
import { useState, useMemo } from 'react'
|
||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts'
|
||
import { MonthPicker } from '@/components/MonthPicker'
|
||
|
||
const PAGE_SIZE = 15
|
||
|
||
export function CostPage() {
|
||
const [costPage, setCostPage] = useState(1)
|
||
const [invPage, setInvPage] = useState(1)
|
||
const [catPage, setCatPage] = useState(1)
|
||
const [month, setMonth] = useState('2026-04')
|
||
|
||
const { data: costData, isLoading: costLoading } = useQuery({
|
||
queryKey: ['cost/comparison', month],
|
||
queryFn: () => api.get('/cost/comparison', { params: { month } }),
|
||
})
|
||
|
||
const { data: invData, isLoading: invLoading } = useQuery({
|
||
queryKey: ['cost/inventory', month],
|
||
queryFn: () => api.get('/cost/inventory', { params: { month } }),
|
||
})
|
||
|
||
const { data: catData, isLoading: catLoading } = useQuery({
|
||
queryKey: ['cost/category-benchmark', month],
|
||
queryFn: () => api.get('/cost/category-benchmark', { params: { month } }),
|
||
})
|
||
|
||
const costRows = (costData as any)?.data || []
|
||
const invRows = (invData as any)?.data || []
|
||
const catRows = (catData as any)?.data || []
|
||
|
||
const pageLoading = costLoading || invLoading || catLoading
|
||
|
||
const validCostRows = costRows.filter((r: any) => !r.variance_level?.includes('口径异常'))
|
||
const abnormalRows = costRows.filter((r: any) => r.variance_level?.includes('口径异常'))
|
||
|
||
const avgTheoreticalRate = validCostRows.length > 0
|
||
? validCostRows.reduce((s: number, r: any) => s + Number(r.theoretical_cost_rate_pct || 0), 0) / validCostRows.length
|
||
: 0
|
||
const avgActualRate = validCostRows.length > 0
|
||
? validCostRows.reduce((s: number, r: any) => s + Number(r.actual_food_cost_rate_pct || 0), 0) / validCostRows.length
|
||
: 0
|
||
const highVarianceCount = validCostRows.filter((r: any) => Number(r.variance_to_theoretical_pct || 0) > 20).length
|
||
const abnormalInvCount = invRows.filter((r: any) => Number(r.estimated_inventory_days) > 7 || Number(r.negative_item_lines) > 0).length
|
||
|
||
const pagedCost = useMemo(() => costRows.slice((costPage - 1) * PAGE_SIZE, costPage * PAGE_SIZE), [costRows, costPage])
|
||
const pagedInv = useMemo(() => invRows.slice((invPage - 1) * PAGE_SIZE, invPage * PAGE_SIZE), [invRows, invPage])
|
||
const pagedCat = useMemo(() => catRows.slice((catPage - 1) * PAGE_SIZE, catPage * PAGE_SIZE), [catRows, catPage])
|
||
|
||
const scatterData = validCostRows.map((r: any) => ({
|
||
name: r.store_name,
|
||
theoretical: Number(r.theoretical_cost_rate_pct || 0),
|
||
actual: Number(r.actual_food_cost_rate_pct || 0),
|
||
variance: Number(r.variance_to_theoretical_pct || 0),
|
||
}))
|
||
|
||
if (pageLoading) {
|
||
return <LoadingSpinner text="加载成本数据..." />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold">成本与库存管理</h1>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">理论vs实际成本 · 原料分类差异 · 库存效率 · {month}</p>
|
||
</div>
|
||
<MonthPicker month={month} onChange={setMonth} />
|
||
</div>
|
||
|
||
{/* 概览指标 */}
|
||
<CollapsibleSection title="成本概览" subtitle="全门店成本效率汇总">
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="平均理论成本率" value={avgTheoreticalRate} format="percent" description="按标准BOM计算的理论食材成本占实收比例(排除口径异常门店)" />
|
||
<MetricCard title="平均实际成本率" value={avgActualRate} format="percent" description="盘点倒挤的实际食材成本占实收比例(排除口径异常门店)" />
|
||
<MetricCard title="高偏差门店(>20%)" value={highVarianceCount} format="number" description="实际成本率超理论20%以上的门店数(排除口径异常)" />
|
||
<MetricCard title="口径异常门店" value={abnormalRows.length} format="number" description="理论成本数据缺失或口径异常,不纳入成本率统计" />
|
||
</div>
|
||
{abnormalRows.length > 0 && (
|
||
<div className="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
<span className="font-medium text-gray-700">口径异常门店({abnormalRows.length}家):</span>
|
||
{abnormalRows.map((r: any) => r.store_name).join('、')}
|
||
— 理论成本数据缺失或口径不一致,需先修复数据再纳入对比
|
||
</p>
|
||
</div>
|
||
)}
|
||
</CollapsibleSection>
|
||
|
||
{/* 成本散点图 */}
|
||
<CollapsibleSection title="理论 vs 实际成本率" subtitle="每个点代表一家门店,偏离对角线越多成本偏差越大">
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 40]} />
|
||
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 50]} />
|
||
<Tooltip
|
||
cursor={{ strokeDasharray: '3 3' }}
|
||
content={({ active, payload }: any) => {
|
||
if (active && payload && payload.length) {
|
||
const d = payload[0].payload
|
||
return (
|
||
<div className="rounded border bg-card p-2 text-xs shadow-sm">
|
||
<p className="font-medium">{d.name}</p>
|
||
<p>理论: {d.theoretical.toFixed(1)}%</p>
|
||
<p>实际: {d.actual.toFixed(1)}%</p>
|
||
<p>偏差: {d.variance > 0 ? '+' : ''}{d.variance.toFixed(1)}%</p>
|
||
</div>
|
||
)
|
||
}
|
||
return null
|
||
}}
|
||
/>
|
||
<Scatter data={scatterData} fill="#3b82f6" />
|
||
</ScatterChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
|
||
{/* 成本对比表 */}
|
||
<CollapsibleSection title={`门店成本对比 (${costRows.length})`} subtitle="理论成本 vs 实际倒挤成本,按偏差降序">
|
||
<Pagination page={costPage} pageSize={PAGE_SIZE} total={costRows.length} onPageChange={setCostPage} />
|
||
<div className="mt-3">
|
||
<DataTable
|
||
columns={[
|
||
{ key: 'store_name', label: '门店' },
|
||
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
|
||
{ key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
|
||
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
|
||
const v = Number(r.variance_to_theoretical_pct || 0)
|
||
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
|
||
}},
|
||
{ key: 'variance_level', label: '偏差等级', render: (r) => (
|
||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
|
||
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
|
||
'bg-green-100 text-green-700'
|
||
}`}>{r.variance_level || '-'}</span>
|
||
)},
|
||
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
|
||
]}
|
||
data={pagedCost}
|
||
/>
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* 库存效率 */}
|
||
<CollapsibleSection title={`库存效率 (${invRows.length})`} subtitle="库存天数、负耗用、异常货品" defaultOpen={false}>
|
||
<Pagination page={invPage} pageSize={PAGE_SIZE} total={invRows.length} onPageChange={setInvPage} />
|
||
<div className="mt-3">
|
||
<DataTable
|
||
columns={[
|
||
{ key: 'store_name', label: '门店' },
|
||
{ key: 'business_type', label: '业态' },
|
||
{ key: 'scale_tier', label: '规模' },
|
||
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
|
||
const d = Number(r.estimated_inventory_days || 0)
|
||
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}天</span>
|
||
}},
|
||
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
|
||
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
|
||
const n = Number(r.negative_item_lines || 0)
|
||
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
|
||
}},
|
||
{ key: 'abnormal_item_lines', label: '异常货品行', align: 'center', render: (r) => {
|
||
const n = Number(r.abnormal_item_lines || 0)
|
||
return n > 0 ? <span className="font-medium text-yellow-600">{n}</span> : <span className="text-muted-foreground">0</span>
|
||
}},
|
||
]}
|
||
data={pagedInv}
|
||
/>
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* 原料分类差异 */}
|
||
<CollapsibleSection title={`原料分类成本基准 (${catRows.length})`} subtitle="按财务分类对比同业基准" defaultOpen={false}>
|
||
<Pagination page={catPage} pageSize={PAGE_SIZE} total={catRows.length} onPageChange={setCatPage} />
|
||
<div className="mt-3">
|
||
<DataTable
|
||
columns={[
|
||
{ key: 'store_name', label: '门店' },
|
||
{ key: 'finance_category', label: '财务分类' },
|
||
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
|
||
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
|
||
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
|
||
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
|
||
const v = Number(r.excess_vs_peer_per_10k || 0)
|
||
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
|
||
}},
|
||
]}
|
||
data={pagedCat}
|
||
/>
|
||
</div>
|
||
</CollapsibleSection>
|
||
</div>
|
||
)
|
||
}
|