feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user