Files
SBrainCO/client/src/pages/ProductionPlanPage.tsx
T

316 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <LoadingSpinner />
if (!data) return <div className="p-4 text-muted-foreground"></div>
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 (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-green-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 公式说明 */}
<div className="rounded-lg border bg-green-50/40 p-3 text-sm text-green-800">
<strong></strong> SKU销量 × BOM = <strong></strong> + <strong></strong> - +
</div>
{/* 核心指标 - 第一行:菜品与BOM */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总菜品数" value={summary.total_dish_count} unit="个" />
<MetricCard title="有BOM菜品" value={summary.matched_sku_count} unit="个" status="good" />
<MetricCard title="BOM覆盖率" value={`${summary.bom_coverage_pct}%`} status={summary.bom_coverage_pct >= 50 ? 'good' : 'warn'} />
<MetricCard title="BOM原料数" value={summary.total_bom_materials} unit="种" />
</div>
{/* 核心指标 - 第二行:销售额与生产计划 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="匹配销售额" value={summary.matched_amt} format="currency" description="有BOM菜品的销售额" />
<MetricCard title="总销售额" value={summary.total_sales_amt} format="currency" />
<MetricCard title="CK生产计划" value={ckProductionPlan.filter((p: any) => p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" />
</div>
{/* BOM覆盖率 + Top原料需求 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<CollapsibleSection title="BOM覆盖率" subtitle="有BOM的菜品占比">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={coverageData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(entry: any) => `${entry.name}: ${entry.value}`}>
{coverageData.map((entry, idx) => (
<Cell key={idx} fill={entry.fill} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<div className="lg:col-span-2">
<CollapsibleSection title="Top15原料需求" subtitle="按销量×BOM展开的原料需求量">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={materialChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-25} textAnchor="end" height={70} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatNumber(v)} />
<Bar dataKey="需求量" fill="#52c41a" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
</div>
{/* 中央厨房生产计划 */}
<CollapsibleSection title="中央厨房生产计划" subtitle="销量驱动的半成品需求 vs 实际完工入库">
<div className="mb-3 flex items-center gap-2">
<Factory className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground"> = BOM中半成品用量合计 = - </span>
</div>
<FilterableTable
data={ckProductionPlan}
filterKey="product_name"
filterLabel="全部产品"
sortOptions={[
{ key: 'demand_qty', label: '需求量' },
{ key: 'actual_inbound_qty', label: '实际入库' },
{ key: 'variance_qty', label: '差异量' },
{ key: 'actual_cost', label: '实际成本' },
{ key: 'store_count', label: '门店数' },
]}
defaultSort="demand_qty"
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称' },
{ key: 'unit', label: '单位', align: 'right' },
{ key: 'demand_qty', label: '需求量', align: 'right', render: (r: any) => 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) => (
<span className={cn(r.variance_qty > 0 ? 'text-red-600' : 'text-green-600')}>{formatNumber(r.variance_qty)}</span>
) },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (r: any) => (
<span className={cn('font-medium', r.variance_pct === null ? 'text-gray-400' : Math.abs(r.variance_pct) <= 10 ? 'text-green-600' : 'text-red-600')}>
{r.variance_pct === null ? '-' : `${r.variance_pct > 0 ? '+' : ''}${r.variance_pct.toFixed(1)}%`}
</span>
) },
{ key: 'store_count', label: '涉及门店', align: 'right' },
{ key: 'actual_cost', label: '实际成本', align: 'right', render: (r: any) => formatCurrency(r.actual_cost) },
]}
/>
</CollapsibleSection>
{/* 生产与配送协同 */}
<CollapsibleSection title="生产与配送协同" subtitle="完工入库 → 配送 → 门店消耗 → 期末库存">
<div className="mb-3 flex items-center gap-2">
<Truck className="h-4 w-4 text-blue-600" />
<span className="text-sm text-muted-foreground"> = / = / = /</span>
</div>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={coordChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-25} textAnchor="end" height={70} />
<YAxis tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatNumber(v)} />
<Bar dataKey="完工入库" fill="#1677ff" />
<Bar dataKey="配送量" fill="#52c41a" />
<Bar dataKey="门店消耗" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<FilterableTable
data={productionCoord}
filterKey="product_name"
filterLabel="全部产品"
sortOptions={[
{ key: 'inbound_qty', label: '完工入库' },
{ key: 'dist_qty', label: '配送量' },
{ key: 'consumption_qty', label: '门店消耗' },
{ key: 'ending_qty', label: '期末库存' },
{ key: 'actual_cost', label: '实际成本' },
]}
defaultSort="actual_cost"
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称' },
{ key: 'inbound_qty', label: '完工入库', align: 'right', render: (r: any) => 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) => (
<span className={rateColorClass(r.completion_distribution_rate)}>{rateText(r.completion_distribution_rate)}</span>
) },
{ key: 'distribution_consumption_rate', label: '配送消耗率', align: 'right', render: (r: any) => (
<span className={rateColorClass(r.distribution_consumption_rate)}>{rateText(r.distribution_consumption_rate)}</span>
) },
{ key: 'inventory_accumulation_rate', label: '库存积压率', align: 'right', render: (r: any) => (
<span className={r.inventory_accumulation_rate === null ? 'text-gray-400' : r.inventory_accumulation_rate > 30 ? 'text-red-600' : 'text-green-600'}>
{rateText(r.inventory_accumulation_rate)}
</span>
) },
]}
/>
</div>
</CollapsibleSection>
{/* 门店要货建议 */}
<CollapsibleSection title="门店要货建议" subtitle={`${storeDemand.length}家门店的原料需求与库存对比`} defaultOpen={false}>
<div className="mb-3 flex items-center gap-2">
<Package className="h-4 w-4 text-green-600" />
<span className="text-sm text-muted-foreground"> = - </span>
</div>
<FilterableTable
data={storeDemand}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'total_amt', label: '销售额' },
{ key: 'total_qty', label: '销量' },
{ key: 'total_material_demand_qty', label: '原料需求量' },
{ key: 'ending_inventory_amt', label: '期末库存额' },
{ key: 'suggested_order_qty', label: '建议要货量' },
{ key: 'sku_count', label: '菜品数' },
]}
defaultSort="total_amt"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'sku_count', label: '有BOM菜品数', align: 'right' },
{ key: 'total_qty', label: '销量', align: 'right', render: (r: any) => 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) => (
<span className={cn('font-medium', r.suggested_order_qty > 0 ? 'text-blue-600' : 'text-green-600')}>{formatNumber(r.suggested_order_qty)}</span>
) },
]}
/>
</CollapsibleSection>
{/* 原料需求明细 */}
<CollapsibleSection title="原料需求明细" subtitle={`${materialDemand.length}种原料的BOM展开需求`} defaultOpen={false}>
<FilterableTable
data={materialDemand}
filterKey="material_name"
filterLabel="全部原料"
sortOptions={[
{ key: 'total_demand_qty', label: '需求量' },
{ key: 'total_demand_amt', label: '需求金额' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_store_demand', label: '门店均需求' },
]}
defaultSort="total_demand_amt"
columns={[
{ key: 'material_code', label: '原料编码' },
{ key: 'material_name', label: '原料名称' },
{ key: 'unit', label: '单位', align: 'right' },
{ key: 'total_demand_qty', label: '总需求量', align: 'right', render: (r: any) => 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) },
]}
/>
</CollapsibleSection>
{/* SKU销量Top50 */}
<CollapsibleSection title="SKU销量Top50" subtitle="含BOM匹配状态" defaultOpen={false}>
<FilterableTable
data={skuSales}
filterKey="dish_name"
filterLabel="全部菜品"
sortOptions={[
{ key: 'amt', label: '销售额' },
{ key: 'qty', label: '销量' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_unit_price', label: '均价' },
{ key: 'material_count', label: '材料数' },
]}
defaultSort="amt"
statusFilterKey="has_bom"
statusFilterLabel="全部状态"
statusOptions={[
{ value: 'true', label: '有BOM' },
{ value: 'false', label: '无BOM' },
]}
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'sku_code', label: 'SKU编码', render: (r: any) => 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 ? <span className="text-green-600"></span> : <span className="text-red-500"></span>
) },
{ key: 'material_count', label: '材料数', align: 'right', render: (r: any) => r.material_count || '-' },
]}
/>
</CollapsibleSection>
</div>
)
}