import { useState, useMemo, useCallback, useEffect, type ReactNode } 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 { FilterableTable } from '@/components/FilterableTable' import { formatNumber, formatCurrency, cn } from '@/lib/utils' import { Network, ChevronRight, ChevronDown, Layers, GitBranch, Box, Package, Loader2, X } 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)}%` } interface BomTreeNode { level: number material_code: string material_name: string unit: string theoretical_qty: number issue_qty: number theoretical_amt: number issue_amt: number avg_unit_price: number is_finished_product: boolean parent_material_code: string root_product_code: string root_product_name: string root_recipe: string path: string children: BomTreeNode[] } function buildTree(flatTree: any[]): BomTreeNode[] { const nodeMap = new Map() const roots: BomTreeNode[] = [] flatTree.forEach((n: any) => { const node: BomTreeNode = { ...n, children: [] } nodeMap.set(node.path, node) }) flatTree.forEach((n: any) => { const node = nodeMap.get(n.path)! if (n.level === 1) { roots.push(node) } else { const parentPath = n.path.split(' -> ').slice(0, -1).join(' -> ') const parent = nodeMap.get(parentPath) if (parent) parent.children.push(node) else roots.push(node) } }) return roots } export function BomPenetrationPage() { const [month, setMonth] = useState('2026-04') const [selectedProduct, setSelectedProduct] = useState(null) const [expandedNodes, setExpandedNodes] = useState>(new Set()) const [lazyChildren, setLazyChildren] = useState>(new Map()) const [loadingNodes, setLoadingNodes] = useState>(new Set()) 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, }) const bomTree = productData?.bomTree || [] // BOM树构建 const bomTreeData = useMemo(() => buildTree(bomTree), [bomTree]) const maxLevel = useMemo(() => Math.max(0, ...bomTree.map((n: any) => n.level)), [bomTree]) const toggleNode = useCallback(async (code: string, isFinishedProduct: boolean) => { if (expandedNodes.has(code)) { setExpandedNodes(prev => { const n = new Set(prev); n.delete(code); return n }) return } setExpandedNodes(prev => new Set(prev).add(code)) if (isFinishedProduct && !lazyChildren.has(code)) { setLoadingNodes(prev => new Set(prev).add(code)) try { const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${code}`) const children = buildTree(res.data.bomTree || []) setLazyChildren(prev => new Map(prev).set(code, children)) } catch (e) { setLazyChildren(prev => new Map(prev).set(code, [])) } finally { setLoadingNodes(prev => { const n = new Set(prev); n.delete(code); return n }) } } }, [expandedNodes, lazyChildren, month]) const openProductModal = (code: string) => { setSelectedProduct(code) setExpandedNodes(new Set()) setLazyChildren(new Map()) setLoadingNodes(new Set()) } // 数据加载后自动展开所有有子级的节点 useEffect(() => { if (!bomTreeData.length) return const toExpand = new Set() const collect = (nodes: BomTreeNode[]) => { nodes.forEach(n => { if (n.children.length > 0) { toExpand.add(n.material_code) collect(n.children) } }) } collect(bomTreeData) if (toExpand.size > 0) { setExpandedNodes(prev => new Set([...prev, ...toExpand])) } }, [bomTreeData]) const closeModal = () => { setSelectedProduct(null) setExpandedNodes(new Set()) setLazyChildren(new Map()) setLoadingNodes(new Set()) } if (overviewLoading) return if (!overviewData) return
暂无数据
const { productList, bomSummary, multiLevelChains } = overviewData // 汇总指标 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, })) const renderTreeRows = (nodes: BomTreeNode[], depth: number): ReactNode[] => { const rows: ReactNode[] = [] nodes.forEach((node) => { const isExpanded = expandedNodes.has(node.material_code) const hasChildren = node.children.length > 0 const lazyLoaded = lazyChildren.get(node.material_code) const isLoading = loadingNodes.has(node.material_code) const showChevron = hasChildren || node.is_finished_product const childrenToRender = hasChildren ? node.children : (lazyLoaded || []) const isZero = node.theoretical_qty === 0 && node.issue_qty === 0 && node.theoretical_amt === 0 && node.issue_amt === 0 rows.push(
{showChevron ? ( ) : ( )} {node.is_finished_product && } {node.material_name} {isZero && 用量为0}
{node.material_code} {node.unit} {formatNumber(node.theoretical_qty)} {formatNumber(node.issue_qty)} {formatCurrency(node.theoretical_amt)} {formatCurrency(node.issue_amt)} = 0 ? 'text-yellow-600' : 'text-red-600')}> {formatCurrency(node.issue_amt - node.theoretical_amt)} ) if (isExpanded && childrenToRender.length > 0) { rows.push(...renderTreeRows(childrenToRender, depth + 1)) } if (isExpanded && !hasChildren && lazyLoaded && lazyLoaded.length === 0 && !isLoading) { rows.push( 无子配方数据 ) } }) return rows } // Treemap数据 const treemapData = bomSummary.slice(0, 30).map((b: any) => ({ name: b.product_name, size: b.bom_theoretical_amt, variance: b.variance_pct, })) return (
{/* 页面标题 */}

