Files
SBrainCO/client/src/pages/BomPenetrationPage.tsx
T
freedakgmail 8f5271fcc6 fix: P2/P3审计问题修复 + 更新审计文档移除已修复问题
- 修复schema前缀:确认central_kitchen/mv_distribution/mv_dish_sales等表在public schema,撤销错误的analytics.前缀
- 移除API内部物化视图刷新:mv_distribution_monthly/mv_dish_sales_monthly改为console.warn
- 修复BOM树节点key:使用path字段替代material_code+level确保唯一性
- 修复HR人才矩阵potential_score:基于v3_learning_record动态计算替代硬编码60
- 修复StoreDetailPage公司均值:优先使用API返回的companyAvg
- 修复前端字段名匹配:discount_rate_pct/check_comment等
- 添加explicit column list和LIMIT到大型查询
- 更新数据溯源审计.md:移除全部52个已修复/确认无需修复的问题条目
2026-08-11 22:00:58 +08:00

426 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, BomTreeNode>()
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<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const [lazyChildren, setLazyChildren] = useState<Map<string, BomTreeNode[]>>(new Map())
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(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<string>()
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 <LoadingSpinner />
if (!overviewData) return <div className="p-4 text-muted-foreground"></div>
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(
<tr key={node.path} className={cn('border-b hover:bg-muted/50', node.is_finished_product && 'bg-purple-50/30', isZero && 'bg-yellow-50/40')}>
<td className="py-1.5 pr-3" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
<div className="flex items-center gap-1.5">
{showChevron ? (
<button onClick={() => toggleNode(node.material_code, node.is_finished_product)} className="flex-shrink-0">
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="inline-block w-3.5" />
)}
{node.is_finished_product && <Box className="h-3.5 w-3.5 text-purple-600 flex-shrink-0" />}
<span className={cn('text-xs', node.is_finished_product && 'font-medium text-purple-700')}>{node.material_name}</span>
{isZero && <span className="rounded bg-yellow-100 px-1 py-0.5 text-[10px] text-yellow-700">0</span>}
</div>
</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.material_code}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.unit}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.theoretical_qty)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.issue_qty)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.issue_amt)}</td>
<td className="py-1.5 pr-3 text-right text-xs">
<span className={cn('font-medium', node.issue_amt - node.theoretical_amt >= 0 ? 'text-yellow-600' : 'text-red-600')}>
{formatCurrency(node.issue_amt - node.theoretical_amt)}
</span>
</td>
<td className="py-1.5 pr-3" />
</tr>
)
if (isExpanded && childrenToRender.length > 0) {
rows.push(...renderTreeRows(childrenToRender, depth + 1))
}
if (isExpanded && !hasChildren && lazyLoaded && lazyLoaded.length === 0 && !isLoading) {
rows.push(
<tr key={node.material_code + '_empty'} className="border-b">
<td className="py-1 pr-3 text-xs text-muted-foreground" style={{ paddingLeft: `${(depth + 1) * 20 + 8}px` }}>
</td>
<td colSpan={8} />
</tr>
)
}
})
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 (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Network className="h-6 w-6 text-purple-600" />
<h1 className="text-xl font-bold">BOM成本穿透</h1>
</div>
<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">
<FilterableTable
data={bomSummary}
searchKeys={['product_code', 'product_name']}
searchPlaceholder="搜索产品..."
sortOptions={[
{ key: 'bom_theoretical_amt', label: '理论成本' },
{ key: 'bom_issue_amt', label: '领用成本' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
{ key: 'material_count', label: '材料数' },
]}
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称', render: (b) => <span className="font-medium text-blue-600">{b.product_name}</span> },
{ key: 'material_count', label: '材料数', align: 'right' },
{ key: 'sub_product_count', label: '半成品数', align: 'right', render: (b) => b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-' },
{ 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) => <span className={b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(b.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (b) => <span className={`font-medium ${varianceColorClass(b.variance_pct)}`}>{varianceText(b.variance_pct)}</span> },
]}
onRowClick={(b) => setSelectedProduct(b.product_code)}
/>
</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>
{/* 产品选择器 */}
<CollapsibleSection title="产品列表" subtitle={`${productList.length}个产品`}>
<FilterableTable
data={productList}
searchKeys={['product_code', 'product_name']}
searchPlaceholder="搜索产品..."
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'actual_cost', label: '实际成本' },
{ key: 'inbound_quantity', label: '入库量' },
{ key: 'material_count', label: '材料数' },
]}
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称', render: (p) => <span className="font-medium">{p.product_name}</span> },
{ 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 ? <span className="text-purple-600"></span> : '-' },
{ key: 'action', label: '', render: (p) => <button className="text-xs text-blue-600 hover:underline" onClick={(e) => { e.stopPropagation(); openProductModal(p.product_code) }}>BOM</button> },
]}
onRowClick={(p) => openProductModal(p.product_code)}
/>
</CollapsibleSection>
{/* 半成品依赖链 */}
<CollapsibleSection title="半成品依赖链" subtitle={`${multiLevelChains.length}条成品→半成品依赖关系`} defaultOpen={false}>
<div className="mb-3 flex items-center gap-2">
<Network className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">BOM依赖关系</span>
</div>
<FilterableTable
data={multiLevelChains}
searchKeys={['finished_code', 'finished_name', 'sub_product_code', 'sub_product_name']}
searchPlaceholder="搜索成品/半成品..."
sortOptions={[
{ key: 'finished_name', label: '成品名称' },
{ key: 'sub_product_name', label: '半成品名称' },
]}
columns={[
{ key: 'finished_code', label: '成品编码' },
{ key: 'finished_name', label: '成品名称', render: (c) => <span className="font-medium">{c.finished_name}</span> },
{ key: 'finished_recipe', label: '成品配方' },
{ key: 'arrow', label: '', render: () => <ChevronRight className="h-4 w-4 text-purple-600" /> },
{ key: 'sub_product_code', label: '半成品编码' },
{ key: 'sub_product_finished_name', label: '半成品名称', render: (c) => <span className="font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</span> },
{ key: 'sub_product_recipe', label: '半成品配方', render: (c) => c.sub_product_recipe || '-' },
]}
/>
</CollapsibleSection>
{/* BOM树弹窗 */}
{selectedProduct && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={closeModal}>
<div className="max-h-[85vh] w-[90vw] max-w-5xl overflow-hidden rounded-lg bg-white shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5 text-purple-600" />
<h2 className="text-base font-semibold">
BOM树穿透 - {productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}
</h2>
<span className="text-xs text-muted-foreground">{bomTree.length}{maxLevel}</span>
</div>
<button onClick={closeModal} className="rounded p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(85vh-60px)] overflow-auto p-4">
{productLoading ? (
<LoadingSpinner />
) : bomTree.length === 0 ? (
<div className="py-8 text-center text-muted-foreground"></div>
) : (
<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-right"></th>
</tr>
</thead>
<tbody>
{renderTreeRows(bomTreeData, 0)}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
</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>
)
}