import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie } from 'recharts' import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { FilterableTable } from '@/components/FilterableTable' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { cn } from '@/lib/utils' import { TrendingUp, Factory, Truck, Package, ArrowRight } from 'lucide-react' import { MonthPicker } from '@/components/MonthPicker' function rateColorClass(rate: number | null): string { if (rate === null) return 'text-gray-400' if (rate >= 90) return 'text-green-600' if (rate >= 70) return 'text-yellow-600' return 'text-red-600' } function rateText(rate: number | null): string { if (rate === null) return '-' return `${rate.toFixed(1)}%` } export function ProductionPlanPage() { const [month, setMonth] = useState('2026-04') const { data, isLoading } = useQuery({ queryKey: ['production-plan', month], queryFn: async () => { const res = await api.get(`/sales-driven/production-plan?month=${month}`) return res.data }, }) if (isLoading) return if (!data) return
暂无数据
const { summary, skuSales, materialDemand, storeDemand, ckProductionPlan, productionCoord } = data // BOM覆盖率饼图 const coverageData = [ { name: '有BOM', value: summary.matched_sku_count, fill: '#52c41a' }, { name: '无BOM', value: summary.total_dish_count - summary.matched_sku_count, fill: '#f5222d' }, ] // Top10原料需求 const topMaterialDemand = materialDemand.slice(0, 15) const materialChartData = topMaterialDemand.map((m: any) => ({ name: m.material_name?.length > 10 ? m.material_name.slice(0, 10) + '...' : m.material_name, 需求量: m.total_demand_qty, 门店数: m.store_count, })) // 图表数据用Top15 const topCkPlan = ckProductionPlan.filter((p: any) => p.demand_qty > 0).slice(0, 15) // 生产配送协同图表Top15 const topCoord = productionCoord.filter((p: any) => p.inbound_qty > 0).slice(0, 15) const coordChartData = topCoord.map((p: any) => ({ name: p.product_name?.length > 10 ? p.product_name.slice(0, 10) + '...' : p.product_name, 完工入库: p.inbound_qty, 配送量: p.dist_qty, 门店消耗: p.consumption_qty, })) return (
{/* 页面标题 */}

销量驱动生产与要货计划

{/* 公式说明 */}
预测逻辑: SKU销量 × 门店BOM = 基础原料需求 | 生产计划: 各门店要货合计 + 中央厨房安全库存 | 要货建议: 需求量 - 当前库存 + 安全库存
{/* 核心指标 - 第一行:菜品与BOM */}
= 50 ? 'good' : 'warn'} />
{/* 核心指标 - 第二行:销售额与生产计划 */}
p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" />
{/* BOM覆盖率 + Top原料需求 */}
`${entry.name}: ${entry.value}`}> {coverageData.map((entry, idx) => ( ))}
formatNumber(v)} />
{/* 中央厨房生产计划 */}
需求量 = 各门店BOM中半成品用量合计,差异 = 需求 - 实际入库
formatNumber(r.demand_qty) }, { key: 'actual_inbound_qty', label: '实际入库', align: 'right', render: (r: any) => formatNumber(r.actual_inbound_qty) }, { key: 'variance_qty', label: '差异量', align: 'right', render: (r: any) => ( 0 ? 'text-red-600' : 'text-green-600')}>{formatNumber(r.variance_qty)} ) }, { key: 'variance_pct', label: '差异率', align: 'right', render: (r: any) => ( {r.variance_pct === null ? '-' : `${r.variance_pct > 0 ? '+' : ''}${r.variance_pct.toFixed(1)}%`} ) }, { key: 'store_count', label: '涉及门店', align: 'right' }, { key: 'actual_cost', label: '实际成本', align: 'right', render: (r: any) => formatCurrency(r.actual_cost) }, ]} />
{/* 生产与配送协同 */}
完工配送率 = 配送/完工 | 配送消耗率 = 消耗/配送 | 库存积压率 = 期末/消耗
`${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 11 }} /> formatNumber(v)} />
formatNumber(r.inbound_qty) }, { key: 'dist_qty', label: '配送量', align: 'right', render: (r: any) => formatNumber(r.dist_qty) }, { key: 'consumption_qty', label: '门店消耗', align: 'right', render: (r: any) => formatNumber(r.consumption_qty) }, { key: 'ending_qty', label: '期末库存', align: 'right', render: (r: any) => formatNumber(r.ending_qty) }, { key: 'completion_distribution_rate', label: '完工配送率', align: 'right', render: (r: any) => ( {rateText(r.completion_distribution_rate)} ) }, { key: 'distribution_consumption_rate', label: '配送消耗率', align: 'right', render: (r: any) => ( {rateText(r.distribution_consumption_rate)} ) }, { key: 'inventory_accumulation_rate', label: '库存积压率', align: 'right', render: (r: any) => ( 30 ? 'text-red-600' : 'text-green-600'}> {rateText(r.inventory_accumulation_rate)} ) }, ]} />
{/* 门店要货建议 */}
建议要货量 = 原料需求量 - 期末库存量,为门店级宏观参考;实际要货请按「原料需求」表逐项确认,注意单位差异
formatNumber(r.total_qty) }, { key: 'total_amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.total_amt) }, { key: 'total_material_demand_qty', label: '原料需求量', align: 'right', render: (r: any) => formatNumber(r.total_material_demand_qty) }, { key: 'ending_inventory_qty', label: '期末库存量', align: 'right', render: (r: any) => formatNumber(r.ending_inventory_qty) }, { key: 'ending_inventory_amt', label: '期末库存额', align: 'right', render: (r: any) => formatCurrency(r.ending_inventory_amt) }, { key: 'suggested_order_qty', label: '建议要货量', align: 'right', render: (r: any) => ( 0 ? 'text-blue-600' : 'text-green-600')}>{formatNumber(r.suggested_order_qty)} ) }, ]} />
{/* 原料需求明细 */} formatNumber(r.total_demand_qty) }, { key: 'total_demand_amt', label: '总需求金额', align: 'right', render: (r: any) => formatCurrency(r.total_demand_amt) }, { key: 'store_count', label: '涉及门店数', align: 'right' }, { key: 'avg_store_demand', label: '门店均需求', align: 'right', render: (r: any) => formatNumber(r.avg_store_demand) }, ]} /> {/* SKU销量Top50 */} r.sku_code || '-' }, { key: 'qty', label: '销量', align: 'right', render: (r: any) => formatNumber(r.qty) }, { key: 'amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.amt) }, { key: 'store_count', label: '门店数', align: 'right' }, { key: 'avg_unit_price', label: '均价', align: 'right', render: (r: any) => formatCurrency(r.avg_unit_price) }, { key: 'has_bom', label: '有BOM', align: 'center', render: (r: any) => ( r.has_bom ? : ) }, { key: 'material_count', label: '材料数', align: 'right', render: (r: any) => r.material_count || '-' }, ]} />
) }