多级BOM成本穿透

{/* 核心指标 */}
= 0 ? 'warn' : 'bad'} />
{/* BOM成本结构Top15 */} `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> formatCurrency(v)} />
{b.product_name} }, { key: 'material_count', label: '材料数', align: 'right' }, { key: 'sub_product_count', label: '半成品数', align: 'right', render: (b) => b.sub_product_count > 0 ? {b.sub_product_count} : '-' }, { key: 'bom_theoretical_amt', label: '理论成本', align: 'right', render: (b) => formatCurrency(b.bom_theoretical_amt) }, { key: 'bom_issue_amt', label: '领用成本', align: 'right', render: (b) => formatCurrency(b.bom_issue_amt) }, { key: 'bom_unit_theoretical_cost', label: '单位理论成本', align: 'right', render: (b) => formatNumber(b.bom_unit_theoretical_cost) }, { key: 'bom_unit_issue_cost', label: '单位领用成本', align: 'right', render: (b) => formatNumber(b.bom_unit_issue_cost) }, { key: 'variance_amt', label: '差异金额', align: 'right', render: (b) => = 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(b.variance_amt)} }, { key: 'variance_pct', label: '差异率', align: 'right', render: (b) => {varianceText(b.variance_pct)} }, ]} onRowClick={(b) => setSelectedProduct(b.product_code)} />
{/* BOM成本Treemap */} } /> {/* 产品选择器 */} {p.product_name} }, { key: 'category_minor', label: '品类' }, { key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) }, { key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) }, { key: 'actual_cost', label: '实际成本', align: 'right', render: (p) => formatCurrency(p.actual_cost) }, { key: 'material_count', label: '材料数', align: 'right' }, { key: 'has_multi_level_bom', label: '多级BOM', align: 'center', render: (p) => p.has_multi_level_bom ? : '-' }, { key: 'action', label: '', render: (p) => }, ]} onRowClick={(p) => openProductModal(p.product_code)} /> {/* 半成品依赖链 */}
展示成品与半成品之间的多级BOM依赖关系,可快速定位半成品成本波动影响的成品范围
{c.finished_name} }, { key: 'finished_recipe', label: '成品配方' }, { key: 'arrow', label: '', render: () => }, { key: 'sub_product_code', label: '半成品编码' }, { key: 'sub_product_finished_name', label: '半成品名称', render: (c) => {c.sub_product_finished_name || c.sub_product_name} }, { key: 'sub_product_recipe', label: '半成品配方', render: (c) => c.sub_product_recipe || '-' }, ]} />
{/* BOM树弹窗 */} {selectedProduct && (
e.stopPropagation()}>

BOM树穿透 - {productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}

共{bomTree.length}个节点,最大{maxLevel}层
{productLoading ? ( ) : bomTree.length === 0 ? (
该产品无配方数据
) : (
{renderTreeRows(bomTreeData, 0)}
物料名称 编码 单位 理论量 领用量 理论金额 领用金额 差异金额
)}
)}
) } 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 ( {name?.length > 10 ? name.slice(0, 10) + '...' : name} ¥{(size / 10000).toFixed(1)}万 ) }