From 3d89baf65df01b2b10cb36d664fa2bea70643cc9 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Tue, 28 Jul 2026 16:56:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8F=9C=E5=93=81=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E5=88=86=E6=9E=90=E6=A8=A1=E5=9D=97=20-=2010=E4=B8=AATab=20+?= =?UTF-8?q?=2031=E4=B8=AA=E5=88=86=E6=9E=90API=20+=207=E4=B8=AA=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E7=AE=A1=E7=90=86API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 新增 cost-analysis.ts 路由,含31个分析端点和7个调整管理端点 - 前端: 新增 CostAnalysisPage 主页面 + 10个Tab组件 - Tab1-9: 成本总览/菜品盈利/原料差异/BOM配方/供应链/包装耗材/数据质量/门店成本/可视化探索 - Tab10: 调整管理(诊断快照+调整记录+效果验证) - 修复: 毛利率和损耗率从简单平均改为加权计算 - 新增: Tabs组件、侧边栏菜单项、路由注册 - 新增: 3张数据库表(诊断快照/调整记录/验证结果) --- client/package-lock.json | 15 +- client/package.json | 1 + client/src/App.tsx | 2 + client/src/components/Layout.tsx | 3 +- client/src/components/Tabs.tsx | 28 + .../cost-analysis/AdjustmentTab.tsx | 285 +++++ .../src/components/cost-analysis/BomTab.tsx | 157 +++ .../components/cost-analysis/ExploreTab.tsx | 56 + .../components/cost-analysis/MaterialTab.tsx | 164 +++ .../components/cost-analysis/OverviewTab.tsx | 129 +++ .../components/cost-analysis/PackagingTab.tsx | 64 ++ .../cost-analysis/ProfitabilityTab.tsx | 128 +++ .../components/cost-analysis/QualityTab.tsx | 96 ++ .../src/components/cost-analysis/StoreTab.tsx | 98 ++ .../components/cost-analysis/SupplyTab.tsx | 170 +++ client/src/pages/CostAnalysisPage.tsx | 51 + server/src/index.ts | 2 + server/src/routes/cost-analysis.ts | 978 ++++++++++++++++++ 菜品成本分析前端方案.md | 525 ++++++++++ 菜品成本分析报告.md | 173 ++++ 菜品成本分析指标体系.md | 607 +++++++++++ 菜品调整指导与验证体系.md | 410 ++++++++ 22 files changed, 4137 insertions(+), 5 deletions(-) create mode 100644 client/src/components/Tabs.tsx create mode 100644 client/src/components/cost-analysis/AdjustmentTab.tsx create mode 100644 client/src/components/cost-analysis/BomTab.tsx create mode 100644 client/src/components/cost-analysis/ExploreTab.tsx create mode 100644 client/src/components/cost-analysis/MaterialTab.tsx create mode 100644 client/src/components/cost-analysis/OverviewTab.tsx create mode 100644 client/src/components/cost-analysis/PackagingTab.tsx create mode 100644 client/src/components/cost-analysis/ProfitabilityTab.tsx create mode 100644 client/src/components/cost-analysis/QualityTab.tsx create mode 100644 client/src/components/cost-analysis/StoreTab.tsx create mode 100644 client/src/components/cost-analysis/SupplyTab.tsx create mode 100644 client/src/pages/CostAnalysisPage.tsx create mode 100644 server/src/routes/cost-analysis.ts create mode 100644 菜品成本分析前端方案.md create mode 100644 菜品成本分析报告.md create mode 100644 菜品成本分析指标体系.md create mode 100644 菜品调整指导与验证体系.md diff --git a/client/package-lock.json b/client/package-lock.json index 88b5040..08f466b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@tanstack/react-query": "^5.51.1", + "@types/react-leaflet": "^2.8.3", "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -1379,14 +1380,12 @@ "version": "7946.0.16", "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, "node_modules/@types/leaflet": { "version": "1.9.21", "resolved": "https://registry.npmmirror.com/@types/leaflet/-/leaflet-1.9.21.tgz", "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", - "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -1406,14 +1405,12 @@ "version": "15.7.15", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1430,6 +1427,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-leaflet": { + "version": "2.8.3", + "resolved": "https://registry.npmmirror.com/@types/react-leaflet/-/react-leaflet-2.8.3.tgz", + "integrity": "sha512-MeBQnVQe6ikw8dkuZE4F96PvMdQeilZG6/ekk5XxhkSzU3lofedULn3UR/6G0uIHjbRazi4DA8LnLACX0bPhBg==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "*", + "@types/react": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", diff --git a/client/package.json b/client/package.json index 39e94f0..7ea6a62 100644 --- a/client/package.json +++ b/client/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.51.1", + "@types/react-leaflet": "^2.8.3", "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 86b8328..9f72deb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import { RegionalPage } from '@/pages/RegionalPage' import { StorePage } from '@/pages/StorePage' import { SKUPage } from '@/pages/SKUPage' import { CostPage } from '@/pages/CostPage' +import { CostAnalysisPage } from '@/pages/CostAnalysisPage' import { PlatformPage } from '@/pages/PlatformPage' import { MemberPage } from '@/pages/MemberPage' import { RiskPage } from '@/pages/RiskPage' @@ -71,6 +72,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 5078c26..26a08a7 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { ReactNode, useState } from 'react' import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin } from 'lucide-react' +import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart } from 'lucide-react' import { cn } from '@/lib/utils' interface LayoutProps { @@ -37,6 +37,7 @@ const menuGroups: MenuGroup[] = [ items: [ { path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] }, { path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] }, + { path: '/cost-analysis', label: '菜品成本分析', icon: PieChart, roles: ['hq', 'dept'] }, { path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] }, { path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] }, { path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] }, diff --git a/client/src/components/Tabs.tsx b/client/src/components/Tabs.tsx new file mode 100644 index 0000000..467fb75 --- /dev/null +++ b/client/src/components/Tabs.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils' + +interface TabsProps { + tabs: { key: string; label: string }[] + active: string + onChange: (key: string) => void +} + +export function Tabs({ tabs, active, onChange }: TabsProps) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ) +} diff --git a/client/src/components/cost-analysis/AdjustmentTab.tsx b/client/src/components/cost-analysis/AdjustmentTab.tsx new file mode 100644 index 0000000..16e0ab1 --- /dev/null +++ b/client/src/components/cost-analysis/AdjustmentTab.tsx @@ -0,0 +1,285 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatCurrency, formatPercent, formatNumber, priorityColor } from '@/lib/utils' +import { useState } from 'react' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend } from 'recharts' + +const PAGE_SIZE = 15 + +const ACTION_LABELS: Record = { + 'price_up': '涨价', + 'price_down': '降价', + 'recipe_optimize': '优化配方', + 'portion_reduce': '减份量', + 'delist': '下架', + 'evaluate_delist': '评估下架', + 'fix_data': '修复数据', + 'monitor': '监控', + 'keep': '保持', +} + +const STATUS_LABELS: Record = { + 'planned': '待执行', + 'executing': '执行中', + 'completed': '已完成', + 'cancelled': '已取消', +} + +export function AdjustmentTab() { + const queryClient = useQueryClient() + const [subTab, setSubTab] = useState<'diagnosis' | 'adjustment' | 'verify'>('diagnosis') + const [diagPage, setDiagPage] = useState(1) + const [adjPage, setAdjPage] = useState(1) + const [diagPriority, setDiagPriority] = useState('') + const [adjStatus, setAdjStatus] = useState('') + const [showForm, setShowForm] = useState(false) + const [verifyId, setVerifyId] = useState(null) + const [formData, setFormData] = useState({}) + + const { data: diag, isLoading: ld } = useQuery({ queryKey: ['ca/diagnosis', diagPage, diagPriority], queryFn: () => api.get(`/cost-analysis/diagnosis?page=${diagPage}&page_size=${PAGE_SIZE}${diagPriority ? `&priority=${diagPriority}` : ''}`) }) + const { data: adj, isLoading: la } = useQuery({ queryKey: ['ca/adjustment', adjPage, adjStatus], queryFn: () => api.get(`/cost-analysis/adjustment?page=${adjPage}&page_size=${PAGE_SIZE}${adjStatus ? `&status=${adjStatus}` : ''}`) }) + const { data: verify, isLoading: lv } = useQuery({ queryKey: ['ca/adjustment/verify', verifyId], queryFn: () => api.get(`/cost-analysis/adjustment/${verifyId}/verify`), enabled: verifyId !== null }) + + const generateDiagnosis = useMutation({ + mutationFn: () => api.post('/cost-analysis/diagnosis/generate'), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ca/diagnosis'] }) }, + }) + + const createAdjustment = useMutation({ + mutationFn: (data: any) => api.post('/cost-analysis/adjustment', data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ca/adjustment'] }); setShowForm(false); setFormData({}) }, + }) + + const updateStatus = useMutation({ + mutationFn: ({ id, status }: { id: number; status: string }) => api.put(`/cost-analysis/adjustment/${id}`, { status }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ca/adjustment'] }), + }) + + const diagData = (diag as any)?.data || [] + const diagMeta = (diag as any)?.meta || { total: 0 } + const adjData = (adj as any)?.data || [] + const adjMeta = (adj as any)?.meta || { total: 0 } + const verifyData = (verify as any)?.data || null + + const p0Count = diagData.filter((d: any) => d.priority === 'P0').length + const p1Count = diagData.filter((d: any) => d.priority === 'P1').length + + const statusColor = (status: string) => { + if (status === 'executing') return 'bg-blue-100 text-blue-700' + if (status === 'completed') return 'bg-green-100 text-green-700' + if (status === 'cancelled') return 'bg-red-100 text-red-700' + return 'bg-gray-100 text-gray-700' + } + + const handleCreateAdjustment = (diag: any) => { + setFormData({ + dish_code: diag.dish_code, + dish_name: diag.dish_name, + adjustment_type: diag.suggested_action === 'price_up' ? 'price' : diag.suggested_action === 'portion_reduce' ? 'portion' : diag.suggested_action === 'delist' ? 'delisting' : 'recipe', + before_theoretical_margin: diag.theoretical_margin_pct, + before_cost_variance: diag.cost_variance_amount, + reason: diag.diagnosis_detail, + diagnosis_id: diag.id, + effective_date: new Date().toISOString().slice(0, 10), + }) + setShowForm(true) + } + + const handleSubmitForm = () => { + createAdjustment.mutate(formData) + } + + return ( +
+
+ + + +
+ + {subTab === 'diagnosis' && ( + <> +
+ + {generateDiagnosis.data && 已生成 {(generateDiagnosis.data as any)?.data?.generated} 条诊断} + +
+ +
+ + + +
+ + {ld ? : ( + <> + +
+ formatPercent(r.theoretical_margin_pct) }, + { key: 'actual_margin_pct', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin_pct) }, + { key: 'cost_variance_amount', label: '成本差异', align: 'right', render: (r) => formatCurrency(r.cost_variance_amount) }, + { key: 'suggested_action', label: '建议动作', render: (r) => ACTION_LABELS[r.suggested_action] || r.suggested_action }, + { key: 'priority', label: '优先级', render: (r) => ( + {r.priority} + )}, + { key: 'action', label: '操作', render: (r) => ( + + )}, + ]} + data={diagData} + /> +
+ + )} + + {showForm && ( + +
+
+
+
+ + +
+
setFormData({ ...formData, effective_date: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" />
+
+
setFormData({ ...formData, after_target_margin: Number(e.target.value) })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" />
+
setFormData({ ...formData, decided_by: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" />
+
setFormData({ ...formData, reason: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" />
+
+
+ + +
+
+ )} + + )} + + {subTab === 'adjustment' && ( + <> +
+ +
+ {la ? : ( + <> + +
+ ACTION_LABELS[r.adjustment_type] || r.adjustment_type }, + { key: 'effective_date', label: '执行日期' }, + { key: 'before_theoretical_margin', label: '调整前毛利率', align: 'right', render: (r) => formatPercent(r.before_theoretical_margin) }, + { key: 'after_target_margin', label: '目标毛利率', align: 'right', render: (r) => formatPercent(r.after_target_margin) }, + { key: 'decided_by', label: '决策人' }, + { key: 'status', label: '状态', render: (r) => ( + {STATUS_LABELS[r.status] || r.status} + )}, + { key: 'actions', label: '操作', render: (r) => ( +
+ {r.status === 'planned' && } + {r.status === 'executing' && } + +
+ )}, + ]} + data={adjData} + /> +
+ + )} + + )} + + {subTab === 'verify' && ( + <> +
+ + setVerifyId(e.target.value ? Number(e.target.value) : null)} placeholder="输入ID" className="rounded border px-3 py-1.5 text-sm w-32" /> +
+ {verifyId && ( + lv ? : verifyData ? ( + <> +
+ + + + +
+ + + + ({ ...d, period: '调整前' })), + ...verifyData.after.map((d: any) => ({ ...d, period: '调整后' })), + ]}> + + + + + + + + + + + + ACTION_LABELS[r.adjustment_type] || r.adjustment_type }, + { key: 'effective_date', label: '执行日期' }, + { key: 'reason', label: '调整原因' }, + { key: 'status', label: '状态', render: (r) => ( + {STATUS_LABELS[r.status] || r.status} + )}, + ]} + data={[verifyData.adjustment]} + /> + + + ) :

