3d89baf65d
- 后端: 新增 cost-analysis.ts 路由,含31个分析端点和7个调整管理端点 - 前端: 新增 CostAnalysisPage 主页面 + 10个Tab组件 - Tab1-9: 成本总览/菜品盈利/原料差异/BOM配方/供应链/包装耗材/数据质量/门店成本/可视化探索 - Tab10: 调整管理(诊断快照+调整记录+效果验证) - 修复: 毛利率和损耗率从简单平均改为加权计算 - 新增: Tabs组件、侧边栏菜单项、路由注册 - 新增: 3张数据库表(诊断快照/调整记录/验证结果)
158 lines
8.5 KiB
TypeScript
158 lines
8.5 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import api from '@/lib/api'
|
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
|
import { MetricCard } from '@/components/MetricCard'
|
|
import { DataTable } from '@/components/DataTable'
|
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
|
import { Pagination } from '@/components/Pagination'
|
|
import { formatNumber, formatPercent } from '@/lib/utils'
|
|
import { useState } from 'react'
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
|
|
|
const PAGE_SIZE = 15
|
|
|
|
export function BomTab() {
|
|
const [complexPage, setComplexPage] = useState(1)
|
|
const [missingPage, setMissingPage] = useState(1)
|
|
const [selectedSku, setSelectedSku] = useState('')
|
|
|
|
const { data: bomOv, isLoading: l1 } = useQuery({ queryKey: ['ca/bom-overview'], queryFn: () => api.get('/cost-analysis/bom-overview') })
|
|
const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', complexPage], queryFn: () => api.get(`/cost-analysis/bom-complexity?page=${complexPage}&page_size=${PAGE_SIZE}`) })
|
|
const { data: highLoss, isLoading: l3 } = useQuery({ queryKey: ['ca/bom-high-loss'], queryFn: () => api.get('/cost-analysis/bom-high-loss?threshold=20') })
|
|
const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', missingPage], queryFn: () => api.get(`/cost-analysis/bom-missing?page=${missingPage}&page_size=${PAGE_SIZE}`) })
|
|
const { data: composition, isLoading: l5 } = useQuery({
|
|
queryKey: ['ca/bom-composition', selectedSku],
|
|
queryFn: () => api.get(`/cost-analysis/bom-composition?sku_code=${selectedSku}`),
|
|
enabled: !!selectedSku,
|
|
})
|
|
|
|
if (l1 || l2 || l3 || l4) return <LoadingSpinner text="加载BOM数据..." />
|
|
|
|
const ov = (bomOv as any)?.data || {}
|
|
const compData = (complexity as any)?.data || []
|
|
const compMeta = (complexity as any)?.meta || { total: 0 }
|
|
const lossData = (highLoss as any)?.data || []
|
|
const missData = (missing as any)?.data || []
|
|
const missMeta = (missing as any)?.meta || { total: 0 }
|
|
const compDetail = (composition as any)?.data || []
|
|
|
|
const complexityColor = (level: string) => {
|
|
if (level === '重点评审') return 'bg-red-100 text-red-700'
|
|
if (level === '较复杂') return 'bg-orange-100 text-orange-700'
|
|
if (level === '正常') return 'bg-blue-100 text-blue-700'
|
|
return 'bg-green-100 text-green-700'
|
|
}
|
|
|
|
const alertColor = (level: string) => {
|
|
if (level === '极端') return 'bg-red-100 text-red-700'
|
|
if (level === '严重') return 'bg-orange-100 text-orange-700'
|
|
return 'bg-yellow-100 text-yellow-700'
|
|
}
|
|
|
|
const complexityDist = [
|
|
{ band: '≤5', cnt: compData.filter((d: any) => d.material_count <= 5).length },
|
|
{ band: '6-10', cnt: compData.filter((d: any) => d.material_count > 5 && d.material_count <= 10).length },
|
|
{ band: '11-15', cnt: compData.filter((d: any) => d.material_count > 10 && d.material_count <= 15).length },
|
|
{ band: '>15', cnt: compData.filter((d: any) => d.material_count > 15).length },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
|
<MetricCard title="BOM覆盖率" value={ov.coverage_pct} format="percent" description="有BOM的SKU占总SKU比例" />
|
|
<MetricCard title="有BOM的SKU" value={ov.sku_with_bom} format="number" />
|
|
<MetricCard title="缺BOM的SKU" value={ov.sku_without_bom} format="number" />
|
|
<MetricCard title="BOM总行数" value={ov.total_bom_rows} format="number" />
|
|
<MetricCard title="高损耗BOM数" value={ov.high_loss_bom} format="number" description="损耗率>20%" />
|
|
<MetricCard title="平均物料数/SKU" value={ov.avg_materials_per_sku} format="number" />
|
|
</div>
|
|
|
|
<CollapsibleSection title="BOM复杂度分布" subtitle="按物料数量分档">
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<BarChart data={complexityDist}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="band" tick={{ fontSize: 12 }} />
|
|
<YAxis tick={{ fontSize: 10 }} />
|
|
<Tooltip />
|
|
<Bar dataKey="cnt" name="SKU数" fill="#3b82f6" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection title="BOM复杂度明细" subtitle="按物料数降序">
|
|
<Pagination page={complexPage} pageSize={PAGE_SIZE} total={compMeta.total} onPageChange={setComplexPage} />
|
|
<div className="mt-3">
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'sku_code', label: 'SKU编码' },
|
|
{ key: 'dish_name', label: '菜品名', render: (r) => (
|
|
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => setSelectedSku(r.sku_code)}>{r.dish_name}</span>
|
|
)},
|
|
{ key: 'category_l1', label: '品类' },
|
|
{ key: 'material_count', label: '原料数', align: 'right', render: (r) => formatNumber(r.material_count) },
|
|
{ key: 'semi_finished_count', label: '半成品数', align: 'right', render: (r) => formatNumber(r.semi_finished_count) },
|
|
{ key: 'unique_material_count', label: '独有原料', align: 'right', render: (r) => formatNumber(r.unique_material_count) },
|
|
{ key: 'high_loss_count', label: '高损耗原料', align: 'right', render: (r) => r.high_loss_count > 0 ? <span className="text-red-600">{r.high_loss_count}</span> : '0' },
|
|
{ key: 'complexity_level', label: '复杂度', render: (r) => (
|
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${complexityColor(r.complexity_level)}`}>{r.complexity_level}</span>
|
|
)},
|
|
]}
|
|
data={compData}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{selectedSku && (
|
|
<CollapsibleSection title={`菜品物料构成: ${selectedSku}`} subtitle="点击菜品名查看物料详情" defaultOpen={true}>
|
|
{l5 ? <LoadingSpinner text="加载物料构成..." /> : (
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'material_name', label: '物料' },
|
|
{ key: 'material_type', label: '类型', align: 'center' },
|
|
{ key: 'gross_qty', label: '标准毛用量', align: 'right', render: (r) => formatNumber(r.gross_qty) },
|
|
{ key: 'net_qty', label: '标准净用量', align: 'right', render: (r) => formatNumber(r.net_qty) },
|
|
{ key: 'unit', label: '单位', align: 'center' },
|
|
{ key: 'waste_rate', label: '损耗率', align: 'right', render: (r) => formatPercent(r.waste_rate) },
|
|
{ key: 'cost_share', label: '成本占比', align: 'right', render: (r) => formatPercent(r.cost_share) },
|
|
]}
|
|
data={compDetail}
|
|
/>
|
|
)}
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
<CollapsibleSection title="高损耗BOM预警" subtitle="损耗率 ≥ 20%的BOM记录" defaultOpen={false}>
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'dish_name', label: '菜品' },
|
|
{ key: 'material_name', label: '物料' },
|
|
{ key: 'waste_rate', label: '损耗率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.waste_rate)}</span> },
|
|
{ key: 'yield_rate', label: '出成率', align: 'right', render: (r) => formatPercent(r.yield_rate) },
|
|
{ key: 'unit', label: '单位', align: 'center' },
|
|
{ key: 'alert_level', label: '预警级别', render: (r) => (
|
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${alertColor(r.alert_level)}`}>{r.alert_level}</span>
|
|
)},
|
|
]}
|
|
data={lossData}
|
|
/>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection title="缺BOM的SKU清单" subtitle="优先补齐高销量SKU的BOM" defaultOpen={false}>
|
|
<Pagination page={missingPage} pageSize={PAGE_SIZE} total={missMeta.total} onPageChange={setMissingPage} />
|
|
<div className="mt-3">
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'sku_code', label: 'SKU编码' },
|
|
{ key: 'dish_name', label: '菜品名' },
|
|
{ key: 'category_l1', label: '品类' },
|
|
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => r.sales_amount > 0 ? `¥${formatNumber(r.sales_amount)}` : '-' },
|
|
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => r.sales_quantity > 0 ? formatNumber(r.sales_quantity) : '-' },
|
|
]}
|
|
data={missData}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
)
|
|
}
|