316 lines
17 KiB
TypeScript
316 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|