diff --git a/.gitignore b/.gitignore index 30606c4..66a0d42 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist/ *.xlsx *.zip .playwright-mcp/ +backups/ +.playwright-cli/ +.tmp/ +数据/ diff --git a/client/src/App.tsx b/client/src/App.tsx index 144ed7a..2d74c86 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -26,6 +26,10 @@ import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage' import { BossPage } from '@/pages/BossPage' import { RevenuePage } from '@/pages/RevenuePage' import { BankPage } from '@/pages/BankPage' +import { CentralKitchenPage } from '@/pages/CentralKitchenPage' +import { DistributionReconciliationPage } from '@/pages/DistributionReconciliationPage' +import { BomPenetrationPage } from '@/pages/BomPenetrationPage' +import { ProductionPlanPage } from '@/pages/ProductionPlanPage' import { LoginPage } from '@/pages/LoginPage' const queryClient = new QueryClient({ @@ -73,6 +77,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/components/FilterableTable.tsx b/client/src/components/FilterableTable.tsx index 6030d85..bd6d442 100644 --- a/client/src/components/FilterableTable.tsx +++ b/client/src/components/FilterableTable.tsx @@ -39,7 +39,7 @@ export function FilterableTable({ const filtered = useMemo(() => { let r = data if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter) - if (statusFilter && statusFilterKey) r = r.filter((row: any) => row[statusFilterKey] === statusFilter) + if (statusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === statusFilter) return [...r].sort((a: any, b: any) => { const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0) const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0) diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index dd6b3c8..7795ac8 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, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark } from 'lucide-react' +import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon } from 'lucide-react' import { cn } from '@/lib/utils' interface LayoutProps { @@ -53,6 +53,10 @@ const menuGroups: MenuGroup[] = [ items: [ { path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] }, { path: '/cost', label: '成本库存', icon: Utensils, roles: ['hq', 'dept'] }, + { path: '/central-kitchen', label: '中央厨房', icon: ChefHat, roles: ['hq', 'dept'] }, + { path: '/distribution-reconciliation', label: '配送对账', icon: Truck, roles: ['hq', 'dept'] }, + { path: '/bom-penetration', label: 'BOM穿透', icon: Network, roles: ['hq', 'dept'] }, + { path: '/production-plan', label: '生产要货', icon: TrendingUpIcon, roles: ['hq', 'dept'] }, { path: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] }, ], }, diff --git a/client/src/components/MonthPicker.tsx b/client/src/components/MonthPicker.tsx new file mode 100644 index 0000000..57dfa30 --- /dev/null +++ b/client/src/components/MonthPicker.tsx @@ -0,0 +1,106 @@ +import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react' +import { useState, useCallback, useEffect } from 'react' + +interface MonthPickerProps { + month: string + onChange: (month: string) => void + rangeMode?: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom' + onRangeModeChange?: (mode: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom') => void +} + +const RANGE_MODES: { value: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'; label: string }[] = [ + { value: 'month', label: '月度' }, + { value: 'quarter', label: '季度' }, + { value: 'halfyear', label: '半年' }, + { value: 'year', label: '全年' }, +] + +export function MonthPicker({ month, onChange, rangeMode = 'month', onRangeModeChange }: MonthPickerProps) { + const [showPicker, setShowPicker] = useState(false) + + const displayMonth = month.length === 10 ? month.substring(0, 7) : month + + const prevMonth = useCallback(() => { + const [y, m] = displayMonth.split('-').map(Number) + const d = new Date(y, m - 2, 1) + onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + }, [displayMonth, onChange]) + + const nextMonth = useCallback(() => { + const [y, m] = displayMonth.split('-').map(Number) + const d = new Date(y, m, 1) + onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + }, [displayMonth, onChange]) + + useEffect(() => { + const handler = () => setShowPicker(false) + if (showPicker) { + window.addEventListener('click', handler) + return () => window.removeEventListener('click', handler) + } + }, [showPicker]) + + return ( +
+
+ +
+ + {showPicker && ( +
e.stopPropagation()} + > + { + onChange(e.target.value) + setShowPicker(false) + }} + className="rounded border px-2 py-1 text-sm" + /> +
+ )} +
+ +
+ + {onRangeModeChange && ( +
+ {RANGE_MODES.map((m) => ( + + ))} +
+ )} +
+ ) +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 5a1fc3e..1c1bae3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2,7 +2,7 @@ import axios from 'axios' const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', - timeout: 30000, + timeout: 120000, }) api.interceptors.request.use((config) => { diff --git a/client/src/lib/useMonthParam.ts b/client/src/lib/useMonthParam.ts new file mode 100644 index 0000000..9b387a2 --- /dev/null +++ b/client/src/lib/useMonthParam.ts @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react' + +const DEFAULT_MONTH = '2026-04' + +export function useMonthParam() { + const [month, setMonth] = useState(DEFAULT_MONTH) + + const monthParam = useCallback(() => { + return { month } + }, [month]) + + const monthForApi = month.length === 7 ? month : month.substring(0, 7) + + return { month: monthForApi, setMonth, monthParam } +} + +export type RangeMode = 'month' | 'quarter' | 'halfyear' | 'year' | 'custom' + +export function useRangeMode() { + const [rangeMode, setRangeMode] = useState('month') + return { rangeMode, setRangeMode } +} diff --git a/client/src/pages/BankPage.tsx b/client/src/pages/BankPage.tsx index 8c4e15a..0e97cf4 100644 --- a/client/src/pages/BankPage.tsx +++ b/client/src/pages/BankPage.tsx @@ -103,7 +103,7 @@ export function BankPage() { // 计算各项指标值 const metrics = useMemo(() => { if (!ov || !wf || !stability) return null - const netMargin = wf.received > 0 ? (wf.net_profit / wf.received * 100) : 0 + const netMargin = wf.received > 0 ? (wf.store_contribution / wf.received * 100) : 0 const cv = stability.cv const redStores = risk.filter((r: any) => r.risk_level === '红色').reduce((s: number, r: any) => s + r.store_count, 0) const totalStores = risk.reduce((s: number, r: any) => s + r.store_count, 0) @@ -140,7 +140,7 @@ export function BankPage() { { name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage, fill: '#8b5cf6' }, { name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#06b6d4' }, { name: '其他', value: -(wf.dorm + wf.commission + wf.other_expense), base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#64748b' }, - { name: '净利润', value: wf.net_profit, base: 0, fill: wf.net_profit >= 0 ? '#22c55e' : '#ef4444' }, + { name: '门店贡献利润', value: wf.store_contribution, base: 0, fill: wf.store_contribution >= 0 ? '#22c55e' : '#ef4444' }, ] }, [wf]) @@ -254,10 +254,10 @@ export function BankPage() { {wf && ( <>
- 0 ? 'good' : 'bad'} description={`净利率 ${wf.received > 0 ? formatPercent(wf.net_profit / wf.received * 100) : '-'}`} /> + 0 ? 'good' : 'bad'} description={`贡献率 ${wf.received > 0 ? formatPercent(wf.store_contribution / wf.received * 100) : '-'} · 仅费用已匹配门店`} /> 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" /> 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" /> - 0 ? wf.net_profit / wf.received * 100 : 0} format="percent" description="净利润占实收比例" /> + 0 ? wf.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
diff --git a/client/src/pages/BomPenetrationPage.tsx b/client/src/pages/BomPenetrationPage.tsx new file mode 100644 index 0000000..5afc704 --- /dev/null +++ b/client/src/pages/BomPenetrationPage.tsx @@ -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 + if (!overviewData) return
暂无数据
+ + 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 = {} + 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 ( +
+ {/* 页面标题 */} +
+ +

多级BOM成本穿透

+ +
+ + {/* 核心指标 */} +
+ + + + + = 0 ? 'warn' : 'bad'} /> + +
+ + {/* BOM成本结构Top15 */} + + + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> + formatCurrency(v)} /> + + + + +
+ + + + + + + + + + + + + + + + + {bomSummaryTop10.map((b: any, i: number) => ( + setSelectedProduct(b.product_code)}> + + + + + + + + + + + + ))} + +
产品编码产品名称材料数半成品数理论成本领用成本单位理论成本单位领用成本差异金额差异率
{b.product_code}{b.product_name}{b.material_count} + {b.sub_product_count > 0 ? {b.sub_product_count} : '-'} + {formatCurrency(b.bom_theoretical_amt)}{formatCurrency(b.bom_issue_amt)}{formatNumber(b.bom_unit_theoretical_cost)}{formatNumber(b.bom_unit_issue_cost)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> + {formatCurrency(b.variance_amt)} + + {varianceText(b.variance_pct)} +
+
+
+ + {/* BOM成本Treemap */} + + + } + /> + + + + {/* BOM树穿透 */} + p.product_code === selectedProduct)?.product_name || selectedProduct}`} + subtitle={`共${bomTree.length}个节点,最大${maxLevel}层`} + > +
+ + 点击下方产品可切换BOM树 +
+ {productLoading ? ( + + ) : ( +
+ {Object.entries(treeByLevel).map(([level, nodes]) => ( +
+
+ + Level {level} + ({nodes.length}个物料) +
+
+ {nodes.map((node: any, i: number) => ( +
+
+ {node.material_name} + {node.is_finished_product && ( + 半成品 + )} +
+
+ 编码: {node.material_code} + 单位: {node.unit} +
+
+ 理论: {formatCurrency(node.theoretical_amt)} + 领用: {formatCurrency(node.issue_amt)} +
+
+ 理论量: {formatNumber(node.theoretical_qty)} + 领用量: {formatNumber(node.issue_qty)} +
+ {node.is_finished_product && ( + + )} +
+ ))} +
+
+ ))} + {bomTree.length === 0 && ( +
该产品无配方数据
+ )} +
+ )} +
+ + {/* 产品选择器 */} + setProductFilter(e.target.value)} + className="rounded border px-3 py-1 text-sm" + /> + } + > +
+ + + + + + + + + + + + + + + + {filteredProducts.map((p: any, i: number) => ( + + + + + + + + + + + + ))} + +
产品编码产品名称品类入库量理论成本实际成本材料数多级BOM
{p.product_code}{p.product_name}{p.category_minor}{formatNumber(p.inbound_quantity)}{formatCurrency(p.theoretical_cost)}{formatCurrency(p.actual_cost)}{p.material_count} + {p.has_multi_level_bom ? : '-'} + + +
+
+
+ + {/* 半成品依赖链 */} + +
+ + + + + + + + + + + + + + {multiLevelChains.map((c: any, i: number) => ( + + + + + + + + + + ))} + +
成品编码成品名称成品配方半成品编码半成品名称半成品配方
{c.finished_code}{c.finished_name}{c.finished_recipe}{c.sub_product_code}{c.sub_product_finished_name || c.sub_product_name}{c.sub_product_recipe || '-'}
+
+
+
+ ) +} + +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)}万 + + + ) +} diff --git a/client/src/pages/BossPage.tsx b/client/src/pages/BossPage.tsx index 8008deb..e42f2b3 100644 --- a/client/src/pages/BossPage.tsx +++ b/client/src/pages/BossPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, LineChart, Line, PieChart, Pie, Legend, ComposedChart } from 'recharts' @@ -6,10 +7,61 @@ import { MetricCard } from '@/components/MetricCard' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' -import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt } from 'lucide-react' +import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react' const RISK_COLORS: Record = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } +function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: any; opp: number; pct: number; confColor: string }) { + const [expanded, setExpanded] = useState(false) + return ( +
+
setExpanded(!expanded)} + > +
{index + 1}
+
+
+ {o.category} + {o.confidence} + +
+

+ 基线: {typeof o.baseline === 'number' ? (o.baseline < 100 ? formatPercent(o.baseline) : formatNumber(o.baseline)) : o.baseline} + {typeof o.baseline === 'number' && o.baseline < 100 ? '%' : ''} · 责任: {o.owner} · 验收: {o.evidence} +

+
+
+

{formatCurrency(opp)}

+

占比 {formatPercent(pct)}

+
+
+ {expanded && o.detail && ( +
+ {o.detail.split('\n').map((line: string, idx: number) => { + const isHeader = line.endsWith(':') || line.endsWith(':') + const isAction = line.startsWith('行动指向') || /^\d)/.test(line) + const isStoreLine = line.includes('费率') || line.includes('差异') || line.includes('优惠率') || line.includes('佣金') || line.includes('收入') + return ( +

+ {line || '\u00A0'} +

+ ) + })} +
+ )} +
+ ) +} + export function BossPage() { const navigate = useNavigate() @@ -49,6 +101,10 @@ export function BossPage() { queryKey: ['overview/store-profit-ranking'], queryFn: () => api.get('/overview/store-profit-ranking'), }) + const { data: profitOppData } = useQuery({ + queryKey: ['overview/profit-opportunity'], + queryFn: () => api.get('/overview/profit-opportunity'), + }) const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading @@ -61,6 +117,8 @@ export function BossPage() { const costOv = (costOverview as any)?.data || {} const structRows = (expenseStructure as any)?.data || [] const profitRanking = (profitRankingData as any)?.data || [] + const profitOpp = (profitOppData as any)?.data?.items || [] + const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Number(o.opportunity || 0), 0) // 风险分布 const riskSummary = riskRows.reduce((acc: any, r: any) => { @@ -101,7 +159,7 @@ export function BossPage() { { name: '宿舍', value: -Number(wf.dorm), type: 'cost' }, { name: '外卖佣金', value: -Number(wf.commission), type: 'cost' }, { name: '其他费用', value: -Number(wf.other_expense), type: 'cost' }, - { name: '净利润', value: Number(wf.net_profit), type: 'profit' }, + { name: '门店贡献利润', value: Number(wf.store_contribution), type: 'profit' }, ] : [] // 瀑布图累计计算 @@ -156,14 +214,14 @@ export function BossPage() { {/* ① 核心经营指标 */}
0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> - + 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} />
{/* ② 利润瀑布 */} {wf && ( - + @@ -189,8 +247,8 @@ export function BossPage() {
))}
-

净利润

-

{formatCurrency(wf.net_profit)}

+

门店贡献利润

+

{formatCurrency(wf.store_contribution)}

{/* 成本费用占比汇总条 */} @@ -213,20 +271,46 @@ export function BossPage() {
其他{formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}
-
- 利润{formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)} +
+ 利润{formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}
食材成本率 {formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)}(建议 ≤ 32%) 费用率 {formatPercent(Number(wf.total_expense) / Number(wf.received) * 100)}(建议 ≤ 40%) - 净利率 {formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}(建议 ≥ 10%) + 贡献率 {formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}(建议 ≥ 10%)
)} )} + {/* ②b 利润机会池 */} + {profitOpp.length > 0 && ( + +
+ {[...profitOpp].sort((a: any, b: any) => Number(b.opportunity || 0) - Number(a.opportunity || 0)).map((o: any, i: number) => { + const opp = Number(o.opportunity || 0) + const pct = totalOpportunity > 0 ? (opp / totalOpportunity * 100) : 0 + const confColor = o.confidence === '中高' ? 'text-green-600 bg-green-50' : o.confidence === '中' ? 'text-blue-600 bg-blue-50' : 'text-yellow-600 bg-yellow-50' + return ( + + ) + })} +
+
+
+ + 30天整改目标 +
+

+ 理论月度机会合计 {formatCurrency(totalOpportunity)},考虑项目重叠和促销弹性,经营承诺值取 ≥ 150万元。 + 标准门店贡献率目标从 7.29% 提升至约 10%。 +

+
+
+ )} + {/* ③ 风险态势 */}
15 ? 'border-red-200 bg-red-50/40' : 'border-yellow-200 bg-yellow-50/40'}`}> @@ -358,7 +442,7 @@ export function BossPage() { {/* ⑤b 门店利润排名 */} {profitRanking.length > 0 && ( - +
{/* TOP5 */}
@@ -373,9 +457,9 @@ export function BossPage() { {i + 1}

{s.store_name}

-

实收 {formatCurrency(s.received)} · 净利率 {formatPercent(s.net_margin_pct)}

+

实收 {formatCurrency(s.received)} · 贡献率 {formatPercent(s.contribution_margin_pct)}

- {formatCurrency(s.net_profit)} + {formatCurrency(s.store_contribution)}
))}
@@ -384,7 +468,7 @@ export function BossPage() {

亏损 BOTTOM5

- {profitRanking.filter((s: any) => Number(s.net_profit) < 0).slice(-5).reverse().map((s: any, i: number) => ( + {profitRanking.filter((s: any) => Number(s.store_contribution) < 0).slice(-5).reverse().map((s: any, i: number) => (
{i + 1}

{s.store_name}

-

实收 {formatCurrency(s.received)} · 净利率 {formatPercent(s.net_margin_pct)}

+

实收 {formatCurrency(s.received)} · 贡献率 {formatPercent(s.contribution_margin_pct)}

- {formatCurrency(s.net_profit)} + {formatCurrency(s.store_contribution)}
))}
diff --git a/client/src/pages/CentralKitchenPage.tsx b/client/src/pages/CentralKitchenPage.tsx new file mode 100644 index 0000000..7b2b22e --- /dev/null +++ b/client/src/pages/CentralKitchenPage.tsx @@ -0,0 +1,394 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ScatterChart, Scatter } 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, formatPercent } from '@/lib/utils' +import { ChefHat, TrendingDown, TrendingUp, AlertTriangle, Package, Factory } from 'lucide-react' +import { MonthPicker } from '@/components/MonthPicker' + +const CATEGORY_COLORS = ['#1677ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16', '#a0d911', '#2f54eb'] + +function varianceColor(pct: number): 'good' | 'warn' | 'bad' { + if (Math.abs(pct) <= 3) return 'good' + if (Math.abs(pct) <= 5) return 'warn' + return 'bad' +} + +function varianceText(pct: number): string { + if (pct > 0) return `+${pct.toFixed(2)}%` + return `${pct.toFixed(2)}%` +} + +export function CentralKitchenPage() { + const [month, setMonth] = useState('2026-04') + const [productFilter, setProductFilter] = useState('') + + const { data, isLoading } = useQuery({ + queryKey: ['central-kitchen-dashboard', month], + queryFn: async () => { + const res = await api.get(`/central-kitchen/dashboard?month=${month}`) + return res.data + }, + }) + + if (isLoading) return + if (!data) return
暂无数据
+ + const { summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis } = data + + const filteredProducts = products.filter((p: any) => + !productFilter || + p.product_name?.includes(productFilter) || + p.product_code?.includes(productFilter) || + p.category_minor?.includes(productFilter) + ) + + // 成本对账瀑布数据 + const waterfallData = [ + { name: '理论成本', value: reconciliation.theoretical_cost, type: 'base' }, + { name: '标准成本', value: reconciliation.standard_cost, type: 'base' }, + { name: '实际材料', value: reconciliation.material_actual_cost, type: 'base' }, + { name: '制造费用', value: reconciliation.allocated_manufacturing_cost, type: 'add' }, + { name: '全制造成本', value: reconciliation.full_manufacturing_cost, type: 'total' }, + { name: '入库价值', value: reconciliation.calculated_inbound_value, type: 'base' }, + { name: '制造毛利', value: reconciliation.manufacturing_margin, type: 'margin' }, + ] + + // 品类成本堆叠数据 + const categoryStackData = categoryCost.map((c: any) => ({ + name: c.category_minor, + 材料成本: c.material_actual_cost, + 制造费用: c.allocated_mfg_cost, + })) + + // 产品成本偏差散点图 + const scatterData = products.map((p: any) => ({ + name: p.product_name, + x: p.theoretical_cost, + y: p.material_actual_cost, + z: p.efficiency_variance_pct, + category: p.category_minor, + })).filter((d: any) => d.x > 0 && d.y > 0) + + return ( +
+ {/* 页面标题 */} +
+ +

中央厨房成本驾驶舱

+ +
+ + {/* 核心指标卡片 */} +
+ + + + + + + +
+ + {/* 成本对账瀑布 */} + +
+ + + + + + = 0 ? 'good' : 'bad'} description="入库价值-全制造成本" /> +
+ + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> + formatCurrency(v)} /> + + {waterfallData.map((entry, idx) => ( + = 0 ? '#52c41a' : '#f5222d') : + '#8ec6ff' + } /> + ))} + + + +
+ + {/* 品类成本结构 */} + + + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> + formatCurrency(v)} /> + + + + +
+ + + + + + + + + + + + + + + {categoryCost.map((c: any, i: number) => ( + + + + + + + + + + + ))} + +
品类产品数入库量理论成本实际材料制造费用全成本效率差异
{c.category_minor}{c.product_count}{formatNumber(c.total_qty)}{formatCurrency(c.theoretical_cost)}{formatCurrency(c.material_actual_cost)}{formatCurrency(c.allocated_mfg_cost)}{formatCurrency(c.full_cost)} + {varianceText(c.efficiency_var_pct)} +
+
+
+ + {/* 产品成本偏差散点图 */} + + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '理论成本', position: 'insideBottom', offset: -5, fontSize: 11 }} /> + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '实际成本', angle: -90, position: 'insideLeft', fontSize: 11 }} /> + name === 'x' || name === 'y' ? formatCurrency(v) : v} labelFormatter={() => ''} /> + + {scatterData.map((entry: any, idx: number) => ( + = 5 ? '#f5222d' : + entry.z >= 0 ? '#faad14' : '#1677ff' + } /> + ))} + + + +
+ 节约(≤-5%) + 正常(-5%~0%) + 轻微超耗(0~5%) + 严重超耗(≥5%) +
+
+ + {/* 产品明细表 */} + setProductFilter(e.target.value)} + className="rounded border px-3 py-1 text-sm" + /> + } + > +
+ + + + + + + + + + + + + + + + + + + + + {filteredProducts.map((p: any, i: number) => ( + + + + + + + + + + + + + + + + + ))} + +
编码产品名品类入库量理论成本标准成本实际材料制造费用全成本单位成本效率差异标准差异入库价值产品毛利
{p.product_code}{p.product_name}{p.category_minor}{formatNumber(p.inbound_quantity)}{formatCurrency(p.theoretical_cost)}{formatCurrency(p.standard_cost)}{formatCurrency(p.material_actual_cost)}{formatCurrency(p.allocated_manufacturing_cost)}{formatCurrency(p.full_manufacturing_cost)}{formatNumber(p.full_unit_cost)} + {varianceText(p.efficiency_variance_pct)} + + {varianceText(p.standard_variance_pct)} + {formatCurrency(p.inbound_value)}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(p.product_margin)} +
+
+
+ + {/* 配方效率分析 */} + +
+ + + + + + + + + + + + + + + + + + {recipeEfficiency.map((r: any, i: number) => ( + + + + + + + + + + + + + + ))} + +
配方物料规格理论用量实际用量理论金额实际金额金额差异率实际出成率标准出成率出成差异
{r.recipe_name}{r.item_name}{r.specification}{formatNumber(r.theoretical_qty)}{formatNumber(r.actual_qty)}{formatCurrency(r.theoretical_amt)}{formatCurrency(r.actual_amt)} + {varianceText(r.variance_pct)} + {r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-'}{r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-'} 0.03 ? 'text-green-600' : ''}`}> + {r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'} +
+
+
+ + {/* 出成率分析 */} + +
+ + + + + + + + + + + + + + + + {yieldAnalysis.map((y: any, i: number) => ( + + + + + + + + + + + + ))} + +
配方产品名理论入库量实际入库量达成率预期偏差率入库金额退库量退库率
{y.recipe_name}{y.product_name}{formatNumber(y.theoretical_inbound_qty)}{formatNumber(y.actual_inbound_qty)}= 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}`}> + {y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'} + 0.03 ? 'text-green-600' : ''}`}> + {y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'} + {formatCurrency(y.total_inbound_amt)}{formatNumber(y.total_return_qty)} 1 ? 'text-red-600' : ''}`}> + {y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'} +
+
+
+ + {/* 制造费用池 */} + +
+ + + + + + + + + + + + + + + + + {mfgPool.map((m: any, i: number) => ( + + + + + + + + + + + + + ))} + +
费用类型费用子类来源原始金额分摊比例分摊金额分摊方法计入重建临时备注
{m.cost_type}{m.cost_subtype}{m.source_type}{formatCurrency(m.source_amount)}{m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-'}{formatCurrency(m.allocated_amount)}{m.allocation_method}{m.include_in_rebuilt_cost ? '✅' : '❌'} + {m.is_provisional && } + {m.note}
+
+
+ + 当前制造费用分摊为临时方法(按人数比例),待电表、面积或工时数据后替换为更精确的分摊基准。 +
+
+
+ ) +} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index b52727a..90f22fc 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart } from 'recharts' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart, Area, AreaChart } from 'recharts' import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { Badge } from '@/components/Badge' @@ -8,8 +8,10 @@ import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MonthPicker } from '@/components/MonthPicker' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { useState, useMemo } from 'react' +import { TrendingUp, TrendingDown, Minus } from 'lucide-react' const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } const PAGE_SIZE = 10 @@ -17,25 +19,26 @@ const PAGE_SIZE = 10 export function DashboardPage() { const navigate = useNavigate() const [priorityPage, setPriorityPage] = useState(1) + const [month, setMonth] = useState('2026-04') const { data: overview, isLoading: odLoading } = useQuery({ - queryKey: ['overview'], - queryFn: () => api.get('/overview'), + queryKey: ['overview', month], + queryFn: () => api.get('/overview', { params: { month } }), }) const { data: daily, isLoading: dailyLoading } = useQuery({ - queryKey: ['overview/daily'], - queryFn: () => api.get('/overview/daily'), + queryKey: ['overview/daily', month], + queryFn: () => api.get('/overview/daily', { params: { month } }), }) const { data: riskData, isLoading: riskLoading } = useQuery({ - queryKey: ['stores/risk'], - queryFn: () => api.get('/stores/risk'), + queryKey: ['stores/risk', month], + queryFn: () => api.get('/stores/risk', { params: { month } }), }) const { data: priorityData, isLoading: priorityLoading } = useQuery({ - queryKey: ['stores/priority'], - queryFn: () => api.get('/stores/priority'), + queryKey: ['stores/priority', month], + queryFn: () => api.get('/stores/priority', { params: { month } }), }) const { data: loopHealth, isLoading: loopLoading } = useQuery({ @@ -44,8 +47,8 @@ export function DashboardPage() { }) const { data: quadrantData } = useQuery({ - queryKey: ['stores/quadrant'], - queryFn: () => api.get('/stores/quadrant'), + queryKey: ['stores/quadrant', month], + queryFn: () => api.get('/stores/quadrant', { params: { month } }), }) const { data: platformData } = useQuery({ @@ -59,8 +62,28 @@ export function DashboardPage() { }) const { data: expenseData } = useQuery({ - queryKey: ['store-expense/overview'], - queryFn: () => api.get('/store-expense/overview'), + queryKey: ['store-expense/overview', month], + queryFn: () => api.get('/store-expense/overview', { params: { month } }), + }) + + const { data: yoyData } = useQuery({ + queryKey: ['overview/yoy', month], + queryFn: () => api.get('/overview/yoy', { params: { month } }), + }) + + const { data: momData } = useQuery({ + queryKey: ['overview/mom', month], + queryFn: () => api.get('/overview/mom', { params: { month } }), + }) + + const { data: trendData } = useQuery({ + queryKey: ['overview/trend', month], + queryFn: () => api.get('/overview/trend', { params: { month, months: 12 } }), + }) + + const { data: profitTrendData } = useQuery({ + queryKey: ['overview/profit-trend', month], + queryFn: () => api.get('/overview/profit-trend', { params: { month, months: 6 } }), }) const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading @@ -75,6 +98,26 @@ export function DashboardPage() { const platformRows = (platformData as any)?.data || [] const alerts = ((alertsData as any)?.data?.alerts || []) as any[] const alertSummary = (alertsData as any)?.data + const yoy = (yoyData as any)?.data + const mom = (momData as any)?.data + const trendRows = ((trendData as any)?.data || []).map((r: any) => ({ + ...r, + month: r.month?.substring(0, 10) || r.month, + })) + const profitTrendRows = ((profitTrendData as any)?.data || []).map((r: any) => ({ + ...r, + report_month: r.report_month?.substring(0, 10) || r.report_month, + })) + + const yoyData_ = yoy?.yoy + const momData_ = mom?.mom + const TrendIcon = ({ val }: { val: number | null }) => { + if (val === null || val === undefined) return + if (val > 0) return + if (val < 0) return + return + } + const fmtChange = (v: number | null) => v === null ? '-' : `${v > 0 ? '+' : ''}${v.toFixed(2)}%` const QUADRANT_COLORS: Record = { '明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7', @@ -159,13 +202,16 @@ export function DashboardPage() {

总部驾驶舱

-

全部门店经营总览 · 2026年4月

+

全部门店经营总览 · {month}

-
- {totalStores} 家门店 - 红色 {riskSummary['红色'] || 0} - 黄色 {riskSummary['黄色'] || 0} - 绿色 {riskSummary['绿色'] || 0} +
+ +
+ {totalStores} 家门店 + 红色 {riskSummary['红色'] || 0} + 黄色 {riskSummary['黄色'] || 0} + 绿色 {riskSummary['绿色'] || 0} +
@@ -188,12 +234,44 @@ export function DashboardPage() {
+ {/* 同比/环比对比 */} + {(yoyData_ || momData_) && ( + +
+ {[ + { label: '实收同比', yoy: yoyData_?.received_change_pct, mom: momData_?.received_change_pct }, + { label: '账单数同比', yoy: yoyData_?.bill_count_change_pct, mom: momData_?.bill_count_change_pct }, + { label: '客单价同比', yoy: yoyData_?.avg_bill_value_change_pct, mom: momData_?.avg_bill_value_change_pct }, + { label: '优惠率变化', yoy: yoyData_?.discount_rate_change, mom: momData_?.discount_rate_change }, + ].map((item) => ( +
+

{item.label}

+
+ 同比 + + 0 ? 'text-green-600' : item.yoy < 0 ? 'text-red-600' : 'text-muted-foreground'}`}> + {fmtChange(item.yoy ?? null)} + +
+
+ 环比 + + 0 ? 'text-green-600' : item.mom < 0 ? 'text-red-600' : 'text-muted-foreground'}`}> + {fmtChange(item.mom ?? null)} + +
+
+ ))} +
+
+ )} + {/* 利润与成本 */} {ex && ( - +
- - + + 10 ? 'bad' : Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct) > 5 ? 'warn' : 'good'} description={`成本率 ${formatPercent(ex.actual_food_cost_rate_pct)} · 理论成本率 ${formatPercent(ex.theoretical_food_cost_rate_pct)} · 差异 ${formatPercent(Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct))}。公式:成本率 = 食材成本 ÷ 实收 × 100%。建议值:实际成本率 ≤ 32%,与理论差异 ≤ 3% 为正常`} /> 50 ? 'bad' : Number(ex.overall_expense_rate_pct) > 40 ? 'warn' : 'good'} description={`费用率 ${formatPercent(ex.overall_expense_rate_pct)} · 公式:费用率 = 经营费用 ÷ 实收 × 100%。人工 ${formatCurrency(ex.total_wage)} · 房租 ${formatCurrency(ex.total_rent)}。建议值:费用率 ≤ 40% 为健康,40-50% 需关注,> 50% 需整改`} />
@@ -368,6 +446,48 @@ export function DashboardPage() {
+ {/* 月度趋势 & 利润趋势 */} +
+ + + + + v?.substring(5, 7) || ''} tick={{ fontSize: 10 }} /> + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + v} + formatter={(v: any, n: string) => n === '实收' ? formatCurrency(v) : formatNumber(v)} + /> + + + + + + + + + + + + v?.substring(5, 7) || ''} tick={{ fontSize: 10 }} /> + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + v} + formatter={(v: any) => formatCurrency(v)} + /> + + + + + + + + + + +
+ {/* P0/P1 门店列表 */} @@ -37,6 +50,29 @@ export function DataQualityPage() {

账单与菜品明细数据质量检查 · {dq.min_date?.substring(0, 10)} ~ {dq.max_date?.substring(0, 10)}

+ {/* 质量状态总览 */} +
+
+

完整性

+

{completenessStatus}

+

缺失字段检查

+
+
+

准确性

+

{accuracyStatus}

+

负值、零值、越界检查

+
+
+

一致性

+

{consistencyStatus}

+

库存负耗用等跨表检查

+
+
+

利润数据置信等级

+

{profitConfidence}

+
+
+ {/* 账单数据质量 */}
@@ -46,14 +82,10 @@ export function DataQualityPage() {
- - -
-

账单质量状态

-

- {billQualityStatus} -

-
+ + + +
{missingBillNo > 0 && (
@@ -79,10 +111,10 @@ export function DataQualityPage() { -
+

菜品质量状态

-

- {dishQualityStatus} +

+ {dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'}

@@ -96,6 +128,22 @@ export function DataQualityPage() { )} + {/* 库存数据质量 */} + +
+ + +
+ {negativeConsumptionCount > 0 && ( +
+

+ 提示: + {negativeConsumptionCount} 条库存负耗用记录,金额 {formatCurrency(negativeConsumptionAmount)},可能扭曲成本和库存周转,需排查盘点差异或录入错误 +

+
+ )} +
+ {/* 数据治理建议 */}
diff --git a/client/src/pages/DistributionReconciliationPage.tsx b/client/src/pages/DistributionReconciliationPage.tsx new file mode 100644 index 0000000..8d65c16 --- /dev/null +++ b/client/src/pages/DistributionReconciliationPage.tsx @@ -0,0 +1,343 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ScatterChart, Scatter } 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, formatPercent } from '@/lib/utils' +import { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react' +import { MonthPicker } from '@/components/MonthPicker' + +function varianceStatus(pct: number): 'good' | 'warn' | 'bad' { + if (Math.abs(pct) <= 3) return 'good' + if (Math.abs(pct) <= 10) return 'warn' + return 'bad' +} + +function varianceColorClass(pct: number): string { + const s = varianceStatus(pct) + return s === 'good' ? 'text-green-600' : s === 'warn' ? 'text-yellow-600' : 'text-red-600' +} + +function varianceText(pct: number): string { + if (pct > 0) return `+${pct.toFixed(2)}%` + return `${pct.toFixed(2)}%` +} + +export function DistributionReconciliationPage() { + const [month, setMonth] = useState('2026-04') + const [storeFilter, setStoreFilter] = useState('') + + const { data, isLoading } = useQuery({ + queryKey: ['distribution-reconciliation', month], + queryFn: async () => { + const res = await api.get(`/distribution/reconciliation?month=${month}`) + return res.data + }, + }) + + if (isLoading) return + if (!data) return
暂无数据
+ + const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation } = data + + const filteredStores = storeReconciliation.filter((s: any) => + !storeFilter || + s.store_code?.includes(storeFilter) || + s.store_name?.includes(storeFilter) + ) + + // 倒挤公式瀑布数据 + const waterfallData = [ + { name: '期初库存', value: summary.total_opening_amt, type: 'base' }, + { name: '配送入库', value: summary.total_dist_amt, type: 'add' }, + { name: '应耗用(倒挤)', value: summary.reverse_consumption_amt, type: 'calc' }, + { name: '实际耗用', value: summary.total_consumption_amt, type: 'actual' }, + { name: '期末库存', value: summary.total_ending_amt, type: 'base' }, + { name: '差异金额', value: summary.variance_amt, type: 'variance' }, + ] + + // 品类对账柱状图 + const categoryChartData = categoryReconciliation.slice(0, 15).map((c: any) => ({ + name: c.minor_category, + 配送金额: c.dist_amt, + 耗用金额: c.consumption_amt, + 期末库存: c.ending_amt, + })) + + // 门店散点图:配送金额 vs 差异率 + const storeScatterData = storeReconciliation + .filter((s: any) => s.dist_amt > 0 && s.consumption_amt > 0) + .map((s: any) => ({ + name: s.store_name || s.store_code, + x: s.dist_amt, + y: s.variance_pct, + store_code: s.store_code, + })) + + return ( +
+ {/* 页面标题 */} +
+ +

配送—倒挤成本对账

+ +
+ + {/* 公式说明 */} +
+ 倒挤公式: 应耗用成本 = 期初库存 + 配送入库 - 期末库存 | 差异 = 实际耗用 - 应耗用(倒挤) | 差异率 = 差异 / 实际耗用 × 100% +
+ + {/* 核心指标 */} +
+ + + + + + = 0 ? 'warn' : 'bad'} /> + +
+ + {/* 倒挤对账瀑布 */} + + + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> + formatCurrency(v)} /> + + {waterfallData.map((entry, idx) => ( + = 0 ? '#faad14' : '#f5222d') : + '#8ec6ff' + } /> + ))} + + + + + + {/* 品类维度对账 */} + + + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> + formatCurrency(v)} /> + + + + + +
+ + + + + + + + + + + + + + + + {categoryReconciliation.map((c: any, i: number) => ( + + + + + + + + + + + + ))} + +
品类品项数配送量配送金额不含税成本耗用金额期末库存差异金额差异率
{c.minor_category}{c.item_count}{formatNumber(c.dist_qty)}{formatCurrency(c.dist_amt)}{formatCurrency(c.dist_cost_excl_tax)}{formatCurrency(c.consumption_amt)}{formatCurrency(c.ending_amt)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> + {formatCurrency(c.variance_amt)} + + {varianceText(c.variance_pct)} +
+
+
+ + {/* 门店差异散点图 */} + + + + + `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '配送金额', position: 'insideBottom', offset: -5, fontSize: 11 }} /> + `${v.toFixed(0)}%`} tick={{ fontSize: 11 }} label={{ value: '差异率%', angle: -90, position: 'insideLeft', fontSize: 11 }} /> + name === 'x' ? formatCurrency(v) : `${v.toFixed(2)}%`} labelFormatter={() => ''} /> + + {storeScatterData.map((entry: any, idx: number) => ( + + ))} + + + +
+ 正常(±3%) + 关注(3~10%) + 异常(>10%) +
+
+ + {/* 门店对账明细 */} + setStoreFilter(e.target.value)} + className="rounded border px-3 py-1 text-sm" + /> + } + > +
+ + + + + + + + + + + + + + + + + + {filteredStores.map((s: any, i: number) => ( + + + + + + + + + + + + + + ))} + +
门店编码门店名称配送金额不含税成本期初库存实际耗用期末库存倒挤应耗用差异金额差异率负库存
{s.store_code}{s.store_name || '-'}{formatCurrency(s.dist_amt)}{formatCurrency(s.dist_cost_excl_tax)}{formatCurrency(s.opening_amt)}{formatCurrency(s.consumption_amt)}{formatCurrency(s.ending_amt)}{formatCurrency(s.reverse_consumption_amt)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> + {formatCurrency(s.variance_amt)} + + {varianceText(s.variance_pct)} + 0 ? 'text-red-600 font-medium' : ''}`}> + {s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'} +
+
+
+ + {/* Top差异品项 */} + +
+ + + + + + + + + + + + + + + + + + + {topVariances.map((v: any, i: number) => ( + + + + + + + + + + + + + + + ))} + +
门店品项编码品项名称品类配送量配送金额期初实际耗用期末倒挤应耗用差异金额差异率
{v.store_code}{v.item_code}{v.item_name}{v.minor_category}{formatNumber(v.dist_qty)}{formatCurrency(v.dist_amt)}{formatCurrency(v.opening_amt)}{formatCurrency(v.consumption_amt)}{formatCurrency(v.ending_amt)}{formatCurrency(v.reverse_consumption_amt)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> + {formatCurrency(v.variance_amt)} + + {varianceText(v.variance_pct)} +
+
+
+ + {/* 未匹配品项 */} + +
+ + + + + + + + + + + + + {unmatchedItems.map((u: any, i: number) => ( + + + + + + + + + ))} + +
品项编码品项名称品类配送量配送金额涉及门店数
{u.item_code}{u.item_name}{u.minor_category}{formatNumber(u.dist_qty)}{formatCurrency(u.dist_amt)}{u.store_count}
+
+
+ + 这些品项在配送系统有发货记录,但在库存系统中无对应的耗用数据,可能存在编码不一致、未入库或数据缺失等问题。 +
+
+
+ ) +} diff --git a/client/src/pages/MonthlyReviewPage.tsx b/client/src/pages/MonthlyReviewPage.tsx index 8fe08f8..93f8ef0 100644 --- a/client/src/pages/MonthlyReviewPage.tsx +++ b/client/src/pages/MonthlyReviewPage.tsx @@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard' import { Pagination } from '@/components/Pagination' import { useState, useMemo } from 'react' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { MonthPicker } from '@/components/MonthPicker' const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' } const PAGE_SIZE = 10 @@ -84,12 +85,7 @@ export function MonthlyReviewPage() {

月度验收与复盘

- setMonth(e.target.value)} - className="rounded-md border px-3 py-1.5 text-sm" - /> +
{/* Tab bar */} diff --git a/client/src/pages/ProductionPlanPage.tsx b/client/src/pages/ProductionPlanPage.tsx new file mode 100644 index 0000000..2242a6f --- /dev/null +++ b/client/src/pages/ProductionPlanPage.tsx @@ -0,0 +1,306 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie } 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, formatPercent } from '@/lib/utils' +import { cn } from '@/lib/utils' +import { TrendingUp, Factory, Truck, Package, ArrowRight } from 'lucide-react' +import { MonthPicker } from '@/components/MonthPicker' + +function rateColorClass(rate: number | null): string { + if (rate === null) return 'text-gray-400' + if (rate >= 90) return 'text-green-600' + if (rate >= 70) return 'text-yellow-600' + return 'text-red-600' +} + +function rateText(rate: number | null): string { + if (rate === null) return '-' + return `${rate.toFixed(1)}%` +} + +export function ProductionPlanPage() { + const [month, setMonth] = useState('2026-04') + + const { data, isLoading } = useQuery({ + queryKey: ['production-plan', month], + queryFn: async () => { + const res = await api.get(`/sales-driven/production-plan?month=${month}`) + return res.data + }, + }) + + if (isLoading) return + if (!data) return
暂无数据
+ + const { summary, skuSales, materialDemand, storeDemand, ckProductionPlan, productionCoord } = data + + // BOM覆盖率饼图 + const coverageData = [ + { name: '有BOM', value: summary.matched_sku_count, fill: '#52c41a' }, + { name: '无BOM', value: summary.total_dish_count - summary.matched_sku_count, fill: '#f5222d' }, + ] + + // Top10原料需求 + const topMaterialDemand = materialDemand.slice(0, 15) + const materialChartData = topMaterialDemand.map((m: any) => ({ + name: m.material_name?.length > 10 ? m.material_name.slice(0, 10) + '...' : m.material_name, + 需求量: m.total_demand_qty, + 门店数: m.store_count, + })) + + // 图表数据用Top15 + const topCkPlan = ckProductionPlan.filter((p: any) => p.demand_qty > 0).slice(0, 15) + + // 生产配送协同图表Top15 + const topCoord = productionCoord.filter((p: any) => p.inbound_qty > 0).slice(0, 15) + const coordChartData = topCoord.map((p: any) => ({ + name: p.product_name?.length > 10 ? p.product_name.slice(0, 10) + '...' : p.product_name, + 完工入库: p.inbound_qty, + 配送量: p.dist_qty, + 门店消耗: p.consumption_qty, + })) + + return ( +
+ {/* 页面标题 */} +
+ +

销量驱动生产与要货计划

+ +
+ + {/* 公式说明 */} +
+ 预测逻辑: SKU销量 × 门店BOM = 基础原料需求 | 生产计划: 各门店要货合计 + 中央厨房安全库存 | 要货建议: 需求量 - 当前库存 + 安全库存 +
+ + {/* 核心指标 */} +
+ + + = 50 ? 'good' : 'warn'} /> + + + + p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" /> +
+ + {/* BOM覆盖率 + Top原料需求 */} +
+ + + + `${entry.name}: ${entry.value}`}> + {coverageData.map((entry, idx) => ( + + ))} + + + + + + +
+ + + + + + + formatNumber(v)} /> + + + + +
+
+ + {/* 中央厨房生产计划 */} + +
+ + 需求量 = 各门店BOM中半成品用量合计,差异 = 需求 - 实际入库 +
+ formatNumber(r.demand_qty) }, + { key: 'actual_inbound_qty', label: '实际入库', align: 'right', render: (r: any) => formatNumber(r.actual_inbound_qty) }, + { key: 'variance_qty', label: '差异量', align: 'right', render: (r: any) => ( + 0 ? 'text-red-600' : 'text-green-600')}>{formatNumber(r.variance_qty)} + ) }, + { key: 'variance_pct', label: '差异率', align: 'right', render: (r: any) => ( + + {r.variance_pct === null ? '-' : `${r.variance_pct > 0 ? '+' : ''}${r.variance_pct.toFixed(1)}%`} + + ) }, + { key: 'store_count', label: '涉及门店', align: 'right' }, + { key: 'actual_cost', label: '实际成本', align: 'right', render: (r: any) => formatCurrency(r.actual_cost) }, + ]} + /> +
+ + {/* 生产与配送协同 */} + +
+ + 完工配送率 = 配送/完工 | 配送消耗率 = 消耗/配送 | 库存积压率 = 期末/消耗 +
+ + + + + `${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 11 }} /> + formatNumber(v)} /> + + + + + +
+ formatNumber(r.inbound_qty) }, + { key: 'dist_qty', label: '配送量', align: 'right', render: (r: any) => formatNumber(r.dist_qty) }, + { key: 'consumption_qty', label: '门店消耗', align: 'right', render: (r: any) => formatNumber(r.consumption_qty) }, + { key: 'ending_qty', label: '期末库存', align: 'right', render: (r: any) => formatNumber(r.ending_qty) }, + { key: 'completion_distribution_rate', label: '完工配送率', align: 'right', render: (r: any) => ( + {rateText(r.completion_distribution_rate)} + ) }, + { key: 'distribution_consumption_rate', label: '配送消耗率', align: 'right', render: (r: any) => ( + {rateText(r.distribution_consumption_rate)} + ) }, + { key: 'inventory_accumulation_rate', label: '库存积压率', align: 'right', render: (r: any) => ( + 30 ? 'text-red-600' : 'text-green-600'}> + {rateText(r.inventory_accumulation_rate)} + + ) }, + ]} + /> +
+
+ + {/* 门店要货建议 */} + + formatNumber(r.total_qty) }, + { key: 'total_amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.total_amt) }, + { key: 'total_material_demand_qty', label: '原料需求量', align: 'right', render: (r: any) => formatNumber(r.total_material_demand_qty) }, + { key: 'ending_inventory_qty', label: '期末库存量', align: 'right', render: (r: any) => formatNumber(r.ending_inventory_qty) }, + { key: 'ending_inventory_amt', label: '期末库存额', align: 'right', render: (r: any) => formatCurrency(r.ending_inventory_amt) }, + { key: 'suggested_order_qty', label: '建议要货量', align: 'right', render: (r: any) => ( + 0 ? 'text-blue-600' : 'text-green-600')}>{formatNumber(r.suggested_order_qty)} + ) }, + ]} + /> + + + {/* 原料需求明细 */} + + formatNumber(r.total_demand_qty) }, + { key: 'total_demand_amt', label: '总需求金额', align: 'right', render: (r: any) => formatCurrency(r.total_demand_amt) }, + { key: 'store_count', label: '涉及门店数', align: 'right' }, + { key: 'avg_store_demand', label: '门店均需求', align: 'right', render: (r: any) => formatNumber(r.avg_store_demand) }, + ]} + /> + + + {/* SKU销量Top50 */} + + r.sku_code || '-' }, + { key: 'qty', label: '销量', align: 'right', render: (r: any) => formatNumber(r.qty) }, + { key: 'amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.amt) }, + { key: 'store_count', label: '门店数', align: 'right' }, + { key: 'avg_unit_price', label: '均价', align: 'right', render: (r: any) => formatCurrency(r.avg_unit_price) }, + { key: 'has_bom', label: '有BOM', align: 'center', render: (r: any) => ( + r.has_bom ? : + ) }, + { key: 'material_count', label: '材料数', align: 'right', render: (r: any) => r.material_count || '-' }, + ]} + /> + +
+ ) +} diff --git a/client/src/pages/RegionalPage.tsx b/client/src/pages/RegionalPage.tsx index c1ff754..8de2367 100644 --- a/client/src/pages/RegionalPage.tsx +++ b/client/src/pages/RegionalPage.tsx @@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard' import { Pagination } from '@/components/Pagination' import { formatCurrency, formatPercent, riskColor } from '@/lib/utils' import { LoadingSpinner } from '@/components/LoadingSpinner' +import { MonthPicker } from '@/components/MonthPicker' import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts' import { useState, useMemo } from 'react' @@ -16,6 +17,7 @@ export function RegionalPage() { const navigate = useNavigate() const queryClient = useQueryClient() const [riskFilter, setRiskFilter] = useState('') + const [month, setMonth] = useState('2026-05') const [riskPage, setRiskPage] = useState(1) const [checkPage, setCheckPage] = useState(1) const [benchPage, setBenchPage] = useState(1) @@ -29,7 +31,7 @@ export function RegionalPage() { const { data: weeklyData, isLoading: weeklyLoading } = useQuery({ queryKey: ['tasks/weekly-check'], - queryFn: () => api.get('/tasks/weekly-check', { params: { month: '2026-05' } }), + queryFn: () => api.get('/tasks/weekly-check', { params: { month } }), }) const { data: priorityData, isLoading: priorityLoading } = useQuery({ @@ -77,7 +79,10 @@ export function RegionalPage() { return (
-

区域经理工作台

+
+

区域经理工作台

+ +
{/* 汇总卡片 */}
diff --git a/client/src/pages/RevenuePage.tsx b/client/src/pages/RevenuePage.tsx index a6ac52f..639aeeb 100644 --- a/client/src/pages/RevenuePage.tsx +++ b/client/src/pages/RevenuePage.tsx @@ -245,7 +245,7 @@ export function RevenuePage() { {/* 门店营收排名 */}
- + diff --git a/client/src/pages/StoreDetailPage.tsx b/client/src/pages/StoreDetailPage.tsx index 7282f23..e25cffc 100644 --- a/client/src/pages/StoreDetailPage.tsx +++ b/client/src/pages/StoreDetailPage.tsx @@ -10,6 +10,7 @@ import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' import { ArrowLeft } from 'lucide-react' +import { MonthPicker } from '@/components/MonthPicker' import { useState } from 'react' type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks' @@ -29,6 +30,7 @@ export function StoreDetailPage() { const navigate = useNavigate() const [tab, setTab] = useState('overview') const [anomalyPage, setAnomalyPage] = useState(1) + const [month, setMonth] = useState('2026-05') const { data: storeData } = useQuery({ queryKey: ['store', code], @@ -42,7 +44,7 @@ export function StoreDetailPage() { const { data: tasksData } = useQuery({ queryKey: ['store', code, 'tasks'], - queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }), + queryFn: () => api.get('/tasks', { params: { store_code: code, month, page_size: 50 } }), enabled: tab === 'tasks', }) @@ -137,6 +139,7 @@ export function StoreDetailPage() {

{sc.store_name}

{risk?.risk_level && } {quadrant && {quadrant}} +
{/* 核心指标 */} diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index 4ef9198..fe40dce 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -4,6 +4,7 @@ import { Badge } from '@/components/Badge' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' import { LoadingSpinner } from '@/components/LoadingSpinner' +import { MonthPicker } from '@/components/MonthPicker' import { useState } from 'react' export function StorePage() { @@ -11,6 +12,7 @@ export function StorePage() { const [executeText, setExecuteText] = useState('') const [activeTaskId, setActiveTaskId] = useState(null) const [storeCode, setStoreCode] = useState('1111') + const [month, setMonth] = useState('2026-05') const { data: storesData, isLoading: storesLoading } = useQuery({ queryKey: ['stores-list'], @@ -30,12 +32,12 @@ export function StorePage() { const { data: tasksData, isLoading: tasksLoading } = useQuery({ queryKey: ['store', storeCode, 'tasks'], - queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month: '2026-05', page_size: 50 } }), + queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }), }) const { data: dailyData, isLoading: dailyLoading } = useQuery({ queryKey: ['store', storeCode, 'daily'], - queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month: '2026-04' } }), + queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }), }) const { data: followupData, isLoading: followupLoading } = useQuery({ @@ -45,12 +47,12 @@ export function StorePage() { const { data: skuData } = useQuery({ queryKey: ['sku-attach', storeCode], - queryFn: () => api.get('/sku/attach'), + queryFn: () => api.get('/sku/attach', { params: { month } }), }) const { data: abcData } = useQuery({ queryKey: ['sku-abc'], - queryFn: () => api.get('/sku/abc'), + queryFn: () => api.get('/sku/abc', { params: { month } }), }) const card = (cardData as any)?.data @@ -109,15 +111,18 @@ export function StorePage() {

店长工作台

- +
+ + +
{/* 门店概况 */} diff --git a/client/src/pages/TasksPage.tsx b/client/src/pages/TasksPage.tsx index 322cc3f..35eb603 100644 --- a/client/src/pages/TasksPage.tsx +++ b/client/src/pages/TasksPage.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/Badge' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { formatCurrency, formatPercent } from '@/lib/utils' +import { MonthPicker } from '@/components/MonthPicker' import { useState, useMemo } from 'react' const PAGE_SIZE = 5 @@ -46,17 +47,14 @@ export function TasksPage() {

任务管理

- 共 {total} 条任务 +
+ + 共 {total} 条任务 +
{/* 筛选器 */}
- setMonth(e.target.value)} - className="rounded-md border px-3 py-1.5 text-sm" - />