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 || '-' },
]}
/>
)
}