feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询

This commit is contained in:
freedakgmail
2026-08-01 13:10:22 +08:00
parent a90c133578
commit dab06ba109
43 changed files with 9135 additions and 309 deletions
+306
View File
@@ -0,0 +1,306 @@
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 gap-2">
<TrendingUp className="h-6 w-6 text-green-600" />
<h1 className="text-xl font-bold"></h1>
<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>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
<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="种" />
<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}>
<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>
)
}