8f5271fcc6
- 修复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个已修复/确认无需修复的问题条目
426 lines
20 KiB
TypeScript
426 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|