输入调整记录ID查看验证数据

+ )} + + )} +
+ ) +} diff --git a/client/src/components/cost-analysis/BomTab.tsx b/client/src/components/cost-analysis/BomTab.tsx new file mode 100644 index 0000000..15689c9 --- /dev/null +++ b/client/src/components/cost-analysis/BomTab.tsx @@ -0,0 +1,157 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' + +const PAGE_SIZE = 15 + +export function BomTab() { + const [complexPage, setComplexPage] = useState(1) + const [missingPage, setMissingPage] = useState(1) + const [selectedSku, setSelectedSku] = useState('') + + const { data: bomOv, isLoading: l1 } = useQuery({ queryKey: ['ca/bom-overview'], queryFn: () => api.get('/cost-analysis/bom-overview') }) + const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', complexPage], queryFn: () => api.get(`/cost-analysis/bom-complexity?page=${complexPage}&page_size=${PAGE_SIZE}`) }) + const { data: highLoss, isLoading: l3 } = useQuery({ queryKey: ['ca/bom-high-loss'], queryFn: () => api.get('/cost-analysis/bom-high-loss?threshold=20') }) + const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', missingPage], queryFn: () => api.get(`/cost-analysis/bom-missing?page=${missingPage}&page_size=${PAGE_SIZE}`) }) + const { data: composition, isLoading: l5 } = useQuery({ + queryKey: ['ca/bom-composition', selectedSku], + queryFn: () => api.get(`/cost-analysis/bom-composition?sku_code=${selectedSku}`), + enabled: !!selectedSku, + }) + + if (l1 || l2 || l3 || l4) return + + const ov = (bomOv as any)?.data || {} + const compData = (complexity as any)?.data || [] + const compMeta = (complexity as any)?.meta || { total: 0 } + const lossData = (highLoss as any)?.data || [] + const missData = (missing as any)?.data || [] + const missMeta = (missing as any)?.meta || { total: 0 } + const compDetail = (composition as any)?.data || [] + + const complexityColor = (level: string) => { + if (level === '重点评审') return 'bg-red-100 text-red-700' + if (level === '较复杂') return 'bg-orange-100 text-orange-700' + if (level === '正常') return 'bg-blue-100 text-blue-700' + return 'bg-green-100 text-green-700' + } + + const alertColor = (level: string) => { + if (level === '极端') return 'bg-red-100 text-red-700' + if (level === '严重') return 'bg-orange-100 text-orange-700' + return 'bg-yellow-100 text-yellow-700' + } + + const complexityDist = [ + { band: '≤5', cnt: compData.filter((d: any) => d.material_count <= 5).length }, + { band: '6-10', cnt: compData.filter((d: any) => d.material_count > 5 && d.material_count <= 10).length }, + { band: '11-15', cnt: compData.filter((d: any) => d.material_count > 10 && d.material_count <= 15).length }, + { band: '>15', cnt: compData.filter((d: any) => d.material_count > 15).length }, + ] + + return ( +
+
+ + + + + + +
+ + + + + + + + + + + + + + + +
+ ( + setSelectedSku(r.sku_code)}>{r.dish_name} + )}, + { key: 'category_l1', label: '品类' }, + { key: 'material_count', label: '原料数', align: 'right', render: (r) => formatNumber(r.material_count) }, + { key: 'semi_finished_count', label: '半成品数', align: 'right', render: (r) => formatNumber(r.semi_finished_count) }, + { key: 'unique_material_count', label: '独有原料', align: 'right', render: (r) => formatNumber(r.unique_material_count) }, + { key: 'high_loss_count', label: '高损耗原料', align: 'right', render: (r) => r.high_loss_count > 0 ? {r.high_loss_count} : '0' }, + { key: 'complexity_level', label: '复杂度', render: (r) => ( + {r.complexity_level} + )}, + ]} + data={compData} + /> +
+
+ + {selectedSku && ( + + {l5 ? : ( + formatNumber(r.gross_qty) }, + { key: 'net_qty', label: '标准净用量', align: 'right', render: (r) => formatNumber(r.net_qty) }, + { key: 'unit', label: '单位', align: 'center' }, + { key: 'waste_rate', label: '损耗率', align: 'right', render: (r) => formatPercent(r.waste_rate) }, + { key: 'cost_share', label: '成本占比', align: 'right', render: (r) => formatPercent(r.cost_share) }, + ]} + data={compDetail} + /> + )} + + )} + + + {formatPercent(r.waste_rate)} }, + { key: 'yield_rate', label: '出成率', align: 'right', render: (r) => formatPercent(r.yield_rate) }, + { key: 'unit', label: '单位', align: 'center' }, + { key: 'alert_level', label: '预警级别', render: (r) => ( + {r.alert_level} + )}, + ]} + data={lossData} + /> + + + + +
+ r.sales_amount > 0 ? `¥${formatNumber(r.sales_amount)}` : '-' }, + { key: 'sales_quantity', label: '销量', align: 'right', render: (r) => r.sales_quantity > 0 ? formatNumber(r.sales_quantity) : '-' }, + ]} + data={missData} + /> +
+
+
+ ) +} diff --git a/client/src/components/cost-analysis/ExploreTab.tsx b/client/src/components/cost-analysis/ExploreTab.tsx new file mode 100644 index 0000000..4b9b4e7 --- /dev/null +++ b/client/src/components/cost-analysis/ExploreTab.tsx @@ -0,0 +1,56 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { formatCurrency, formatNumber } from '@/lib/utils' +import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ZAxis, ResponsiveContainer, ReferenceLine } from 'recharts' + +const CATEGORY_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f97316', '#eab308', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#a3a3a3', '#f43f5e', '#6366f1', '#14b8a6', '#facc15', '#7c3aed', '#10b981', '#fb923c', '#64748b', '#d946ef'] + +export function ExploreTab() { + const { data: scatter, isLoading } = useQuery({ queryKey: ['ca/scatter'], queryFn: () => api.get('/cost-analysis/scatter') }) + + if (isLoading) return + + const scatterData = (scatter as any)?.data || [] + const categories: string[] = [...new Set(scatterData.map((r: any) => r.category_level1).filter(Boolean))] as string[] + + return ( +
+ + + + + + + + + { + if (active && payload?.length) { + const d = payload[0].payload + return
+

{d.dish_name}

+

品类: {d.category_level1}

+

销量: {formatNumber(d.x)}

+

成本差异: {formatCurrency(d.y)}

+

销售额: {formatCurrency(d.size)}

+
+ } + return null + }} /> + {categories.map((cat, i) => ( + d.category_level1 === cat)} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} fillOpacity={0.6} /> + ))} +
+
+
+ {categories.map((cat, i) => ( + + {cat as string} + + ))} +
+
+
+ ) +} diff --git a/client/src/components/cost-analysis/MaterialTab.tsx b/client/src/components/cost-analysis/MaterialTab.tsx new file mode 100644 index 0000000..291a903 --- /dev/null +++ b/client/src/components/cost-analysis/MaterialTab.tsx @@ -0,0 +1,164 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' +import { useState } from 'react' +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts' + +const PAGE_SIZE = 15 +const PIE_COLORS = ['#22c55e', '#3b82f6', '#eab308', '#f97316', '#ef4444', '#8b5cf6', '#a3a3a3'] + +export function MaterialTab() { + const [dishCode, setDishCode] = useState('') + const [searchInput, setSearchInput] = useState('') + const [page, setPage] = useState(1) + + const { data: variance, isLoading: lv } = useQuery({ + queryKey: ['ca/material-variance', dishCode], + queryFn: () => api.get(`/cost-analysis/material-variance?dish_code=${dishCode}`), + enabled: !!dishCode, + }) + const { data: lossDist, isLoading: ld } = useQuery({ queryKey: ['ca/loss-distribution'], queryFn: () => api.get('/cost-analysis/loss-distribution') }) + const { data: lossTop, isLoading: lt } = useQuery({ queryKey: ['ca/material-loss-top'], queryFn: () => api.get('/cost-analysis/material-loss-top?limit=50') }) + const { data: typeLoss, isLoading: ltl } = useQuery({ queryKey: ['ca/material-type-loss'], queryFn: () => api.get('/cost-analysis/material-type-loss') }) + + if (ld || lt || ltl) return + + const varianceData = (variance as any)?.data || [] + const distData = (lossDist as any)?.data || [] + const topData = (lossTop as any)?.data || [] + const typeData = (typeLoss as any)?.data || [] + + const reasonColor = (reason: string) => { + if (reason === '份量超标') return 'bg-red-100 text-red-700' + if (reason === '分摊遗漏') return 'bg-orange-100 text-orange-700' + if (reason === '未使用') return 'bg-gray-100 text-gray-700' + return 'bg-green-100 text-green-700' + } + + return ( +
+ +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && setDishCode(searchInput)} + placeholder="输入菜品编码(如 66920)" + className="rounded border px-3 py-1.5 text-sm w-64" + /> + +
+ {dishCode && ( + varianceData.length === 0 && !lv ? ( +

未找到菜品编码 {dishCode} 的原料数据

+ ) : lv ? ( + + ) : ( + <> + + + + + + { + if (active && payload?.length) { + const d = payload[0].payload + return
+

{d.material_name}

+

金额差异: {formatCurrency(d.loss_amount)}

+

损耗率: {formatPercent(d.loss_rate)}

+
+ } + return null + }} /> + + {varianceData.slice(0, 15).map((d: any, i: number) => ( + + ))} + +
+
+
+ formatNumber(r.theo_qty) }, + { key: 'actual_qty', label: '实际用量', align: 'right', render: (r) => formatNumber(r.actual_qty) }, + { key: 'loss_qty', label: '数量差异', align: 'right', render: (r) => formatNumber(r.loss_qty) }, + { key: 'loss_rate', label: '损耗率', align: 'right', render: (r) => { + const v = Number(r.loss_rate || 0) + return {formatPercent(v)} + }}, + { key: 'loss_amount', label: '金额差异', align: 'right', render: (r) => formatCurrency(r.loss_amount) }, + { key: 'reason', label: '原因', render: (r) => ( + {r.reason} + )}, + ]} + data={varianceData} + /> +
+ + ) + )} +
+ + +
+ + + `${e.cnt}`}> + {distData.map((_: any, i: number) => )} + + + + +
+ +
+
+
+ + + +
+ formatNumber(r.dish_count) }, + { key: 'total_loss_qty', label: '总损耗量', align: 'right', render: (r) => formatNumber(r.total_loss_qty) }, + { key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => {formatPercent(r.avg_loss_rate)} }, + { key: 'max_loss_rate', label: '最大损耗率', align: 'right', render: (r) => formatPercent(r.max_loss_rate) }, + { key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) }, + ]} + data={topData.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)} + /> +
+
+ + + formatPercent(r.avg_loss_rate) }, + { key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) }, + ]} + data={typeData} + /> + +
+ ) +} diff --git a/client/src/components/cost-analysis/OverviewTab.tsx b/client/src/components/cost-analysis/OverviewTab.tsx new file mode 100644 index 0000000..a8c92a2 --- /dev/null +++ b/client/src/components/cost-analysis/OverviewTab.tsx @@ -0,0 +1,129 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { formatCurrency, formatPercent } from '@/lib/utils' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' + +const PIE_COLORS = ['#ef4444', '#f97316', '#eab308', '#3b82f6', '#22c55e', '#a3a3a3', '#8b5cf6'] + +export function OverviewTab() { + const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['ca/overview'], queryFn: () => api.get('/cost-analysis/overview') }) + const { data: categories, isLoading: l2 } = useQuery({ queryKey: ['ca/category-comparison'], queryFn: () => api.get('/cost-analysis/category-comparison') }) + const { data: deviation, isLoading: l3 } = useQuery({ queryKey: ['ca/margin-deviation'], queryFn: () => api.get('/cost-analysis/margin-deviation') }) + const { data: varianceTop, isLoading: l4 } = useQuery({ queryKey: ['ca/variance-top'], queryFn: () => api.get('/cost-analysis/variance-top?limit=50') }) + + if (l1 || l2 || l3 || l4) return + + const ov = (overview as any)?.data || {} + const cats = (categories as any)?.data || [] + const devs = (deviation as any)?.data || [] + const tops = (varianceTop as any)?.data || [] + + const tierColor = (tier: string) => { + if (tier === '紧急' || tier === '数据异常') return 'bg-red-100 text-red-700' + if (tier === '整改') return 'bg-orange-100 text-orange-700' + if (tier === '关注') return 'bg-yellow-100 text-yellow-700' + return 'bg-green-100 text-green-700' + } + + return ( +
+
+ + + + + + + + +
+ + + + + + + + { + if (active && payload?.length) { + const d = payload[0].payload + return
+

{d.category_level1}

+

理论毛利率: {Number(d.avg_theo_margin).toFixed(2)}%

+

实际毛利率: {Number(d.avg_actual_margin).toFixed(2)}%

+

销售额: {formatCurrency(d.total_sales)}

+

成本差异: {formatCurrency(d.total_variance)}

+
+ } + return null + }} /> + + +
+
+
+ formatPercent(r.avg_theo_margin) }, + { key: 'avg_actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.avg_actual_margin) }, + { key: 'total_sales', label: '销售额', align: 'right', render: (r) => formatCurrency(r.total_sales) }, + { key: 'total_variance', label: '成本差异', align: 'right', render: (r) => { + const v = Number(r.total_variance || 0) + return 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)} + }}, + ]} + data={cats} + /> +
+
+ + +
+ + + `${e.deviation_band}: ${e.cnt}`}> + {devs.map((_: any, i: number) => )} + + + + +
+ +
+
+
+ + + formatPercent(r.theo_margin) }, + { key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) }, + { key: 'cost_variance', label: '成本差异', align: 'right', render: (r) => { + const v = Number(r.cost_variance || 0) + return 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)} + }}, + { key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) }, + { key: 'cost_tier', label: '分层', render: (r) => ( + {r.cost_tier} + )}, + ]} + data={tops} + /> + +
+ ) +} diff --git a/client/src/components/cost-analysis/PackagingTab.tsx b/client/src/components/cost-analysis/PackagingTab.tsx new file mode 100644 index 0000000..6a6058b --- /dev/null +++ b/client/src/components/cost-analysis/PackagingTab.tsx @@ -0,0 +1,64 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' + +const PAGE_SIZE = 15 + +export function PackagingTab() { + const [page, setPage] = useState(1) + + const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/packaging-overview'], queryFn: () => api.get('/cost-analysis/packaging-overview') }) + const { data: detail, isLoading: l2 } = useQuery({ queryKey: ['ca/packaging-detail', page], queryFn: () => api.get(`/cost-analysis/packaging-detail?page=${page}&page_size=${PAGE_SIZE}`) }) + + if (l1 || l2) return + + const ovData = (ov as any)?.data || {} + const detailData = (detail as any)?.data || [] + const meta = (detail as any)?.meta || { total: 0 } + + const statusColor = (status: string) => { + if (status === '核查') return 'bg-red-100 text-red-700' + if (status === '关注') return 'bg-yellow-100 text-yellow-700' + return 'bg-green-100 text-green-700' + } + + return ( +
+
+ + + + +
+ + + +
+ formatNumber(r.dish_count) }, + { key: 'total_qty', label: '总用量', align: 'right', render: (r) => formatNumber(r.total_qty) }, + { key: 'total_cost', label: '总成本', align: 'right', render: (r) => formatCurrency(r.total_cost) }, + { key: 'loss_qty', label: '损耗量', align: 'right', render: (r) => formatNumber(r.loss_qty) }, + { key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => { + const v = Number(r.avg_loss_rate || 0) + return {formatPercent(v)} + }}, + { key: 'status', label: '状态', render: (r) => ( + {r.status} + )}, + ]} + data={detailData} + /> +
+
+
+ ) +} diff --git a/client/src/components/cost-analysis/ProfitabilityTab.tsx b/client/src/components/cost-analysis/ProfitabilityTab.tsx new file mode 100644 index 0000000..8877293 --- /dev/null +++ b/client/src/components/cost-analysis/ProfitabilityTab.tsx @@ -0,0 +1,128 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' +import { useState } from 'react' +import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis } from 'recharts' + +const PAGE_SIZE = 15 +const MENU_COLORS: Record = { + '明星盈利品': '#22c55e', + '高销低利品': '#eab308', + '低销高利品': '#3b82f6', + '低销低利品': '#ef4444', + '数据异常品': '#a3a3a3', +} + +export function ProfitabilityTab() { + const [page, setPage] = useState(1) + const [category, setCategory] = useState('') + + const { data: matrix, isLoading: l1 } = useQuery({ queryKey: ['ca/menu-engineering'], queryFn: () => api.get('/cost-analysis/menu-engineering') }) + const { data: profit, isLoading: l2 } = useQuery({ queryKey: ['ca/profitability', page, category], queryFn: () => api.get(`/cost-analysis/profitability?page=${page}&page_size=${PAGE_SIZE}${category ? `&category=${category}` : ''}`) }) + const { data: pricing, isLoading: l3 } = useQuery({ queryKey: ['ca/pricing'], queryFn: () => api.get('/cost-analysis/pricing?threshold=50') }) + + if (l1 || l2 || l3) return + + const matrixData = ((matrix as any)?.data || []).filter((r: any) => r.actual_margin > -500) + const profitData = (profit as any)?.data || [] + const profitMeta = (profit as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 } + const pricingData = (pricing as any)?.data || [] + + const categories = [...new Set(matrixData.map((r: any) => r.category_level1).filter(Boolean))] + + const menuTypeColor = (type: string) => { + const c: Record = { + '明星盈利品': 'bg-green-100 text-green-700', + '高销低利品': 'bg-yellow-100 text-yellow-700', + '低销高利品': 'bg-blue-100 text-blue-700', + '低销低利品': 'bg-red-100 text-red-700', + '数据异常品': 'bg-gray-100 text-gray-700', + } + return c[type] || 'bg-gray-100 text-gray-700' + } + + return ( +
+ + + + + + + + { + if (active && payload?.length) { + const d = payload[0].payload + return
+

{d.dish_name}

+

销量: {formatNumber(d.sales_quantity)}

+

实际毛利率: {formatPercent(d.actual_margin)}

+

销售额: {formatCurrency(d.sales_amount)}

+

类型: {d.menu_type}

+
+ } + return null + }} /> + {Object.entries(MENU_COLORS).map(([type, color]) => ( + d.menu_type === type)} fill={color} /> + ))} +
+
+
+ {Object.entries(MENU_COLORS).map(([type, color]) => ( + + {type} + + ))} +
+
+ + +
+ + +
+ +
+ formatNumber(r.sales_quantity) }, + { key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) }, + { key: 'theo_profit', label: '理论毛利', align: 'right', render: (r) => formatCurrency(r.theo_profit) }, + { key: 'actual_profit', label: '实际毛利', align: 'right', render: (r) => formatCurrency(r.actual_profit) }, + { key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theo_margin) }, + { key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) }, + { key: 'revenue_contribution', label: '收入贡献度', align: 'right', render: (r) => formatPercent(r.revenue_contribution) }, + ]} + data={profitData} + /> +
+
+ + + formatCurrency(r.price) }, + { key: 'theo_cost', label: '理论成本', align: 'right', render: (r) => formatCurrency(r.theo_cost) }, + { key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theo_cost_rate) }, + { key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => {formatPercent(r.theo_margin)} }, + { key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) }, + { key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) }, + ]} + data={pricingData} + /> + +
+ ) +} diff --git a/client/src/components/cost-analysis/QualityTab.tsx b/client/src/components/cost-analysis/QualityTab.tsx new file mode 100644 index 0000000..0bae7dd --- /dev/null +++ b/client/src/components/cost-analysis/QualityTab.tsx @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatPercent, formatNumber, formatCurrency } from '@/lib/utils' +import { useState } from 'react' + +const PAGE_SIZE = 15 + +export function QualityTab() { + const [dishPage, setDishPage] = useState(1) + const [matPage, setMatPage] = useState(1) + + const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/data-quality'], queryFn: () => api.get('/cost-analysis/data-quality') }) + const { data: dishes, isLoading: l2 } = useQuery({ queryKey: ['ca/unmatched-dishes', dishPage], queryFn: () => api.get(`/cost-analysis/unmatched-dishes?page=${dishPage}&page_size=${PAGE_SIZE}`) }) + const { data: materials, isLoading: l3 } = useQuery({ queryKey: ['ca/unmatched-materials', matPage], queryFn: () => api.get(`/cost-analysis/unmatched-materials?page=${matPage}&page_size=${PAGE_SIZE}`) }) + + if (l1 || l2 || l3) return + + const ovData = (ov as any)?.data || {} + const dishData = (dishes as any)?.data || [] + const dishMeta = (dishes as any)?.meta || { total: 0 } + const matData = (materials as any)?.data || [] + const matMeta = (materials as any)?.meta || { total: 0 } + + const dishMatchRate = ovData.total_dishes > 0 ? (Number(ovData.matched_dishes) / Number(ovData.total_dishes) * 100) : 0 + const matMatchRate = ovData.total_materials > 0 ? (Number(ovData.matched_materials) / Number(ovData.total_materials) * 100) : 0 + + const qualityChecks = [ + { check: 'BOM覆盖率', value: `${ovData.bom_coverage || 0}%`, abnormal: `${ovData.total_sku - ovData.sku_with_bom}个SKU缺BOM`, status: Number(ovData.bom_coverage) < 80 ? '异常' : '正常' }, + { check: '菜品匹配率', value: `${dishMatchRate.toFixed(2)}%`, abnormal: `${Number(ovData.total_dishes) - Number(ovData.matched_dishes)}个未匹配`, status: dishMatchRate < 90 ? '异常' : '正常' }, + { check: '物料匹配率', value: `${matMatchRate.toFixed(2)}%`, abnormal: `${Number(ovData.total_materials) - Number(ovData.matched_materials)}个未匹配`, status: matMatchRate < 90 ? '异常' : '正常' }, + { check: '零实际用量BOM', value: `${ovData.zero_actual_bom}条`, abnormal: 'standard_net_quantity = 0', status: Number(ovData.zero_actual_bom) > 0 ? '异常' : '正常' }, + { check: '负毛利率菜品', value: `${ovData.negative_margin_count}道`, abnormal: '实际毛利率 < 0', status: Number(ovData.negative_margin_count) > 0 ? '异常' : '正常' }, + { check: '理论负毛利菜品', value: `${ovData.negative_theo_margin_count}道`, abnormal: '理论毛利率 < 0', status: Number(ovData.negative_theo_margin_count) > 0 ? '异常' : '正常' }, + ] + + const checkColor = (status: string) => status === '异常' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700' + + return ( +
+
+ + + +
+ + + ( + {r.status} + )}, + ]} + data={qualityChecks} + /> + + + + +
+ formatCurrency(r.sales_amount) }, + ]} + data={dishData} + /> +
+
+ + + +
+ formatNumber(r.dish_count) }, + { key: 'loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.loss_amount) }, + ]} + data={matData} + /> +
+
+
+ ) +} diff --git a/client/src/components/cost-analysis/StoreTab.tsx b/client/src/components/cost-analysis/StoreTab.tsx new file mode 100644 index 0000000..3be09d2 --- /dev/null +++ b/client/src/components/cost-analysis/StoreTab.tsx @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { DataTable } from '@/components/DataTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' +import { useState } from 'react' +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts' + +const PAGE_SIZE = 15 +const LEVEL_COLORS: Record = { + '红色-严重超耗': '#ef4444', + '橙色-明显超耗': '#f97316', + '绿色-基本正常': '#22c55e', + '灰色-口径异常': '#a3a3a3', +} + +export function StoreTab() { + const [page, setPage] = useState(1) + + const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/store-overview'], queryFn: () => api.get('/cost-analysis/store-overview') }) + const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', page], queryFn: () => api.get(`/cost-analysis/store-ranking?page=${page}&page_size=${PAGE_SIZE}`) }) + + if (l1 || l2) return + + const ovData = (ov as any)?.data || {} + const rankData = (ranking as any)?.data || [] + const meta = (ranking as any)?.meta || { total: 0 } + + const pieData = [ + { name: '红色-严重超耗', value: Number(ovData.red_count || 0) }, + { name: '橙色-明显超耗', value: Number(ovData.orange_count || 0) }, + { name: '绿色-基本正常', value: Number(ovData.green_count || 0) }, + { name: '灰色-口径异常', value: Number(ovData.gray_count || 0) }, + ].filter(d => d.value > 0) + + const levelColor = (level: string) => { + if (level === '红色-严重超耗') return 'bg-red-100 text-red-700' + if (level === '橙色-明显超耗') return 'bg-orange-100 text-orange-700' + if (level === '绿色-基本正常') return 'bg-green-100 text-green-700' + return 'bg-gray-100 text-gray-700' + } + + return ( +
+
+ + + + + + +
+ + + + + `${e.name}: ${e.value}`}> + {pieData.map((d, i) => )} + + + + + + + + +
+ formatPercent(r.theo_cost_rate) }, + { key: 'actual_cost_rate', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_cost_rate) }, + { key: 'variance_pct', label: '偏差', align: 'right', render: (r) => { + const v = Number(r.variance_pct || 0) + return 20 ? 'text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{formatPercent(v)} + }}, + { key: 'variance_amount', label: '超耗金额', align: 'right', render: (r) => { + const v = Number(r.variance_amount || 0) + return 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)} + }}, + { key: 'negative_item_lines', label: '负库存项', align: 'center', render: (r) => { + const n = Number(r.negative_item_lines || 0) + return n > 0 ? {n} : 0 + }}, + { key: 'variance_level', label: '分级', render: (r) => ( + {r.variance_level} + )}, + ]} + data={rankData} + /> +
+
+
+ ) +} diff --git a/client/src/components/cost-analysis/SupplyTab.tsx b/client/src/components/cost-analysis/SupplyTab.tsx new file mode 100644 index 0000000..605942a --- /dev/null +++ b/client/src/components/cost-analysis/SupplyTab.tsx @@ -0,0 +1,170 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { DataTable } from '@/components/DataTable' +import { MetricCard } from '@/components/MetricCard' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Pagination } from '@/components/Pagination' +import { formatCurrency, formatNumber } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts' + +const PAGE_SIZE = 15 + +export function SupplyTab() { + const [riskPage, setRiskPage] = useState(1) + const [demandPage, setDemandPage] = useState(1) + const [skuInput, setSkuInput] = useState('') + const [simResult, setSimResult] = useState(null) + const [simLoading, setSimLoading] = useState(false) + + const { data: sharing, isLoading: l1 } = useQuery({ queryKey: ['ca/material-sharing'], queryFn: () => api.get('/cost-analysis/material-sharing?limit=30') }) + const { data: risk, isLoading: l2 } = useQuery({ queryKey: ['ca/unique-material-risk', riskPage], queryFn: () => api.get(`/cost-analysis/unique-material-risk?page=${riskPage}&page_size=${PAGE_SIZE}`) }) + const { data: demand, isLoading: l3 } = useQuery({ queryKey: ['ca/material-demand'], queryFn: () => api.get('/cost-analysis/material-demand?limit=100') }) + + if (l1 || l2 || l3) return + + const sharingData = (sharing as any)?.data || [] + const riskData = (risk as any)?.data || [] + const riskMeta = (risk as any)?.meta || { total: 0 } + const demandData = (demand as any)?.data || [] + + const sharingColor = (type: string) => { + if (type === '核心原料') return 'bg-red-100 text-red-700' + if (type === '独有原料') return 'bg-gray-100 text-gray-700' + return 'bg-blue-100 text-blue-700' + } + + const riskLevelColor = (level: string) => { + if (level === '高') return 'bg-red-100 text-red-700' + if (level === '中') return 'bg-orange-100 text-orange-700' + return 'bg-green-100 text-green-700' + } + + const handleSimulate = async () => { + const codes = skuInput.split(/[,\n\s]+/).filter(Boolean) + if (codes.length === 0) return + setSimLoading(true) + try { + const res: any = await api.post('/cost-analysis/sku-simplify-simulate', { sku_codes: codes }) + setSimResult(res.data) + } catch (e) { + setSimResult({ error: '模拟失败' }) + } + setSimLoading(false) + } + + return ( +
+ + + + + + + { + if (active && payload?.length) { + const d = payload[0].payload + return
+

{d.material_name}

+

被 {d.sku_count} 个SKU使用

+

类型: {d.sharing_type}

+
+ } + return null + }} /> + + {sharingData.map((d: any, i: number) => ( + + ))} + +
+
+
+ formatNumber(r.sku_count) }, + { key: 'sharing_type', label: '分类', render: (r) => ( + {r.sharing_type} + )}, + ]} + data={sharingData} + /> +
+
+ + + +
+ formatCurrency(r.sales_amount) }, + { key: 'unique_material_count', label: '独有原料数', align: 'right', render: (r) => 3 ? 'text-red-600' : ''}>{formatNumber(r.unique_material_count)} }, + { key: 'risk_level', label: '风险等级', render: (r) => ( + {r.risk_level} + )}, + ]} + data={riskData} + /> +
+
+ + +
+