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
+342
View File
@@ -0,0 +1,342 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Treemap } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency } from '@/lib/utils'
import { Network, ChevronRight, Layers, GitBranch } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function varianceColorClass(pct: number): string {
if (Math.abs(pct) <= 3) return 'text-green-600'
if (Math.abs(pct) <= 10) return 'text-yellow-600'
return 'text-red-600'
}
function varianceText(pct: number): string {
if (pct > 0) return `+${pct.toFixed(2)}%`
return `${pct.toFixed(2)}%`
}
export function BomPenetrationPage() {
const [month, setMonth] = useState('2026-04')
const [selectedProduct, setSelectedProduct] = useState('0202028')
const [productFilter, setProductFilter] = useState('')
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['bom-penetration-overview', month],
queryFn: async () => {
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}`)
return res.data
},
})
const { data: productData, isLoading: productLoading } = useQuery({
queryKey: ['bom-penetration', month, selectedProduct],
queryFn: async () => {
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${selectedProduct}`)
return res.data
},
enabled: !!selectedProduct,
})
if (overviewLoading) return <LoadingSpinner />
if (!overviewData) return <div className="p-4 text-muted-foreground"></div>
const { productList, bomSummary, multiLevelChains } = overviewData
const bomTree = productData?.bomTree || []
// 汇总指标
const totalProducts = productList.length
const multiLevelProducts = productList.filter((p: any) => p.has_multi_level_bom).length
const totalBomTheoretical = bomSummary.reduce((s: number, b: any) => s + b.bom_theoretical_amt, 0)
const totalBomIssue = bomSummary.reduce((s: number, b: any) => s + b.bom_issue_amt, 0)
const totalVariance = bomSummary.reduce((s: number, b: any) => s + b.variance_amt, 0)
// BOM汇总Top10
const bomSummaryTop10 = bomSummary.slice(0, 15)
const bomChartData = bomSummaryTop10.map((b: any) => ({
name: b.product_name,
理论成本: b.bom_theoretical_amt,
领用成本: b.bom_issue_amt,
}))
// BOM树按层级分组
const treeByLevel: Record<number, any[]> = {}
bomTree.forEach((node: any) => {
if (!treeByLevel[node.level]) treeByLevel[node.level] = []
treeByLevel[node.level].push(node)
})
const maxLevel = Math.max(...Object.keys(treeByLevel).map(Number), 1)
// Treemap数据
const treemapData = bomSummary.slice(0, 30).map((b: any) => ({
name: b.product_name,
size: b.bom_theoretical_amt,
variance: b.variance_pct,
}))
const filteredProducts = productList.filter((p: any) =>
!productFilter ||
p.product_code?.includes(productFilter) ||
p.product_name?.includes(productFilter)
)
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<Network className="h-6 w-6 text-purple-600" />
<h1 className="text-xl font-bold">BOM成本穿透</h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
<MetricCard title="完工产品数" value={totalProducts} unit="个" />
<MetricCard title="含多级BOM" value={multiLevelProducts} unit="个" status="warn" description="配方中包含半成品的产成品数" />
<MetricCard title="BOM理论成本" value={totalBomTheoretical} format="currency" />
<MetricCard title="BOM领用成本" value={totalBomIssue} format="currency" />
<MetricCard title="差异金额" value={totalVariance} format="currency" status={totalVariance >= 0 ? 'warn' : 'bad'} />
<MetricCard title="半成品依赖链" value={multiLevelChains.length} unit="条" description="成品→半成品的依赖关系数" />
</div>
{/* BOM成本结构Top15 */}
<CollapsibleSection title="BOM成本结构Top15" subtitle="按理论成本排序的产成品BOM汇总">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={bomChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="理论成本" fill="#1677ff" />
<Bar dataKey="领用成本" fill="#722ed1" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{bomSummaryTop10.map((b: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50 cursor-pointer" onClick={() => setSelectedProduct(b.product_code)}>
<td className="py-1.5 pr-3 text-xs">{b.product_code}</td>
<td className="py-1.5 pr-3 font-medium text-blue-600">{b.product_name}</td>
<td className="py-1.5 pr-3 text-right">{b.material_count}</td>
<td className="py-1.5 pr-3 text-right">
{b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-'}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_issue_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_issue_cost)}</td>
<td className={`py-1.5 pr-3 text-right ${b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(b.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(b.variance_pct)}`}>
{varianceText(b.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* BOM成本Treemap */}
<CollapsibleSection title="BOM成本分布Treemap" subtitle="按理论成本大小展示产成品BOM分布" defaultOpen={false}>
<ResponsiveContainer width="100%" height={400}>
<Treemap
data={treemapData}
dataKey="size"
stroke="#fff"
fill="#722ed1"
content={<CustomTreemapContent />}
/>
</ResponsiveContainer>
</CollapsibleSection>
{/* BOM树穿透 */}
<CollapsibleSection
title={`BOM树穿透 - ${productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}`}
subtitle={`${bomTree.length}个节点,最大${maxLevel}`}
>
<div className="mb-3 flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">BOM树</span>
</div>
{productLoading ? (
<LoadingSpinner />
) : (
<div className="space-y-3">
{Object.entries(treeByLevel).map(([level, nodes]) => (
<div key={level} className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<Layers className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">Level {level}</span>
<span className="text-xs text-muted-foreground">({nodes.length})</span>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
{nodes.map((node: any, i: number) => (
<div
key={i}
className={`rounded border p-2 ${node.is_finished_product ? 'border-purple-300 bg-purple-50/50' : 'border-gray-200'}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{node.material_name}</span>
{node.is_finished_product && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-600"></span>
)}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>: {node.material_code}</span>
<span>: {node.unit}</span>
</div>
<div className="mt-1 flex justify-between text-xs">
<span>: {formatCurrency(node.theoretical_amt)}</span>
<span>: {formatCurrency(node.issue_amt)}</span>
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>: {formatNumber(node.theoretical_qty)}</span>
<span>: {formatNumber(node.issue_qty)}</span>
</div>
{node.is_finished_product && (
<button
className="mt-1 text-xs text-purple-600 hover:underline"
onClick={() => setSelectedProduct(node.material_code)}
>
BOM
</button>
)}
</div>
))}
</div>
</div>
))}
{bomTree.length === 0 && (
<div className="py-4 text-center text-muted-foreground"></div>
)}
</div>
)}
</CollapsibleSection>
{/* 产品选择器 */}
<CollapsibleSection title="产品列表" subtitle={`${filteredProducts.length}个产品`} defaultOpen={false}
headerRight={
<input
type="text"
placeholder="搜索产品..."
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-center">BOM</th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{filteredProducts.map((p: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.actual_cost)}</td>
<td className="py-1.5 pr-3 text-right">{p.material_count}</td>
<td className="py-1.5 pr-3 text-center">
{p.has_multi_level_bom ? <span className="text-purple-600"></span> : '-'}
</td>
<td className="py-1.5 pr-3">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => setSelectedProduct(p.product_code)}
>
BOM
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 半成品依赖链 */}
<CollapsibleSection title="半成品依赖链" subtitle={`${multiLevelChains.length}条成品→半成品依赖关系`} defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{multiLevelChains.map((c: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{c.finished_code}</td>
<td className="py-1.5 pr-3 font-medium">{c.finished_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.finished_recipe}</td>
<td className="py-1.5 pr-3"><ChevronRight className="h-4 w-4 text-purple-600" /></td>
<td className="py-1.5 pr-3 text-xs">{c.sub_product_code}</td>
<td className="py-1.5 pr-3 font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.sub_product_recipe || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
</div>
)
}
function CustomTreemapContent(props: any) {
const { x, y, width, height, name, size, variance } = props
if (width < 50 || height < 30) return null
const fill = Math.abs(variance || 0) <= 3 ? '#52c41a' : Math.abs(variance || 0) <= 10 ? '#faad14' : '#f5222d'
return (
<g>
<rect x={x} y={y} width={width} height={height} stroke="#fff" fill={fill} fillOpacity={0.7} />
<text x={x + 4} y={y + 16} fontSize={11} fill="#333">
{name?.length > 10 ? name.slice(0, 10) + '...' : name}
</text>
<text x={x + 4} y={y + 30} fontSize={10} fill="#666">
¥{(size / 10000).toFixed(1)}
</text>
</g>
)
}