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 && (
+
+
+
+
+
+
+
+ )}
+ >
+ )}
+
+ {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}
+ />
+
+
+
+
+
+
+ {simResult && !simResult.error && (
+ <>
+
+
+
+
+
+
+
+ {simResult.releasable_materials?.length > 0 && (
+ formatCurrency(r.inventory_value) },
+ ]}
+ data={simResult.releasable_materials}
+ />
+ )}
+ >
+ )}
+ {simResult?.error && {simResult.error}
}
+
+
+
+
+
+ formatNumber(r.estimated_demand) },
+ { key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
+ ]}
+ data={demandData.slice((demandPage - 1) * PAGE_SIZE, demandPage * PAGE_SIZE)}
+ />
+
+
+
+ )
+}
diff --git a/client/src/pages/CostAnalysisPage.tsx b/client/src/pages/CostAnalysisPage.tsx
new file mode 100644
index 0000000..89fc901
--- /dev/null
+++ b/client/src/pages/CostAnalysisPage.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react'
+import { Tabs } from '@/components/Tabs'
+import { OverviewTab } from '@/components/cost-analysis/OverviewTab'
+import { ProfitabilityTab } from '@/components/cost-analysis/ProfitabilityTab'
+import { MaterialTab } from '@/components/cost-analysis/MaterialTab'
+import { BomTab } from '@/components/cost-analysis/BomTab'
+import { SupplyTab } from '@/components/cost-analysis/SupplyTab'
+import { PackagingTab } from '@/components/cost-analysis/PackagingTab'
+import { QualityTab } from '@/components/cost-analysis/QualityTab'
+import { StoreTab } from '@/components/cost-analysis/StoreTab'
+import { ExploreTab } from '@/components/cost-analysis/ExploreTab'
+import { AdjustmentTab } from '@/components/cost-analysis/AdjustmentTab'
+
+const TABS = [
+ { key: 'overview', label: '成本总览' },
+ { key: 'profitability', label: '菜品盈利' },
+ { key: 'material', label: '原料差异' },
+ { key: 'bom', label: 'BOM与配方' },
+ { key: 'supply', label: '供应链与精简' },
+ { key: 'packaging', label: '包装耗材' },
+ { key: 'quality', label: '数据质量' },
+ { key: 'store', label: '门店成本' },
+ { key: 'explore', label: '可视化探索' },
+ { key: 'adjustment', label: '调整管理' },
+]
+
+export function CostAnalysisPage() {
+ const [activeTab, setActiveTab] = useState('overview')
+
+ return (
+
+
+
菜品成本分析
+
基于BOM与成本报表的多维度运营分析
+
+
+
+ {activeTab === 'overview' &&
}
+ {activeTab === 'profitability' &&
}
+ {activeTab === 'material' &&
}
+ {activeTab === 'bom' &&
}
+ {activeTab === 'supply' &&
}
+ {activeTab === 'packaging' &&
}
+ {activeTab === 'quality' &&
}
+ {activeTab === 'store' &&
}
+ {activeTab === 'explore' &&
}
+ {activeTab === 'adjustment' &&
}
+
+
+ )
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 2d5ca25..817aa42 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -6,6 +6,7 @@ import { errorHandler, notFoundHandler } from './middleware/error.js'
import authRoutes from './routes/auth.js'
import dataRoutes from './routes/data.js'
import taskRoutes from './routes/tasks.js'
+import costAnalysisRoutes from './routes/cost-analysis.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -31,6 +32,7 @@ app.use((req, res, next) => {
app.use('/api', dataRoutes)
app.use('/api/tasks', taskRoutes)
+app.use('/api/cost-analysis', costAnalysisRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
diff --git a/server/src/routes/cost-analysis.ts b/server/src/routes/cost-analysis.ts
new file mode 100644
index 0000000..8174954
--- /dev/null
+++ b/server/src/routes/cost-analysis.ts
@@ -0,0 +1,978 @@
+import { Router } from 'express'
+import { query } from '../config/database.js'
+import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
+import type { AuthRequest } from '../middleware/auth.js'
+
+const router = Router()
+
+// ============ Tab1: 成本总览 ============
+
+// 成本总览指标
+router.get('/overview', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ count(*) AS total_dishes,
+ count(*) FILTER (WHERE theoretical_margin_rate_pct < 50) AS low_margin_dishes,
+ count(*) FILTER (WHERE cost_variance_amount > 0) AS over_cost_dishes,
+ round((1 - sum(theoretical_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_theo_margin,
+ round((1 - sum(actual_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_actual_margin,
+ round(sum(sales_amount)::numeric, 2) AS total_sales,
+ round(sum(theoretical_cost)::numeric, 2) AS total_theo_cost,
+ round(sum(actual_cost)::numeric, 2) AS total_actual_cost,
+ round(sum(cost_variance_amount)::numeric, 2) AS total_variance
+ FROM public.dish_cost_analysis_summary
+ `)
+ sendSuccess(res, result.rows[0])
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 品类成本对比
+router.get('/category-comparison', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT category_level1,
+ count(*) AS dishes,
+ round((1 - sum(theoretical_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_theo_margin,
+ round((1 - sum(actual_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_actual_margin,
+ round(sum(sales_amount)::numeric, 2) AS total_sales,
+ round(sum(theoretical_cost)::numeric, 2) AS total_theo_cost,
+ round(sum(actual_cost)::numeric, 2) AS total_actual_cost,
+ round(sum(cost_variance_amount)::numeric, 2) AS total_variance
+ FROM public.dish_cost_analysis_summary
+ WHERE category_level1 IS NOT NULL
+ GROUP BY category_level1 ORDER BY total_sales DESC
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 毛利率偏差分布
+router.get('/margin-deviation', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ CASE
+ WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) > 10 THEN '实际高于理论>10%'
+ WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) BETWEEN 0 AND 10 THEN '实际略高0-10%'
+ WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) BETWEEN -10 AND 0 THEN '实际略低0-10%'
+ WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) BETWEEN -30 AND -10 THEN '实际偏低10-30%'
+ WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) < -30 THEN '实际严重偏低>30%'
+ END AS deviation_band,
+ count(*) AS cnt
+ FROM public.dish_cost_analysis_summary
+ WHERE theoretical_margin_rate_pct IS NOT NULL AND actual_margin_rate_pct IS NOT NULL
+ GROUP BY deviation_band ORDER BY cnt DESC
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 成本差异TOP榜
+router.get('/variance-top', async (req: AuthRequest, res) => {
+ try {
+ const limit = parseInt((req.query.limit as string) || '50')
+ const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
+ const result = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
+ round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
+ round(cost_variance_amount::numeric, 2) AS cost_variance,
+ round(sales_amount::numeric, 2) AS sales_amount,
+ CASE
+ WHEN actual_margin_rate_pct < 0 THEN '数据异常'
+ WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.2 THEN '紧急'
+ WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.1 THEN '整改'
+ WHEN cost_variance_amount > 0 THEN '关注'
+ ELSE '正常'
+ END AS cost_tier
+ FROM public.dish_cost_analysis_summary
+ ORDER BY cost_variance_amount ${order} LIMIT $1
+ `, [limit])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab2: 菜品盈利分析 ============
+
+// 菜单工程矩阵
+router.get('/menu-engineering', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(sales_quantity::numeric, 2) AS sales_quantity,
+ round(sales_amount::numeric, 2) AS sales_amount,
+ round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
+ CASE
+ WHEN actual_margin_rate_pct < 0 THEN '数据异常'
+ WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
+ AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '明星盈利品'
+ WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
+ AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '高销低利品'
+ WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
+ AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '低销高利品'
+ ELSE '低销低利品'
+ END AS menu_type
+ FROM public.dish_cost_analysis_summary
+ WHERE actual_margin_rate_pct IS NOT NULL
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 菜品盈利明细
+router.get('/profitability', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const category = req.query.category as string
+ const menuType = req.query.type as string
+
+ let where = 'WHERE actual_margin_rate_pct IS NOT NULL'
+ const params: any[] = []
+ if (category) { params.push(category); where += ` AND category_level1 = $${params.length}` }
+
+ const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary ${where}`, params)
+ const total = countResult.rows[0].count
+
+ params.push(pageSize, offset)
+ const result = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(sales_quantity::numeric, 2) AS sales_quantity,
+ round(sales_amount::numeric, 2) AS sales_amount,
+ round((sales_amount - theoretical_cost)::numeric, 2) AS theo_profit,
+ round((sales_amount - actual_cost)::numeric, 2) AS actual_profit,
+ round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
+ round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
+ round(sales_amount / nullif(sum(sales_amount) OVER (), 0) * 100, 2) AS revenue_contribution
+ FROM public.dish_cost_analysis_summary ${where}
+ ORDER BY sales_amount DESC LIMIT $${params.length - 1} OFFSET $${params.length}
+ `, params)
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 定价合理性
+router.get('/pricing', async (req: AuthRequest, res) => {
+ try {
+ const threshold = parseFloat((req.query.threshold as string) || '50')
+ const result = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(price::numeric, 2) AS price,
+ round(theoretical_cost::numeric, 2) AS theo_cost,
+ round((theoretical_cost / nullif(price, 0) * 100)::numeric, 2) AS theo_cost_rate,
+ round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
+ round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
+ round(sales_amount::numeric, 2) AS sales_amount
+ FROM public.dish_cost_analysis_summary
+ WHERE theoretical_margin_rate_pct < $1 AND sales_amount > 1000
+ ORDER BY theoretical_margin_rate_pct ASC
+ `, [threshold])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab3: 原料差异分析 ============
+
+// 菜品原料差异分解
+router.get('/material-variance', async (req: AuthRequest, res) => {
+ try {
+ const dishCode = req.query.dish_code as string
+ if (!dishCode) return sendError(res, 'dish_code is required')
+
+ const result = await query(`
+ SELECT m.material_name, m.material_unit,
+ round(m.theoretical_quantity::numeric, 4) AS theo_qty,
+ round(m.actual_quantity::numeric, 4) AS actual_qty,
+ round(m.loss_quantity::numeric, 4) AS loss_qty,
+ round(m.loss_quantity_rate_pct::numeric, 2) AS loss_rate,
+ round(m.theoretical_amount::numeric, 2) AS theo_amount,
+ round(m.actual_amount::numeric, 2) AS actual_amount,
+ round(m.loss_amount::numeric, 2) AS loss_amount,
+ CASE
+ WHEN m.loss_quantity_rate_pct < -100 THEN '分摊遗漏'
+ WHEN m.loss_quantity_rate_pct < -30 THEN '份量超标'
+ WHEN m.actual_quantity = 0 THEN '未使用'
+ ELSE '正常损耗'
+ END AS reason
+ FROM public.dish_cost_analysis_material_detail m
+ JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
+ WHERE s.dish_code = $1
+ ORDER BY m.loss_amount ASC
+ `, [dishCode])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 损耗率分布
+router.get('/loss-distribution', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ CASE
+ WHEN loss_quantity_rate_pct = 0 THEN '无损耗'
+ WHEN loss_quantity_rate_pct BETWEEN -1 AND 0 THEN '轻微(-1~0%)'
+ WHEN loss_quantity_rate_pct BETWEEN -10 AND -1 THEN '轻度(-1~-10%)'
+ WHEN loss_quantity_rate_pct BETWEEN -30 AND -10 THEN '中度(-10~-30%)'
+ WHEN loss_quantity_rate_pct BETWEEN -100 AND -30 THEN '严重(-30~-100%)'
+ WHEN loss_quantity_rate_pct < -100 THEN '极重(<-100%)'
+ ELSE '正向(>0%)'
+ END AS loss_band,
+ count(*) AS cnt
+ FROM public.dish_cost_analysis_material_detail
+ GROUP BY loss_band ORDER BY cnt DESC
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 超耗物料TOP榜
+router.get('/material-loss-top', async (req: AuthRequest, res) => {
+ try {
+ const limit = parseInt((req.query.limit as string) || '50')
+ const order = (req.query.order as string) === 'rate' ? 'avg_loss_rate' : 'total_loss_qty'
+ const result = await query(`
+ SELECT material_name, material_type,
+ count(DISTINCT m.summary_id) AS dish_count,
+ round(sum(loss_quantity)::numeric, 2) AS total_loss_qty,
+ round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
+ round(max(loss_quantity_rate_pct)::numeric, 2) AS max_loss_rate,
+ round(sum(loss_amount)::numeric, 2) AS total_loss_amount
+ FROM public.dish_cost_analysis_material_detail m
+ WHERE m.loss_quantity < 0
+ GROUP BY material_name, material_type
+ ORDER BY ${order} ASC LIMIT $1
+ `, [limit])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 物料类型损耗对比
+router.get('/material-type-loss', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT material_type,
+ count(*) AS cnt,
+ round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
+ round(sum(loss_amount)::numeric, 2) AS total_loss_amount,
+ count(DISTINCT material_name) AS unique_materials
+ FROM public.dish_cost_analysis_material_detail
+ GROUP BY material_type ORDER BY total_loss_amount ASC
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab4: BOM与配方 ============
+
+// BOM总览
+router.get('/bom-overview', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ (SELECT count(*) FROM analytics.dim_sku) AS total_sku,
+ (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom,
+ (SELECT count(*) FROM analytics.dim_sku) - (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_without_bom,
+ round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS coverage_pct,
+ (SELECT count(*) FROM analytics.fact_recipe_bom) AS total_bom_rows,
+ (SELECT count(*) FROM analytics.fact_recipe_bom WHERE waste_rate > 20) AS high_loss_bom,
+ (SELECT count(*) FROM analytics.fact_recipe_bom WHERE waste_rate > 100) AS extreme_loss_bom,
+ (SELECT round(avg(cnt)::numeric, 1) FROM (SELECT count(*) AS cnt FROM analytics.fact_recipe_bom GROUP BY sku_code) t) AS avg_materials_per_sku
+ `)
+ sendSuccess(res, result.rows[0])
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// BOM复杂度
+router.get('/bom-complexity', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom`)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ SELECT b.sku_code, s.standard_name AS dish_name, s.category_l1,
+ count(*) AS material_count,
+ count(*) FILTER (WHERE dm.major_category = '半成品') AS semi_finished_count,
+ count(*) FILTER (WHERE ref_count = 1) AS unique_material_count,
+ count(DISTINCT b.unit) AS unit_count,
+ count(*) FILTER (WHERE b.waste_rate > 20) AS high_loss_count,
+ count(*) FILTER (WHERE b.standard_net_quantity = 0) AS zero_actual_count,
+ CASE
+ WHEN count(*) <= 5 THEN '低复杂度'
+ WHEN count(*) <= 10 THEN '正常'
+ WHEN count(*) <= 15 THEN '较复杂'
+ ELSE '重点评审'
+ END AS complexity_level
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_sku s ON s.sku_code = b.sku_code
+ JOIN analytics.dim_material dm ON dm.material_code = b.material_code
+ LEFT JOIN (SELECT material_code, count(DISTINCT sku_code) AS ref_count FROM analytics.fact_recipe_bom GROUP BY material_code) r ON r.material_code = b.material_code
+ GROUP BY b.sku_code, s.standard_name, s.category_l1
+ ORDER BY material_count DESC LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 高损耗BOM预警
+router.get('/bom-high-loss', async (req: AuthRequest, res) => {
+ try {
+ const threshold = parseFloat((req.query.threshold as string) || '20')
+ const result = await query(`
+ SELECT b.sku_code, s.standard_name AS dish_name, dm.material_name,
+ round(b.waste_rate::numeric, 2) AS waste_rate,
+ round(b.yield_rate::numeric, 2) AS yield_rate,
+ round(b.standard_gross_quantity::numeric, 8) AS gross_qty,
+ round(b.standard_net_quantity::numeric, 8) AS net_qty,
+ b.unit,
+ CASE
+ WHEN b.waste_rate > 100 THEN '极端'
+ WHEN b.waste_rate > 50 THEN '严重'
+ ELSE '关注'
+ END AS alert_level
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_sku s ON s.sku_code = b.sku_code
+ JOIN analytics.dim_material dm ON dm.material_code = b.material_code
+ WHERE b.waste_rate >= $1
+ ORDER BY b.waste_rate DESC LIMIT 100
+ `, [threshold])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 缺BOM的SKU清单
+router.get('/bom-missing', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`
+ SELECT count(*) FROM analytics.dim_sku s
+ WHERE s.sku_code NOT IN (SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom)
+ `)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ SELECT s.sku_code, s.standard_name AS dish_name, s.category_l1,
+ COALESCE(round(d.sales_amount::numeric, 2), 0) AS sales_amount,
+ COALESCE(round(d.sales_quantity::numeric, 2), 0) AS sales_quantity
+ FROM analytics.dim_sku s
+ LEFT JOIN public.dish_cost_analysis_summary d ON d.dish_code = s.sku_code
+ WHERE s.sku_code NOT IN (SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom)
+ ORDER BY sales_amount DESC LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 菜品物料构成
+router.get('/bom-composition', async (req: AuthRequest, res) => {
+ try {
+ const skuCode = req.query.sku_code as string
+ if (!skuCode) return sendError(res, 'sku_code is required')
+
+ const result = await query(`
+ SELECT dm.material_name, dm.major_category AS material_type,
+ round(b.standard_gross_quantity::numeric, 8) AS gross_qty,
+ round(b.standard_net_quantity::numeric, 8) AS net_qty,
+ b.unit,
+ round(b.waste_rate::numeric, 2) AS waste_rate,
+ round(b.yield_rate::numeric, 2) AS yield_rate,
+ round(m.theoretical_amount::numeric, 2) AS theo_amount,
+ round(m.theoretical_amount / nullif(sum(m.theoretical_amount) OVER (PARTITION BY m.summary_id), 0) * 100, 2) AS cost_share
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_material dm ON dm.material_code = b.material_code
+ LEFT JOIN public.dish_cost_analysis_material_detail m ON m.material_name = dm.material_name
+ WHERE b.sku_code = $1
+ ORDER BY theo_amount DESC NULLS LAST
+ `, [skuCode])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab5: 供应链与精简 ============
+
+// 原料共用度
+router.get('/material-sharing', async (req: AuthRequest, res) => {
+ try {
+ const limit = parseInt((req.query.limit as string) || '20')
+ const result = await query(`
+ SELECT dm.material_name, dm.major_category,
+ count(DISTINCT b.sku_code) AS sku_count,
+ CASE
+ WHEN count(DISTINCT b.sku_code) > 50 THEN '核心原料'
+ WHEN count(DISTINCT b.sku_code) = 1 THEN '独有原料'
+ ELSE '普通共用'
+ END AS sharing_type
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_material dm ON dm.material_code = b.material_code
+ GROUP BY dm.material_name, dm.major_category
+ ORDER BY sku_count DESC LIMIT $1
+ `, [limit])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 独有原料风险
+router.get('/unique-material-risk', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`
+ SELECT count(DISTINCT b.sku_code) FROM analytics.fact_recipe_bom b
+ WHERE b.material_code IN (SELECT material_code FROM analytics.fact_recipe_bom GROUP BY material_code HAVING count(DISTINCT sku_code) = 1)
+ `)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ WITH unique_materials AS (
+ SELECT material_code FROM analytics.fact_recipe_bom GROUP BY material_code HAVING count(DISTINCT sku_code) = 1
+ )
+ SELECT b.sku_code, s.standard_name AS dish_name, s.category_l1,
+ COALESCE(round(d.sales_amount::numeric, 2), 0) AS sales_amount,
+ count(*) AS unique_material_count,
+ CASE
+ WHEN COALESCE(d.sales_amount, 0) < 5000 AND count(*) > 3 THEN '高'
+ WHEN COALESCE(d.sales_amount, 0) < 5000 THEN '中'
+ ELSE '低'
+ END AS risk_level
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_sku s ON s.sku_code = b.sku_code
+ LEFT JOIN public.dish_cost_analysis_summary d ON d.dish_code = b.sku_code
+ WHERE b.material_code IN (SELECT material_code FROM unique_materials)
+ GROUP BY b.sku_code, s.standard_name, s.category_l1, d.sales_amount
+ ORDER BY sales_amount ASC, unique_material_count DESC LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// SKU精简模拟
+router.post('/sku-simplify-simulate', async (req: AuthRequest, res) => {
+ try {
+ const { sku_codes } = req.body
+ if (!sku_codes || !Array.isArray(sku_codes) || sku_codes.length === 0) {
+ return sendError(res, 'sku_codes array is required')
+ }
+
+ const result = await query(`
+ WITH target_skus AS (SELECT unnest($1::text[]) AS sku_code)
+ SELECT
+ (SELECT round(sum(sales_amount)::numeric, 2) FROM public.dish_cost_analysis_summary WHERE dish_code IN (SELECT sku_code FROM target_skus)) AS revenue_impact,
+ (SELECT round(sum(sales_amount - actual_cost)::numeric, 2) FROM public.dish_cost_analysis_summary WHERE dish_code IN (SELECT sku_code FROM target_skus)) AS profit_impact,
+ (SELECT count(DISTINCT material_code) FROM analytics.fact_recipe_bom WHERE sku_code IN (SELECT sku_code FROM target_skus) AND material_code NOT IN (SELECT material_code FROM analytics.fact_recipe_bom WHERE sku_code NOT IN (SELECT sku_code FROM target_skus))) AS releasable_materials,
+ (SELECT count(DISTINCT material_code) FROM analytics.fact_recipe_bom WHERE sku_code IN (SELECT sku_code FROM target_skus) AND material_code IN (SELECT material_code FROM analytics.fact_recipe_bom WHERE sku_code NOT IN (SELECT sku_code FROM target_skus))) AS shared_materials,
+ (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE dish_code IN (SELECT sku_code FROM target_skus) AND actual_margin_rate_pct < 0) AS negative_margin_count
+ `, [sku_codes])
+
+ const releasable = await query(`
+ WITH target_skus AS (SELECT unnest($1::text[]) AS sku_code)
+ SELECT dm.material_name, dm.major_category,
+ round(sum(m.actual_amount)::numeric, 2) AS inventory_value
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_material dm ON dm.material_code = b.material_code
+ LEFT JOIN public.dish_cost_analysis_material_detail m ON m.material_name = dm.material_name
+ WHERE b.sku_code IN (SELECT sku_code FROM target_skus)
+ AND b.material_code NOT IN (SELECT material_code FROM analytics.fact_recipe_bom WHERE sku_code NOT IN (SELECT sku_code FROM target_skus))
+ GROUP BY dm.material_name, dm.major_category
+ ORDER BY inventory_value DESC NULLS LAST LIMIT 50
+ `, [sku_codes])
+
+ sendSuccess(res, { summary: result.rows[0], releasable_materials: releasable.rows })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// BOM驱动物料需求预测
+router.get('/material-demand', async (req: AuthRequest, res) => {
+ try {
+ const limit = parseInt((req.query.limit as string) || '50')
+ const result = await query(`
+ SELECT dm.material_name, dm.major_category, dm.base_unit,
+ round(sum(b.standard_gross_quantity * dcs.sales_quantity)::numeric, 2) AS estimated_demand,
+ count(DISTINCT b.sku_code) AS dish_count
+ FROM analytics.fact_recipe_bom b
+ JOIN analytics.dim_material dm ON dm.material_code = b.material_code
+ JOIN public.dish_cost_analysis_summary dcs ON dcs.dish_code = b.sku_code
+ GROUP BY dm.material_name, dm.major_category, dm.base_unit
+ ORDER BY estimated_demand DESC LIMIT $1
+ `, [limit])
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab6: 包装耗材 ============
+
+// 包装总览
+router.get('/packaging-overview', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ count(DISTINCT material_name) AS packaging_types,
+ round(sum(theoretical_amount)::numeric, 2) AS total_cost,
+ round(sum(loss_quantity)::numeric, 2) AS total_loss_qty,
+ count(*) FILTER (WHERE loss_quantity_rate_pct < -20) AS high_loss_count
+ FROM public.dish_cost_analysis_material_detail
+ WHERE material_name ~* '餐盒|餐具|打包|纸巾|餐盒|调味包|餐盒|碗|袋|杯|盒'
+ `)
+ sendSuccess(res, result.rows[0])
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 包装明细
+router.get('/packaging-detail', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`
+ SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail
+ WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
+ `)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ SELECT material_name,
+ count(DISTINCT summary_id) AS dish_count,
+ round(sum(theoretical_quantity)::numeric, 2) AS total_qty,
+ round(sum(theoretical_amount)::numeric, 2) AS total_cost,
+ round(sum(loss_quantity)::numeric, 2) AS loss_qty,
+ round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
+ CASE
+ WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20 THEN '核查'
+ WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -5 THEN '关注'
+ ELSE '正常'
+ END AS status
+ FROM public.dish_cost_analysis_material_detail
+ WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
+ GROUP BY material_name
+ ORDER BY total_cost DESC LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab7: 数据质量 ============
+
+// 数据质量总览
+router.get('/data-quality', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ (SELECT count(*) FROM analytics.dim_sku) AS total_sku,
+ (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom,
+ round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS bom_coverage,
+ (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = true) AS matched_dishes,
+ (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary) AS total_dishes,
+ (SELECT count(*) FROM analytics.fact_recipe_bom WHERE standard_net_quantity = 0) AS zero_actual_bom,
+ (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct < 0) AS negative_margin_count,
+ (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < 0) AS negative_theo_margin_count,
+ (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = true) AS matched_materials,
+ (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail) AS total_materials
+ `)
+ sendSuccess(res, result.rows[0])
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 未匹配菜品
+router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = false`)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(sales_amount::numeric, 2) AS sales_amount
+ FROM analytics.v_dish_cost_analysis_latest_summary
+ WHERE matched_to_dish_sales_by_name = false
+ ORDER BY sales_amount DESC NULLS LAST LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 未匹配物料
+router.get('/unmatched-materials', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = false`)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ SELECT material_name, material_type,
+ count(DISTINCT summary_id) AS dish_count,
+ round(sum(loss_amount)::numeric, 2) AS loss_amount
+ FROM analytics.v_dish_cost_analysis_latest_material_detail
+ WHERE matched_to_inventory_by_name = false
+ GROUP BY material_name, material_type
+ ORDER BY dish_count DESC LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab8: 门店成本 ============
+
+// 门店成本总览
+router.get('/store-overview', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT
+ count(*) AS total_stores,
+ count(*) FILTER (WHERE variance_level = '红色-严重超耗') AS red_count,
+ count(*) FILTER (WHERE variance_level = '橙色-明显超耗') AS orange_count,
+ count(*) FILTER (WHERE variance_level = '绿色-基本正常') AS green_count,
+ count(*) FILTER (WHERE variance_level = '灰色-口径异常') AS gray_count,
+ round(sum(food_cost_variance)::numeric, 2) AS total_variance
+ FROM analytics.v_store_theoretical_actual_cost_april
+ `)
+ sendSuccess(res, result.rows[0])
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 门店地图数据
+router.get('/store-map', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT c.store_code, c.store_name, c.variance_level,
+ round(c.food_cost_variance::numeric, 2) AS variance,
+ round(c.theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate,
+ round(c.actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate,
+ l.latitude_gcj02, l.longitude_gcj02
+ FROM analytics.v_store_theoretical_actual_cost_april c
+ LEFT JOIN analytics.v_store_location_operating l ON l.store_code = c.store_code
+ WHERE l.latitude_gcj02 IS NOT NULL
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 门店超耗排名
+router.get('/store-ranking', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const countResult = await query(`SELECT count(*) FROM analytics.v_store_theoretical_actual_cost_april`)
+ const total = countResult.rows[0].count
+
+ const result = await query(`
+ SELECT store_code, store_name,
+ round(theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate,
+ round(actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate,
+ round(variance_to_theoretical_pct::numeric, 2) AS variance_pct,
+ round(food_cost_variance::numeric, 2) AS variance_amount,
+ negative_item_lines,
+ variance_level
+ FROM analytics.v_store_theoretical_actual_cost_april
+ ORDER BY food_cost_variance DESC LIMIT $1 OFFSET $2
+ `, [pageSize, offset])
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab9: 可视化探索 ============
+
+// 散点图数据
+router.get('/scatter', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(sales_quantity::numeric, 2) AS x,
+ round(cost_variance_amount::numeric, 2) AS y,
+ round(sales_amount::numeric, 2) AS size
+ FROM public.dish_cost_analysis_summary
+ WHERE cost_variance_amount IS NOT NULL AND sales_quantity IS NOT NULL
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ Tab10: 调整管理 ============
+
+// 生成诊断快照
+router.post('/diagnosis/generate', async (req: AuthRequest, res) => {
+ try {
+ const diagDate = new Date().toISOString().slice(0, 10)
+
+ await query(`DELETE FROM public.dish_diagnosis_snapshot WHERE diagnosis_date = $1`, [diagDate])
+
+ const result = await query(`
+ INSERT INTO public.dish_diagnosis_snapshot (diagnosis_date, dish_code, dish_name, category_l1, sales_amount, sales_quantity, theoretical_margin_pct, actual_margin_pct, cost_variance_amount, cost_tier, bom_complexity_score, unique_material_count, waste_rate_avg, diagnosis_type, diagnosis_detail, suggested_action, priority)
+ SELECT
+ $1::date,
+ s.dish_code,
+ s.dish_name,
+ s.category_level1,
+ round(s.sales_amount::numeric, 2),
+ round(s.sales_quantity::numeric, 2),
+ round(s.theoretical_margin_rate_pct::numeric, 2),
+ round(s.actual_margin_rate_pct::numeric, 2),
+ round(s.cost_variance_amount::numeric, 2),
+ CASE
+ WHEN s.actual_margin_rate_pct < 0 THEN '数据异常'
+ WHEN s.cost_variance_amount > 0 AND s.theoretical_cost > 0 AND (s.cost_variance_amount / s.theoretical_cost) > 0.2 THEN '紧急'
+ WHEN s.cost_variance_amount > 0 AND s.theoretical_cost > 0 AND (s.cost_variance_amount / s.theoretical_cost) > 0.1 THEN '整改'
+ WHEN s.cost_variance_amount > 0 THEN '关注'
+ ELSE '正常'
+ END,
+ COALESCE(bom.cnt, 0),
+ COALESCE(bom.unique_cnt, 0),
+ COALESCE(round(bom.avg_waste::numeric, 2), 0),
+ CASE
+ WHEN s.actual_margin_rate_pct < 0 THEN '数据异常'
+ WHEN s.theoretical_margin_rate_pct < 0 THEN '负毛利'
+ WHEN s.theoretical_margin_rate_pct < 30 AND s.actual_margin_rate_pct > s.theoretical_margin_rate_pct THEN '低毛利-定价偏低'
+ WHEN s.theoretical_margin_rate_pct < 50 AND s.actual_margin_rate_pct < s.theoretical_margin_rate_pct THEN '低毛利-成本超耗'
+ WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 20 THEN '高超耗-份量超标'
+ WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 100 THEN '高超耗-分摊异常'
+ WHEN COALESCE(bom.cnt, 0) > 15 AND s.sales_quantity < 5 THEN '配方复杂-低销量'
+ WHEN COALESCE(bom.unique_cnt, 0) > 3 AND s.sales_amount < 5000 THEN '独有原料风险'
+ WHEN s.cost_variance_amount > 0 THEN '成本差异'
+ ELSE '正常'
+ END,
+ CASE
+ WHEN s.actual_margin_rate_pct < 0 THEN '实际毛利率为负,需先核查BOM/单位/分摊'
+ WHEN s.theoretical_margin_rate_pct < 0 THEN '理论毛利率为负,定价低于标准成本'
+ WHEN s.theoretical_margin_rate_pct < 30 THEN '理论毛利率低于30%,定价偏低'
+ WHEN s.theoretical_margin_rate_pct < 50 AND s.actual_margin_rate_pct < s.theoretical_margin_rate_pct THEN '实际成本超理论,存在超耗'
+ WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 20 THEN '物料损耗率超过20%'
+ ELSE '成本基本正常'
+ END,
+ CASE
+ WHEN s.actual_margin_rate_pct < 0 THEN 'fix_data'
+ WHEN s.theoretical_margin_rate_pct < 0 THEN 'price_up'
+ WHEN s.theoretical_margin_rate_pct < 30 AND s.actual_margin_rate_pct > s.theoretical_margin_rate_pct THEN 'price_up'
+ WHEN s.theoretical_margin_rate_pct < 50 AND s.actual_margin_rate_pct < s.theoretical_margin_rate_pct THEN 'recipe_optimize'
+ WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 20 THEN 'portion_reduce'
+ WHEN COALESCE(bom.cnt, 0) > 15 AND s.sales_quantity < 5 THEN 'delist'
+ WHEN COALESCE(bom.unique_cnt, 0) > 3 AND s.sales_amount < 5000 THEN 'evaluate_delist'
+ WHEN s.cost_variance_amount > 0 THEN 'monitor'
+ ELSE 'keep'
+ END,
+ CASE
+ WHEN s.actual_margin_rate_pct < 0 THEN 'P0'
+ WHEN s.theoretical_margin_rate_pct < 0 THEN 'P0'
+ WHEN s.cost_variance_amount > 0 AND s.theoretical_cost > 0 AND (s.cost_variance_amount / s.theoretical_cost) > 0.2 THEN 'P0'
+ WHEN s.theoretical_margin_rate_pct < 50 THEN 'P1'
+ WHEN COALESCE(bom.cnt, 0) > 15 AND s.sales_quantity < 5 THEN 'P2'
+ WHEN COALESCE(bom.unique_cnt, 0) > 3 AND s.sales_amount < 5000 THEN 'P2'
+ ELSE 'P3'
+ END
+ FROM public.dish_cost_analysis_summary s
+ LEFT JOIN (
+ SELECT b.sku_code,
+ count(*) AS cnt,
+ count(*) FILTER (WHERE r.ref_count = 1) AS unique_cnt,
+ sum(b.waste_rate * b.standard_gross_quantity) / nullif(sum(b.standard_gross_quantity), 0) AS avg_waste
+ FROM analytics.fact_recipe_bom b
+ LEFT JOIN (SELECT material_code, count(DISTINCT sku_code) AS ref_count FROM analytics.fact_recipe_bom GROUP BY material_code) r ON r.material_code = b.material_code
+ GROUP BY b.sku_code
+ ) bom ON bom.sku_code = s.dish_code
+ WHERE s.dish_code IS NOT NULL
+ `, [diagDate])
+
+ const countResult = await query(`SELECT count(*) FROM public.dish_diagnosis_snapshot WHERE diagnosis_date = $1`, [diagDate])
+ sendSuccess(res, { generated: countResult.rows[0].count, date: diagDate })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 获取诊断建议列表
+router.get('/diagnosis', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const priority = req.query.priority as string
+
+ let where = 'WHERE 1=1'
+ const params: any[] = []
+ if (priority) { params.push(priority); where += ` AND priority = $${params.length}` }
+
+ const countResult = await query(`SELECT count(*) FROM public.dish_diagnosis_snapshot ${where}`, params)
+ const total = countResult.rows[0].count
+
+ params.push(pageSize, offset)
+ const result = await query(`
+ SELECT * FROM public.dish_diagnosis_snapshot ${where}
+ ORDER BY CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END, cost_variance_amount DESC NULLS LAST
+ LIMIT $${params.length - 1} OFFSET $${params.length}
+ `, params)
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 获取调整记录列表
+router.get('/adjustment', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const status = req.query.status as string
+
+ let where = 'WHERE 1=1'
+ const params: any[] = []
+ if (status) { params.push(status); where += ` AND status = $${params.length}` }
+
+ const countResult = await query(`SELECT count(*) FROM public.dish_adjustment_log ${where}`, params)
+ const total = countResult.rows[0].count
+
+ params.push(pageSize, offset)
+ const result = await query(`
+ SELECT * FROM public.dish_adjustment_log ${where}
+ ORDER BY effective_date DESC, created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}
+ `, params)
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 创建调整记录
+router.post('/adjustment', async (req: AuthRequest, res) => {
+ try {
+ const { dish_code, dish_name, adjustment_type, before_price, before_theoretical_cost, before_theoretical_margin, before_sales_avg_daily, before_cost_variance, after_price, after_theoretical_cost, after_target_margin, target_cost_reduction, effective_date, decided_by, reason, diagnosis_id } = req.body
+
+ if (!dish_code || !dish_name || !adjustment_type || !effective_date) {
+ return sendError(res, 'dish_code, dish_name, adjustment_type, effective_date are required')
+ }
+
+ const result = await query(`
+ INSERT INTO public.dish_adjustment_log (dish_code, dish_name, adjustment_type, before_price, before_theoretical_cost, before_theoretical_margin, before_sales_avg_daily, before_cost_variance, after_price, after_theoretical_cost, after_target_margin, target_cost_reduction, effective_date, decided_by, reason, diagnosis_id, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'planned')
+ RETURNING id
+ `, [dish_code, dish_name, adjustment_type, before_price, before_theoretical_cost, before_theoretical_margin, before_sales_avg_daily, before_cost_variance, after_price, after_theoretical_cost, after_target_margin, target_cost_reduction, effective_date, decided_by, reason, diagnosis_id])
+
+ sendSuccess(res, { id: result.rows[0].id })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 更新调整状态
+router.put('/adjustment/:id', async (req: AuthRequest, res) => {
+ try {
+ const id = parseInt(req.params.id)
+ const { status } = req.body
+ const result = await query(`
+ UPDATE public.dish_adjustment_log SET status = $1, updated_at = now() WHERE id = $2 RETURNING id
+ `, [status, id])
+ if (result.rowCount === 0) return sendError(res, 'Adjustment not found', 404)
+ sendSuccess(res, { id: result.rows[0].id })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 获取验证数据(销量对比)
+router.get('/adjustment/:id/verify', async (req: AuthRequest, res) => {
+ try {
+ const id = parseInt(req.params.id)
+ const adjResult = await query(`SELECT * FROM public.dish_adjustment_log WHERE id = $1`, [id])
+ if (adjResult.rowCount === 0) return sendError(res, 'Adjustment not found', 404)
+ const adj = adjResult.rows[0]
+
+ const effectiveDate = adj.effective_date
+ const dishName = adj.dish_name
+
+ const beforeData = await query(`
+ SELECT (closed_at)::date AS day, count(*) AS bills, round(sum(gross_amount)::numeric, 2) AS sales
+ FROM public.dish_sales_details
+ WHERE dish_name = $1 AND closed_at >= ($2::date - interval '7 days') AND closed_at < $2::date
+ GROUP BY day ORDER BY day
+ `, [dishName, effectiveDate])
+
+ const afterData = await query(`
+ SELECT (closed_at)::date AS day, count(*) AS bills, round(sum(gross_amount)::numeric, 2) AS sales
+ FROM public.dish_sales_details
+ WHERE dish_name = $1 AND closed_at >= $2::date AND closed_at < ($2::date + interval '7 days')
+ GROUP BY day ORDER BY day
+ `, [dishName, effectiveDate])
+
+ const beforeAvg = beforeData.rows.length > 0 ? beforeData.rows.reduce((s, r) => s + Number(r.bills), 0) / beforeData.rows.length : 0
+ const afterAvg = afterData.rows.length > 0 ? afterData.rows.reduce((s, r) => s + Number(r.bills), 0) / afterData.rows.length : 0
+ const changePct = beforeAvg > 0 ? ((afterAvg - beforeAvg) / beforeAvg * 100) : null
+
+ sendSuccess(res, {
+ adjustment: adj,
+ before: beforeData.rows,
+ after: afterData.rows,
+ before_avg_daily: Math.round(beforeAvg * 100) / 100,
+ after_avg_daily: Math.round(afterAvg * 100) / 100,
+ sales_change_pct: changePct !== null ? Math.round(changePct * 100) / 100 : null,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// 提交验证结果
+router.post('/adjustment/:id/result', async (req: AuthRequest, res) => {
+ try {
+ const id = parseInt(req.params.id)
+ const { period_type, period_start, period_end, actual_sales_avg_daily, actual_sales_change_pct, actual_margin, actual_margin_change, actual_cost_variance, actual_cost_change_pct, target_achieved, assessment, notes } = req.body
+
+ const result = await query(`
+ INSERT INTO public.dish_adjustment_result (adjustment_id, period_type, period_start, period_end, actual_sales_avg_daily, actual_sales_change_pct, actual_margin, actual_margin_change, actual_cost_variance, actual_cost_change_pct, target_achieved, assessment, notes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ RETURNING id
+ `, [id, period_type, period_start, period_end, actual_sales_avg_daily, actual_sales_change_pct, actual_margin, actual_margin_change, actual_cost_variance, actual_cost_change_pct, target_achieved, assessment, notes])
+
+ sendSuccess(res, { id: result.rows[0].id })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+export default router
diff --git a/菜品成本分析前端方案.md b/菜品成本分析前端方案.md
new file mode 100644
index 0000000..af31e38
--- /dev/null
+++ b/菜品成本分析前端方案.md
@@ -0,0 +1,525 @@
+# 菜品成本分析前端展现方案
+
+> 基于现有架构:侧边栏分组(Layout.tsx) + 路由(App.tsx) + 页面组件(pages/) + 折叠面板(CollapsibleSection) + 数据表格(DataTable) + 图表(recharts)
+
+---
+
+## 整体架构
+
+### 新增页面路由
+
+在 `App.tsx` 中新增1个页面路由,在 `Layout.tsx` 中新增1个菜单项:
+
+```
+/cost-analysis → CostAnalysisPage → "菜品成本分析" → 业务模块组
+```
+
+### 页面结构
+
+采用**单页面 + Tab切换**的方式,避免新增过多路由。页面顶部为Tab栏,每个Tab对应一个分析维度:
+
+```
+菜品成本分析
+├── Tab: 成本总览 → 模块1 + 模块10 + 模块25
+├── Tab: 菜品盈利分析 → 模块3 + 模块12
+├── Tab: 原料差异分析 → 模块2 + 模块11
+├── Tab: BOM与配方 → 模块4 + 模块23 + 模块20 + 模块21 + 模块22
+├── Tab: 供应链与精简 → 模块5 + 模块6 + 模块7 + 模块13
+├── Tab: 包装耗材 → 模块8
+├── Tab: 数据质量 → 模块9 + 模块14
+├── Tab: 门店成本 → 模块15 + 模块16 + 模块17 + 模块26
+└── Tab: 可视化探索 → 模块24 + 散点图 + 热力图
+```
+
+---
+
+## 各Tab详细设计
+
+### Tab 1: 成本总览
+
+**包含模块**:模块1(SKU理论与实际成本)、模块10(品类成本对比)、模块25(毛利率偏差分布)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 成本总览 │
+├─────────────────────────────────────────────────────────┤
+│ [MetricCard] 理论成本 [MetricCard] 实际成本 │
+│ [MetricCard] 成本差异 [MetricCard] 差异率 │
+│ [MetricCard] 平均理论毛利率 [MetricCard] 平均实际毛利率 │
+│ [MetricCard] 超理论菜品数 [MetricCard] 低毛利菜品数 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 品类成本对比(柱状图 + 表格) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ BarChart: X轴=品类, Y轴=毛利率 │ │
+│ │ 双柱: 理论毛利率 vs 实际毛利率 │ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: 品类 | 菜品数 | 理论毛利率 | 实际毛利率 │
+│ | 销售额 | 成本差异 | 成本率 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 毛利率偏差分布(饼图 + 表格) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ PieChart: 严重偏低/偏低/略低/略高/高于理论 │ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: 偏差区间 | 菜品数 | 占比 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 成本差异TOP榜(表格,默认展开) │
+│ DataTable: 菜品 | 品类 | 理论毛利率 | 实际毛利率 │
+│ | 成本差异 | 成本分层 | 销售额 │
+│ 成本分层用 Badge 着色: 正常(绿) / 关注(黄) / 整改(橙) │
+│ / 紧急(红) / 数据异常(灰) │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/overview` → 总览指标
+- `GET /api/cost-analysis/category-comparison` → 品类对比
+- `GET /api/cost-analysis/margin-deviation` → 偏差分布
+- `GET /api/cost-analysis/variance-top?limit=50&order=desc` → 成本差异TOP
+
+---
+
+### Tab 2: 菜品盈利分析
+
+**包含模块**:模块3(菜品盈利与菜单工程)、模块12(菜品定价合理性)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 菜品盈利分析 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 菜单工程矩阵(四象限图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ ScatterChart: │ │
+│ │ X轴=销量, Y轴=实际毛利率 │ │
+│ │ 气泡大小=销售额, 颜色=品类 │ │
+│ │ 四象限: 明星/高销低利/低销高利/低销低利 │ │
+│ │ 点击气泡 → 弹出菜品详情 │ │
+│ └──────────────────────────────────────────┘ │
+│ 图例: 🟢明星盈利 🟡高销低利 🔵低销高利 🔴低销低利 │
+│ ⚪数据异常 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 菜品盈利明细(表格) │
+│ DataTable: 菜品 | 品类 | 销量 | 销售额 | 理论毛利 │
+│ | 实际毛利 | 毛利贡献度 | 菜单类型(Badge) │
+│ 支持排序: 按销售额/毛利/毛利率 │
+│ 支持筛选: 品类下拉 / 菜单类型下拉 │
+│ Pagination │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 定价合理性(毛利率分布图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ BarChart: X轴=毛利率区间, Y轴=菜品数 │ │
+│ │ 区间: <0% / 0-30% / 30-50% / 50-70% │ │
+│ │ / 70-90% / >90% │ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: 低毛利菜品清单(理论<50%且销售额>1000) │
+│ 菜品 | 售价 | 理论成本 | 理论毛利率 | 实际毛利率 │
+│ | 定价偏离度 | 建议 │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/menu-engineering` → 菜单工程矩阵数据
+- `GET /api/cost-analysis/profitability?page=1&category=&type=` → 盈利明细
+- `GET /api/cost-analysis/pricing?threshold=50` → 定价合理性
+
+---
+
+### Tab 3: 原料差异分析
+
+**包含模块**:模块2(SKU原料差异贡献)、模块11(物料损耗率分布)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 原料差异分析 │
+├─────────────────────────────────────────────────────────┤
+│ [搜索框] 输入菜品名称 → 查看该菜品的原料差异分解 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 菜品原料差异分解(选中菜品后展示) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ 水平BarChart: 原料名 vs 金额差异 │ │
+│ │ 红色=超耗, 绿色=节约 │ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: 原料 | 理论用量 | 实际用量 | 数量差异 │
+│ | 损耗率 | 金额差异 | 差异贡献度 | 原因分类 │
+│ 原因分类(Badge): 份量超标/替代原料/分摊遗漏/门店错位 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 物料损耗率分布(饼图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ PieChart: 无损耗/轻微/轻度/中度/严重/极重/正向│ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: 损耗区间 | 明细数 | 占比 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 超耗物料TOP榜(表格) │
+│ DataTable: 物料 | 类型(原料/半成品) | 涉及菜品数 │
+│ | 总损耗量 | 平均损耗率 | 最大损耗率 │
+│ 支持排序: 按损耗量/损耗率 │
+│ Pagination │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 物料类型损耗对比(柱状图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ BarChart: 原材料 vs 半成品 │ │
+│ │ 双柱: 平均损耗率 | 涉及菜品数 │ │
+│ └──────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/material-variance?dish_code=66920` → 菜品原料差异分解
+- `GET /api/cost-analysis/loss-distribution` → 损耗率分布
+- `GET /api/cost-analysis/material-loss-top?limit=50&order=desc` → 超耗物料TOP
+- `GET /api/cost-analysis/material-type-loss` → 物料类型损耗对比
+
+---
+
+### Tab 4: BOM与配方
+
+**包含模块**:模块4(BOM复杂度)、模块23(菜品物料构成)、模块20(BOM覆盖率)、模块21(适用门店覆盖)、模块22(高损耗BOM预警)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ BOM与配方 │
+├─────────────────────────────────────────────────────────┤
+│ [MetricCard] BOM覆盖率 [MetricCard] 有BOM的SKU数 │
+│ [MetricCard] 缺BOM的SKU [MetricCard] 平均物料数/SKU │
+│ [MetricCard] 高损耗BOM数 [MetricCard] 极端损耗BOM数 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] BOM复杂度分布(柱状图 + 表格) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ BarChart: X轴=物料数区间, Y轴=SKU数 │ │
+│ │ ≤5 / 6-10 / 11-15 / 16-20 / >20 │ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: SKU编码 | 菜品名 | 品类 | 原料数 │
+│ | 半成品数 | 独有原料数 | 单位数 │
+│ | 高损耗原料数 | 复杂度评分 | 复杂度等级 │
+│ 复杂度等级(Badge): 低(绿) / 正常(蓝) / 较复杂(橙) │
+│ / 重点评审(红) │
+│ Pagination │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 高损耗BOM预警(表格,默认展开) │
+│ DataTable: 菜品 | 物料 | 损耗率 | 出成率 | 标准毛用量 │
+│ | 标准净用量 | 单位 | 预警级别(Badge) │
+│ 预警级别: >20%(黄) / >50%(橙) / >100%(红) │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 缺BOM的SKU清单(表格) │
+│ DataTable: SKU编码 | 菜品名 | 品类 | 销售额 | 销量 │
+│ 按销售额降序,优先补齐高销量SKU的BOM │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 菜品物料构成(点击菜品展开详情) │
+│ 选中菜品后展示: │
+│ DataTable: 物料 | 类型 | 标准用量 | 标准金额 | 成本占比 │
+│ | 损耗率 | 出成率 │
+│ 饼图: 物料成本占比 │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/bom-overview` → BOM总览指标
+- `GET /api/cost-analysis/bom-complexity?page=1` → 复杂度分布
+- `GET /api/cost-analysis/bom-high-loss?threshold=20` → 高损耗BOM预警
+- `GET /api/cost-analysis/bom-missing?page=1` → 缺BOM的SKU清单
+- `GET /api/cost-analysis/bom-composition?sku_code=66920` → 菜品物料构成
+
+---
+
+### Tab 5: 供应链与精简
+
+**包含模块**:模块5(独有原料风险)、模块6(SKU精简供应链影响)、模块7(原料共用度)、模块13(BOM驱动物料需求预测)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 供应链与精简 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 原料共用度网络(表格 + 图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ 水平BarChart: TOP20物料被引用次数 │ │
+│ │ 核心原料(>50个SKU)标红, 独有原料(=1)标灰 │ │
+│ └──────────────────────────────────────────┘ │
+│ DataTable: 物料 | 类型 | 被引用SKU数 | 分类 │
+│ 核心原料(Badge红) / 普通共用(蓝) / 独有(灰) │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 独有原料风险(表格) │
+│ DataTable: SKU | 菜品名 | 销售额 | 独有原料数 │
+│ | 独有原料成本 | 替代品 | 风险等级(Badge) │
+│ 风险等级: 高(低收入+多独有原料,红) / 中(橙) / 低(绿) │
+│ 按销售额升序(低收入优先),独有原料数降序 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] SKU精简模拟器(交互式) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ [下拉] 选择要下架的SKU │ │
+│ │ 或 [输入] 批量输入SKU编码 │ │
+│ │ [按钮] 模拟下架影响 │ │
+│ └──────────────────────────────────────────┘ │
+│ 模拟结果: │
+│ [MetricCard] 收入影响 [MetricCard] 毛利影响 │
+│ [MetricCard] 可减少原料数 [MetricCard] 独有库存金额 │
+│ [MetricCard] 仍需采购的共用原料 [MetricCard] 报损风险 │
+│ DataTable: 可释放的独有原料清单 │
+│ 物料 | 涉及菜品数(=1) | 库存金额 | 处置建议 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] BOM驱动物料需求预测(表格) │
+│ DataTable: 物料 | 类型 | 单位 | 预计需求量 │
+│ | 涉及菜品数 | 需求排名 │
+│ 按需求量降序,TOP50 │
+│ Pagination │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/material-sharing?limit=20` → 原料共用度
+- `GET /api/cost-analysis/unique-material-risk?page=1` → 独有原料风险
+- `POST /api/cost-analysis/sku-simplify-simulate` → SKU精简模拟(传入SKU编码列表)
+- `GET /api/cost-analysis/material-demand?limit=50` → 物料需求预测
+
+---
+
+### Tab 6: 包装耗材
+
+**包含模块**:模块8(包装及耗材成本分析)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 包装耗材成本 │
+├─────────────────────────────────────────────────────────┤
+│ [MetricCard] 包装物料种类 [MetricCard] 包装总成本 │
+│ [MetricCard] 包装费用率 [MetricCard] 高损耗包装数 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 包装物料明细(表格) │
+│ DataTable: 物料 | 涉及菜品数 | 每单用量 | 每单成本 │
+│ | 费用率 | 损耗量 | 损耗率 | 状态(Badge) │
+│ 状态: 正常(绿) / 关注(黄) / 核查(红) │
+│ 支持筛选: 物料类型(餐盒/餐具/打包袋/调味包/其他) │
+│ Pagination │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 包装损耗TOP榜(柱状图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ 水平BarChart: 物料名 vs 损耗量 │ │
+│ └──────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/packaging-overview` → 包装总览
+- `GET /api/cost-analysis/packaging-detail?page=1&type=` → 包装明细
+- `GET /api/cost-analysis/packaging-loss-top?limit=15` → 包装损耗TOP
+
+---
+
+### Tab 7: 数据质量
+
+**包含模块**:模块9(BOM数据质量)、模块14(物料匹配率)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 数据质量 │
+├─────────────────────────────────────────────────────────┤
+│ [MetricCard] BOM覆盖率 [MetricCard] 菜品匹配率 │
+│ [MetricCard] 物料匹配率 [MetricCard] 异常菜品数 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] BOM数据质量检查清单(表格) │
+│ DataTable: 检查项 | 异常数 | 异常率 | 状态(Badge) │
+│ 检查项: │
+│ - BOM缺失(168个SKU无BOM) │
+│ - 零实际用量(standard_net_quantity=0) │
+│ - 成本率异常(实际毛利率<0) │
+│ - 理论毛利率为负 │
+│ - 负数量记录 │
+│ - 明细与汇总不一致 │
+│ - 重复BOM记录 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 未匹配菜品清单(表格) │
+│ DataTable: 菜品名 | 品类 | 销售额 | 匹配状态 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 未匹配物料清单(表格) │
+│ DataTable: 物料名 | 涉及菜品数 | 损耗金额 | 匹配状态 │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/data-quality` → 数据质量总览
+- `GET /api/cost-analysis/unmatched-dishes?page=1` → 未匹配菜品
+- `GET /api/cost-analysis/unmatched-materials?page=1` → 未匹配物料
+
+---
+
+### Tab 8: 门店成本
+
+**包含模块**:模块15(门店成本差异分布)、模块16(门店超耗排名)、模块17(理论vs实际成本率)、模块26(门店成本分级地图)
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 门店成本 │
+├─────────────────────────────────────────────────────────┤
+│ [MetricCard] 门店总数 [MetricCard] 红色门店数 │
+│ [MetricCard] 橙色门店数 [MetricCard] 绿色门店数 │
+│ [MetricCard] 灰色门店数 [MetricCard] 总超耗金额 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 门店成本分级地图 │
+│ ┌──────────────────────────────────────────┐ │
+│ │ 地图组件(复用StoreMap或现有地图) │ │
+│ │ 标注: 红/橙/绿/灰 圆点 │ │
+│ │ 点击 → 弹出门店成本详情 │ │
+│ └──────────────────────────────────────────┘ │
+│ 图例: 🔴严重超耗 🟠明显超耗 🟢基本正常 ⚪口径异常 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 门店成本差异分布(饼图) │
+│ ┌──────────────────────────────────────────┐ │
+│ │ PieChart: 红35 / 橙40 / 绿8 / 灰6 │ │
+│ └──────────────────────────────────────────┘ │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 门店超耗排名(表格,默认展开) │
+│ DataTable: 门店 | 理论成本率 | 实际成本率 | 偏差 │
+│ | 超耗金额 | 负库存项数 | 分级(Badge) │
+│ 分级(Badge): 红色-严重超耗(红) / 橙色-明显超耗(橙) │
+│ / 绿色-基本正常(绿) / 灰色-口径异常(灰) │
+│ 支持排序: 按超耗金额/偏差率 │
+│ Pagination │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/store-overview` → 门店成本总览
+- `GET /api/cost-analysis/store-map` → 门店地图数据(含经纬度+分级)
+- `GET /api/cost-analysis/store-ranking?page=1&order=desc` → 门店超耗排名
+
+---
+
+### Tab 9: 可视化探索
+
+**包含模块**:模块24(成本差异vs销量散点图)+ 自由探索
+
+**布局**:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 可视化探索 │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 成本差异 vs 销量散点图 │
+│ ┌──────────────────────────────────────────┐ │
+│ │ ScatterChart: │ │
+│ │ X轴=销量, Y轴=成本差异额 │ │
+│ │ 气泡大小=销售额, 颜色=品类 │ │
+│ │ 四象限标注: │ │
+│ │ 右上=高销量高差异(优先整改) │ │
+│ │ 右下=高销量低差异(保持) │ │
+│ │ 左上=低销量高差异(考虑下架) │ │
+│ │ 左下=低销量低差异(观察) │ │
+│ │ 点击气泡 → 弹出菜品详情 │ │
+│ └──────────────────────────────────────────┘ │
+├─────────────────────────────────────────────────────────┤
+│ [CollapsibleSection] 品类成本热力图 │
+│ ┌──────────────────────────────────────────┐ │
+│ │ 热力图: 行=品类, 列=成本指标 │ │
+│ │ 指标: 理论毛利率/实际毛利率/差异率/成本率 │ │
+│ │ 颜色: 红=差, 绿=好 │ │
+│ └──────────────────────────────────────────┘ │
+├─────────────────────────────────────────────────────────┤
+│ [筛选器] │
+│ 品类下拉 | 毛利率范围滑块 | 销量范围滑块 │
+│ [按钮] 应用筛选 → 更新散点图 │
+└─────────────────────────────────────────────────────────┘
+```
+
+**API端点**:
+- `GET /api/cost-analysis/scatter?x=sales_quantity&y=cost_variance&size=sales_amount&color=category_l1` → 散点图数据
+- `GET /api/cost-analysis/category-heatmap` → 品类热力图
+
+---
+
+## 组件复用清单
+
+| 组件 | 用途 | 已有 |
+|------|------|------|
+| `CollapsibleSection` | 折叠面板 | ✅ |
+| `MetricCard` | 指标卡片 | ✅ |
+| `DataTable` | 数据表格 | ✅ |
+| `Pagination` | 分页 | ✅ |
+| `Badge` | 状态标签 | ✅ |
+| `LoadingSpinner` | 加载中 | ✅ |
+| `BarChart` | 柱状图 | ✅ recharts |
+| `PieChart` | 饼图 | ✅ recharts |
+| `ScatterChart` | 散点图 | ✅ recharts |
+| `StoreMap` | 地图 | ✅ 已有 |
+| Tab切换组件 | Tab栏 | ❌ 需新建 |
+| 搜索框组件 | 菜品搜索 | ❌ 用input实现 |
+| 筛选器组件 | 下拉/滑块 | ❌ 用select/input实现 |
+
+## 新增文件清单
+
+| 文件 | 说明 |
+|------|------|
+| `client/src/pages/CostAnalysisPage.tsx` | 主页面(Tab容器) |
+| `client/src/components/Tabs.tsx` | Tab切换组件 |
+| `client/src/components/cost-analysis/OverviewTab.tsx` | Tab1: 成本总览 |
+| `client/src/components/cost-analysis/ProfitabilityTab.tsx` | Tab2: 菜品盈利 |
+| `client/src/components/cost-analysis/MaterialTab.tsx` | Tab3: 原料差异 |
+| `client/src/components/cost-analysis/BomTab.tsx` | Tab4: BOM与配方 |
+| `client/src/components/cost-analysis/SupplyTab.tsx` | Tab5: 供应链精简 |
+| `client/src/components/cost-analysis/PackagingTab.tsx` | Tab6: 包装耗材 |
+| `client/src/components/cost-analysis/QualityTab.tsx` | Tab7: 数据质量 |
+| `client/src/components/cost-analysis/StoreTab.tsx` | Tab8: 门店成本 |
+| `client/src/components/cost-analysis/ExploreTab.tsx` | Tab9: 可视化探索 |
+| `server/src/routes/cost-analysis.ts` | API路由(所有端点) |
+
+## API端点汇总
+
+| 端点 | Tab | 说明 |
+|------|-----|------|
+| `GET /api/cost-analysis/overview` | 1 | 成本总览指标 |
+| `GET /api/cost-analysis/category-comparison` | 1 | 品类成本对比 |
+| `GET /api/cost-analysis/margin-deviation` | 1 | 毛利率偏差分布 |
+| `GET /api/cost-analysis/variance-top` | 1 | 成本差异TOP榜 |
+| `GET /api/cost-analysis/menu-engineering` | 2 | 菜单工程矩阵 |
+| `GET /api/cost-analysis/profitability` | 2 | 菜品盈利明细 |
+| `GET /api/cost-analysis/pricing` | 2 | 定价合理性 |
+| `GET /api/cost-analysis/material-variance` | 3 | 菜品原料差异分解 |
+| `GET /api/cost-analysis/loss-distribution` | 3 | 损耗率分布 |
+| `GET /api/cost-analysis/material-loss-top` | 3 | 超耗物料TOP |
+| `GET /api/cost-analysis/material-type-loss` | 3 | 物料类型损耗对比 |
+| `GET /api/cost-analysis/bom-overview` | 4 | BOM总览 |
+| `GET /api/cost-analysis/bom-complexity` | 4 | BOM复杂度 |
+| `GET /api/cost-analysis/bom-high-loss` | 4 | 高损耗BOM预警 |
+| `GET /api/cost-analysis/bom-missing` | 4 | 缺BOM的SKU |
+| `GET /api/cost-analysis/bom-composition` | 4 | 菜品物料构成 |
+| `GET /api/cost-analysis/material-sharing` | 5 | 原料共用度 |
+| `GET /api/cost-analysis/unique-material-risk` | 5 | 独有原料风险 |
+| `POST /api/cost-analysis/sku-simplify-simulate` | 5 | SKU精简模拟 |
+| `GET /api/cost-analysis/material-demand` | 5 | 物料需求预测 |
+| `GET /api/cost-analysis/packaging-overview` | 6 | 包装总览 |
+| `GET /api/cost-analysis/packaging-detail` | 6 | 包装明细 |
+| `GET /api/cost-analysis/packaging-loss-top` | 6 | 包装损耗TOP |
+| `GET /api/cost-analysis/data-quality` | 7 | 数据质量总览 |
+| `GET /api/cost-analysis/unmatched-dishes` | 7 | 未匹配菜品 |
+| `GET /api/cost-analysis/unmatched-materials` | 7 | 未匹配物料 |
+| `GET /api/cost-analysis/store-overview` | 8 | 门店成本总览 |
+| `GET /api/cost-analysis/store-map` | 8 | 门店地图数据 |
+| `GET /api/cost-analysis/store-ranking` | 8 | 门店超耗排名 |
+| `GET /api/cost-analysis/scatter` | 9 | 散点图数据 |
+| `GET /api/cost-analysis/category-heatmap` | 9 | 品类热力图 |
+
+共 **31个API端点**,覆盖27个分析模块。
+
+## 落地顺序
+
+与指标体系的4批对应:
+
+| 批次 | Tab | 优先级 | API数 |
+|------|-----|--------|-------|
+| 第一批 | Tab1 + Tab2 + Tab3 | 最高 | 11 |
+| 第二批 | Tab4 + Tab6 + Tab7 | 高 | 10 |
+| 第三批 | Tab8 | 中 | 3 |
+| 第四批 | Tab5 + Tab9 | 低 | 7 |
diff --git a/菜品成本分析报告.md b/菜品成本分析报告.md
new file mode 100644
index 0000000..b5ee99c
--- /dev/null
+++ b/菜品成本分析报告.md
@@ -0,0 +1,173 @@
+# 菜品成本分析报告
+
+> 数据来源:`dish_cost_analysis_summary`(788道菜品) + `dish_cost_analysis_material_detail`(3568条物料明细)
+> 报表期间:菜品成本分析报表.xlsx(单次导入,无门店维度拆分)
+
+---
+
+## 一、总体概览
+
+| 指标 | 数值 |
+|------|------|
+| 菜品总数 | 788 |
+| 物料明细总数 | 3,568 |
+| 总销售金额 | ¥54,323,594.48 |
+| 理论成本合计 | ¥16,077,398.19 |
+| 实际成本合计 | ¥16,547,027.90 |
+| 成本差异合计 | +¥469,629.70(实际超理论) |
+| 平均理论毛利率 | 61.54% |
+| 平均实际毛利率 | 56.48% |
+| 低毛利菜品数(<50%) | 95道 |
+| 实际超理论成本菜品数 | 236道(30%) |
+
+**关键发现**:实际成本比理论成本高出约47万元,30%的菜品存在实际超耗问题,平均实际毛利率比理论低5个百分点。
+
+---
+
+## 二、品类成本对比
+
+按一级分类汇总,按销售额降序:
+
+| 品类 | 菜品数 | 理论毛利率 | 实际毛利率 | 销售额 | 理论成本 | 实际成本 | 成本差异 |
+|------|--------|-----------|-----------|--------|---------|---------|---------|
+| 兰州牛肉面 | 52 | 18.96% | 26.24% | ¥20,593,407.82 | ¥5,227,478.92 | ¥5,403,298.21 | +¥175,819.28 |
+| 西部主食 | 24 | 59.38% | 50.02% | ¥6,940,187.86 | ¥2,538,500.93 | ¥2,523,826.52 | -¥14,674.41 |
+| 夜市烧烤 | 42 | 63.03% | 66.12% | ¥6,599,273.91 | ¥2,416,450.70 | ¥2,732,932.25 | +¥316,481.55 |
+| 爽口凉菜 | 98 | 78.16% | 73.26% | ¥5,828,071.98 | ¥1,429,227.22 | ¥1,593,143.87 | +¥163,916.65 |
+| 丝路美食 | 176 | 70.33% | 78.00% | ¥4,848,750.57 | ¥1,481,242.04 | ¥1,430,441.06 | -¥50,800.98 |
+| 特色小吃 | 118 | 48.42% | 51.21% | ¥2,915,821.68 | ¥1,113,187.40 | ¥1,194,082.70 | +¥80,895.31 |
+| 早点类 | 55 | 65.22% | 58.98% | ¥2,260,167.97 | ¥561,421.53 | ¥626,134.72 | +¥64,713.18 |
+| 丝路茶饮 | 18 | 75.22% | 78.69% | ¥1,392,754.29 | ¥350,592.43 | ¥265,773.45 | -¥84,818.98 |
+| 酒水饮料 | 44 | 56.05% | 65.26% | ¥1,016,903.79 | ¥383,080.18 | ¥344,015.45 | -¥39,064.73 |
+| 其他 | 28 | 42.77% | -151.20% | ¥865,855.77 | ¥157,754.43 | ¥665,531.86 | -¥91,222.57 |
+
+**关键发现**:
+- **兰州牛肉面**是核心品类(销售额占比38%),但理论毛利率仅18.96%,是最低毛利品类,且实际成本超理论17.6万
+- **夜市烧烤**成本差异最大(+31.6万),实际超耗严重
+- **爽口凉菜**实际毛利率比理论低5个百分点,超耗16.4万
+- **其他**品类实际毛利率为-151.20%,严重异常,需排查包装/物料分摊问题
+
+---
+
+## 三、成本差异TOP10(实际超理论最严重)
+
+| 菜品 | 品类 | 理论毛利率 | 实际毛利率 | 成本差异 | 销售额 |
+|------|------|-----------|-----------|---------|--------|
+| 酱烧琵琶鸡腿饭 | 西部主食 | 46.11% | -246.50% | +¥336,119.68 | ¥114,871.94 |
+| 草原游牧羔羊肉串 | 夜市烧烤 | 61.12% | 51.10% | +¥331,402.24 | ¥3,309,006.31 |
+| 大片牛肉骨汤牛肉面 | 兰州牛肉面 | 66.09% | 62.10% | +¥215,526.69 | ¥5,397,928.06 |
+| 煨牛肉夹馍 | 特色小吃 | 70.21% | 46.86% | +¥120,768.49 | ¥517,122.54 |
+| 山珍卤牛肉饭 | 西部主食 | 53.64% | 12.15% | +¥110,519.01 | ¥266,331.70 |
+| 呼伦贝尔羔羊炒烤肉 | 丝路美食 | 39.87% | 1.78% | +¥74,975.13 | ¥196,827.28 |
+| 【外卖】辣椒调味包 | 外卖 | 30.14% | -1450.62% | +¥69,315.66 | ¥4,681.06 |
+| 鲜牛棒骨汤面 | 兰州牛肉面 | 84.86% | 82.43% | +¥62,241.52 | ¥2,559,782.07 |
+| 蒜泥茄子 | 爽口凉菜 | 83.03% | -93.97% | +¥52,614.81 | ¥29,726.29 |
+| 白切牛肉 | 爽口凉菜 | 37.64% | 27.97% | +¥50,279.15 | ¥519,547.22 |
+
+**关键发现**:
+- **酱烧琵琶鸡腿饭**实际毛利率为-246.50%,成本严重失控,实际成本是售价的3.5倍
+- **草原游牧羔羊肉串**销售额330万但超耗33万,是金额影响最大的单品
+- **蒜泥茄子**理论毛利率83%但实际为-94%,疑似物料分摊错误
+- **外卖辣椒调味包**实际毛利率-1450%,包装/物料成本远超售价
+
+---
+
+## 四、低毛利菜品预警(理论毛利率<50%且销售额>1000)
+
+| 菜品 | 品类 | 理论毛利率 | 实际毛利率 | 销售额 |
+|------|------|-----------|-----------|--------|
+| 坚果奶酪包 | 特色小吃 | -310.66% | 38.69% | ¥22,099.43 |
+| 蔓越莓枣糕 | 特色小吃 | -286.95% | -170.89% | ¥1,997.61 |
+| 米饭 | 西部主食 | -233.15% | -27.89% | ¥186,243.13 |
+| 鲜牛肉小笼包(一笼) | 早点类 | -176.34% | -98.11% | ¥2,164.11 |
+| 鲜花椒香锅烤鱼 | 丝路美食 | -13.57% | 88.41% | ¥6,744.18 |
+| 【外卖】餐具包 | 外卖 | -13.42% | -93.97% | ¥56,986.08 |
+| 新疆阳光番茄面(儿童餐) | 特色小吃 | -1.53% | -17.25% | ¥3,699.90 |
+| 杏皮茶(瓶装) | 酒水饮料 | 8.85% | 10.67% | ¥83,763.99 |
+| 滋补萝卜羊蝎子煲 | 丝路美食 | 10.07% | 21.16% | ¥122,334.58 |
+| 牛肉大葱饺子(12个)赠醋辣椒 | 外卖 | 13.33% | 94.23% | ¥80,524.08 |
+| 餐盒(大) | 其他 | 17.77% | 38.69% | ¥32,544.61 |
+| 糖蒜 | 特色小吃 | 19.08% | -95.03% | ¥13,301.70 |
+| 白切牛肉(1斤) | 爽口凉菜 | 21.03% | 21.44% | ¥13,590.07 |
+| 酱香牛腱子袋(零售) | 特色小吃 | 21.82% | 100.00% | ¥1,794.19 |
+| 奥尔良烤鸡翅(儿童餐) | 特色小吃 | 22.72% | 12.25% | ¥12,457.59 |
+
+**关键发现**:
+- **米饭**理论毛利率-233%,BOM标准成本远超售价,定价策略需重新审视
+- **坚果奶酪包/蔓越莓枣糕**理论毛利率为负,可能是新品试销或BOM配置错误
+- **滋补萝卜羊蝎子煲**销售额12万但毛利率仅10%,低毛利高销量产品
+- 共95道菜品理论毛利率低于50%,占总菜品数的12%
+
+---
+
+## 五、物料损耗分析
+
+### 5.1 损耗概况
+
+| 指标 | 数值 |
+|------|------|
+| 物料明细总数 | 3,568 |
+| 存在损耗的物料明细 | 1,193条(33.4%) |
+| 严重损耗(>20%) | 894条 |
+| 平均损耗率 | 832.30% |
+| 最大损耗率 | 1,457,008.49% |
+
+### 5.2 高损耗物料TOP15(按损耗量排序)
+
+| 物料名称 | 涉及菜品数 | 总损耗量 | 平均损耗率 | 最大损耗率 |
+|---------|-----------|---------|-----------|-----------|
+| 方餐盒 | 19 | -472,945.30 | -73.26% | -15.60% |
+| 拉面打包碗1300ml | 4 | -165,796.00 | -100.00% | -100.00% |
+| 牛皮纸烤串袋 | 12 | -102,970.63 | -30.71% | 0.00% |
+| 华米东北香米 | 1 | -86,050.00 | -100.00% | -100.00% |
+| 乌梅茶标签纸 | 1 | -53,610.50 | -87.13% | -87.13% |
+| 美式750餐盒 | 28 | -31,841.60 | -4.84% | 545.55% |
+| 紫土豆 | 12 | -30,926.53 | -100.00% | -100.00% |
+| 紫葱头(粉皮) | 58 | -27,547.01 | -93.79% | -68.09% |
+| 金龙鱼稻花香大米 | 1 | -27,286.30 | -31.71% | -31.71% |
+| 元宝富强粉 | 24 | -26,473.78 | -63.64% | 160.18% |
+| 番茄酱炼制 | 36 | -25,951.18 | -100.00% | -100.00% |
+| 大米(稻米香二号) | 39 | -25,180.71 | -40.95% | 3.37% |
+| 五湖大豆油 | 135 | -19,008.20 | -100.00% | -100.00% |
+| 雪花纯生500ml | 2 | -18,306.00 | -19.92% | 7.69% |
+| 白萝卜 | 23 | -18,257.21 | -36.03% | 133.50% |
+
+**关键发现**:
+- **包装物料**(方餐盒、打包碗、烤串袋)损耗量最大,反映包装管理问题
+- **五湖大豆油**涉及135道菜品,损耗率-100%,是影响面最广的物料
+- **紫葱头**涉及58道菜品,平均损耗率-93.79%,采购/存储环节需排查
+- **大米类**(华米东北香米、金龙鱼、稻米香二号)损耗严重,主食成本管控需加强
+- **番茄酱**涉及36道菜品,损耗率-100%,疑似领用未核销
+
+---
+
+## 六、数据匹配率
+
+| 维度 | 已匹配 | 未匹配 | 匹配率 |
+|------|--------|--------|--------|
+| 菜品→销售数据 | 723 | 65 | 91.75% |
+| 物料→库存数据 | 3,450 | 118 | 96.65% |
+
+**关键发现**:
+- 65道菜品(8.25%)未匹配到销售数据,可能是已停售菜品或名称不一致
+- 118条物料明细(3.31%)未匹配到库存数据,需排查物料名称标准化问题
+
+---
+
+## 七、建议行动项
+
+### 紧急(P0)
+1. **酱烧琵琶鸡腿饭**:实际毛利率-246.50%,立即核查BOM配置和物料分摊
+2. **蒜泥茄子**:理论83%实际-94%,排查物料归属错误
+3. **外卖辣椒调味包/餐具包**:成本远超售价,重新定价或调整分量
+
+### 重点(P1)
+4. **夜市烧烤品类**:超耗31.6万,重点排查羔羊肉串的物料损耗
+5. **兰州牛肉面品类**:理论毛利率仅19%,核心品类需重新审视定价策略
+6. **包装物料管理**:方餐盒/打包碗损耗量大,建立包装领用核销制度
+7. **五湖大豆油/番茄酱**:涉及菜品多、损耗率100%,排查领用未核销问题
+
+### 改善(P2)
+8. **65道未匹配菜品**:清理停售菜品或统一命名规范
+9. **118条未匹配物料**:建立物料名称标准化对照表
+10. **米饭定价**:理论毛利率-233%,重新核算成本或调整售价
diff --git a/菜品成本分析指标体系.md b/菜品成本分析指标体系.md
new file mode 100644
index 0000000..2ffc776
--- /dev/null
+++ b/菜品成本分析指标体系.md
@@ -0,0 +1,607 @@
+# 菜品成本分析指标体系
+
+> 数据基础:`dish_cost_analysis_summary`(788道菜品)、`dish_cost_analysis_material_detail`(3,568条物料明细)、`fact_recipe_bom`(3,520条BOM)、`dim_sku`(765个SKU)、`dim_material`(600种物料)、`v_store_theoretical_actual_cost_april`(89门店)、`dish_sales_details`(555万行销售明细)
+> 核心限制:菜品成本报表缺少"月份+门店"字段,现阶段适合做公司级商品与供应链决策,不适合直接对具体门店进行SKU成本绩效考核。
+
+---
+
+## 第一优先级:现有数据可直接开展
+
+### 模块1:SKU理论与实际成本分析
+
+**解决的问题**:哪些菜品实际成本明显高于标准
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 理论成本 | 按BOM标准用量×标准单价计算的应有成本 | `theoretical_cost` | `dish_cost_analysis_summary` |
+| 实际成本 | 实际盘点耗用物料的成本 | `actual_cost` | `dish_cost_analysis_summary` |
+| 成本差异额 | 实际成本 - 理论成本 | `cost_variance_amount` | `dish_cost_analysis_summary` |
+| 成本差异率 | 成本差异额 ÷ 理论成本 × 100% | 计算字段 | 计算 |
+| 理论毛利率 | (售价 - 理论成本) ÷ 售价 × 100% | `theoretical_margin_rate_pct` | `dish_cost_analysis_summary` |
+| 实际毛利率 | (售价 - 实际成本) ÷ 售价 × 100% | `actual_margin_rate_pct` | `dish_cost_analysis_summary` |
+| 毛利率偏差 | 实际毛利率 - 理论毛利率 | 计算字段 | 计算 |
+
+**成本分层标准**:
+- 实际成本 ≤ 理论成本:正常
+- 实际超理论 0-10%:关注
+- 实际超理论 10-20%:重点整改
+- 实际超理论 >20%:紧急核查
+- 实际成本率 >100%(实际毛利率为负):先核查BOM/单位/分摊异常,不直接认定超耗
+
+**当前数据概况**:
+- 理论成本合计:¥16,077,398.19
+- 实际成本合计:¥16,547,027.90
+- 超理论金额:¥469,629.70
+- 整体差异率:2.92%
+- 实际超理论菜品:236道(30%)
+- 低毛利菜品(理论<50%):95道
+
+---
+
+### 模块2:SKU原料差异贡献分析
+
+**解决的问题**:一个菜品超耗具体由哪些原料造成
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 原料理论用量 | BOM标准每份用量 × 销量 | `theoretical_quantity` | `material_detail` |
+| 原料实际用量 | 实际盘点耗用量 | `actual_quantity` | `material_detail` |
+| 数量差异 | 实际用量 - 理论用量 | `loss_quantity` | `material_detail` |
+| 损耗率 | 数量差异 ÷ 理论用量 × 100% | `loss_quantity_rate_pct` | `material_detail` |
+| 金额差异 | 数量差异 × 物料单价 | `loss_amount` | `material_detail` |
+| 每份理论用量 | 单份菜品的标准用量 | `theoretical_quantity_per_dish` | `material_detail` |
+| 每份实际用量 | 单份菜品的实际用量 | `actual_quantity_per_dish` | `material_detail` |
+| 差异贡献度 | 单原料金额差异 ÷ 菜品总成本差异 × 100% | 计算字段 | 计算 |
+
+**分析路径**:
+```
+菜品成本差异
+→ 哪种原料
+→ 理论用量 vs 实际用量
+→ 数量差异
+→ 金额差异
+→ 差异原因分类:份量超标 / 替代原料 / 分摊遗漏 / 门店错位
+```
+
+**当前数据概况**:
+- 存在损耗的物料明细:1,193条(33.4%)
+- 严重损耗(>20%):894条
+- 极重损耗(>100%):25条
+
+---
+
+### 模块3:菜品盈利与菜单工程
+
+**解决的问题**:哪些SKU销量高但不赚钱,哪些值得推广
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 销售额 | 菜品总销售金额 | `sales_amount` | `dish_cost_analysis_summary` |
+| 销量 | 菜品销售份数 | `sales_quantity` | `dish_cost_analysis_summary` |
+| 理论毛利 | 销售额 - 理论成本 | 计算字段 | 计算 |
+| 实际毛利 | 销售额 - 实际成本 | 计算字段 | 计算 |
+| 收入贡献度 | 菜品销售额 ÷ 总销售额 × 100% | 计算字段 | 计算 |
+| 毛利贡献度 | 菜品毛利 ÷ 总毛利 × 100% | 计算字段 | 计算 |
+| ABC销售等级 | 按销售额累计占比分类 | A(前70%) / B(70-90%) / C(后10%) | 计算 |
+
+**菜单工程矩阵**:
+
+| 类型 | 销售表现 | 成本表现 | 建议 |
+|------|---------|---------|------|
+| 明星盈利品 | 高销售 | 高实际毛利 | 推广和保证供应 |
+| 高销低利品 | 高销售 | 低毛利 | 调价、减量或优化BOM |
+| 低销高利品 | 低销售 | 高毛利 | 改善陈列和推荐 |
+| 低销低利品 | 低销售 | 低毛利 | 可能占用独有原料,优先下架 |
+| 数据异常品 | — | 成本率异常 | 先修复数据 |
+
+---
+
+### 模块4:BOM复杂度分析
+
+**解决的问题**:哪些菜品配方太复杂、培训和执行难度高
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 原料种数 | SKU的BOM物料总数 | `count(*) GROUP BY sku_code` | `fact_recipe_bom` |
+| 半成品种数 | BOM中半成品物料数 | `count(*) WHERE major_category='半成品'` | `fact_recipe_bom` + `dim_material` |
+| 独有原料数 | 仅被该SKU使用的物料数 | 物料被引用次数=1 | `fact_recipe_bom` |
+| 单位种类数 | BOM中出现的不同单位数 | `count(DISTINCT unit)` | `fact_recipe_bom` |
+| 高损耗原料数 | waste_rate > 20%的物料数 | `count(*) WHERE waste_rate > 20` | `fact_recipe_bom` |
+| 零实际用量原料数 | standard_net_quantity = 0 | `count(*) WHERE standard_net_quantity = 0` | `fact_recipe_bom` |
+| 配方复杂度评分 | 综合评分 | 加权计算 | 计算 |
+
+**复杂度分级**:
+- ≤5种原料:低复杂度
+- 6-10种:正常
+- 11-15种:较复杂
+- >15种:重点评审
+
+**当前数据概况**:
+- 1个物料:163个SKU
+- 2-5个物料:150个SKU
+- 6-10个物料:180个SKU
+- 11-20个物料:100个SKU
+- >20个物料:4个SKU(最多42种:鲜花椒香锅烤鱼)
+
+---
+
+### 模块5:独有原料风险分析
+
+**解决的问题**:哪些低收入SKU占用专用原料
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| SKU收入 | 菜品销售额 | `sales_amount` | `dish_cost_analysis_summary` |
+| 独有原料数 | 仅被该SKU使用的物料数 | 物料引用次数=1 | `fact_recipe_bom` |
+| 独有原料成本 | 独有物料的采购成本 | 独有物料金额合计 | `material_detail` |
+| 库存耗用 | 独有物料的实际耗用金额 | `actual_amount` | `material_detail` |
+| 替代品是否存在 | 是否有同类替代物料 | `substitute_material` | `fact_recipe_bom` |
+| 下架后原料释放 | 停用SKU后可减少的物料数 | 独有物料数 | `fact_recipe_bom` |
+
+**分析链路**:
+```
+低收入SKU
+→ 独有原料
+→ 门店库存及耗用
+→ 保质期
+→ 是否有其他菜品可以消化
+→ 下架后的处置方案
+```
+
+---
+
+### 模块6:SKU精简供应链影响模拟
+
+**解决的问题**:砍掉某SKU能真正减少多少原料和库存
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 可减少原料数 | 停用SKU后独有物料数 | 独有物料数 | `fact_recipe_bom` |
+| 独有库存金额 | 独有物料的库存价值 | 独有物料金额合计 | `material_detail` |
+| 替代SKU数 | 可替代该SKU的其他菜品 | 同品类同价位SKU | `dim_sku` |
+| 收入影响 | 停用SKU减少的销售额 | `sales_amount` | `dish_cost_analysis_summary` |
+| 毛利影响 | 停用SKU减少的毛利 | 实际毛利 | 计算 |
+| 仍需采购的共用原料 | 被其他SKU也使用的物料 | 物料引用次数>1 | `fact_recipe_bom` |
+| 一次性报损风险 | 独有物料的当前库存 | 库存金额 | `material_detail` |
+
+**核心价值**:避免"砍了很多SKU,但所有原料仍然需要采购"的假精简。
+
+---
+
+### 模块7:原料共用度和标准化分析
+
+**解决的问题**:建立"原料—SKU网络",识别核心原料与独有原料
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 物料共用度 | 被多少个SKU引用 | `count(DISTINCT sku_code)` | `fact_recipe_bom` |
+| 核心原料 | 被超过50个SKU使用的物料 | 共用度 > 50 | `fact_recipe_bom` |
+| 独有原料 | 仅被1个SKU使用的物料 | 共用度 = 1 | `fact_recipe_bom` |
+| 同物异名原料 | 名称不同但实际同类的物料 | 名称相似度分析 | `dim_material` |
+| 物料品类分布 | 原材料 vs 半成品 | `major_category` | `dim_material` |
+| 高依赖原料 | 高价值且共用度高的物料 | 金额×共用度排序 | `material_detail` + `fact_recipe_bom` |
+
+**管理意义**:
+- 高共用原料重点保供
+- 独有原料严格控制新品准入
+- 同物异名原料统一编码
+- 规格过多的原料进行采购标准化
+
+**当前数据概况**:
+- 五湖大豆油:被134个SKU使用(共用度最高)
+- 大葱(粗):130个SKU
+- 蒜米:129个SKU
+- 鲜姜:123个SKU
+
+---
+
+### 模块8:包装及耗材成本分析
+
+**解决的问题**:餐盒、餐具、纸巾是否使用合理
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 每单用量 | 每份菜品耗用包装数量 | `theoretical_quantity_per_dish` | `material_detail` |
+| 每单成本 | 每份菜品包装成本 | `theoretical_amount / sales_quantity` | `material_detail` |
+| 费用率 | 包装成本 ÷ 销售额 × 100% | 计算字段 | 计算 |
+| 包装物料识别 | 餐盒/餐具包/打包袋/调味包等 | `material_name LIKE '%餐盒%餐具%打包%'` | `material_detail` |
+| 损耗量 | 包装物料的损耗数量 | `loss_quantity` | `material_detail` |
+| 损耗率 | 包装物料的损耗率 | `loss_quantity_rate_pct` | `material_detail` |
+
+**标准项目示例(餐巾纸)**:
+- 每单约5.30抽
+- 每单费用约0.044元
+- 全部纸巾费用约占营业收入0.145%
+- 建议标准:≤6抽正常、6-8抽关注、>8抽核查
+
+---
+
+### 模块9:BOM数据质量分析
+
+**解决的问题**:配方、单位和分摊数据是否可靠
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| BOM缺失率 | 无BOM的SKU占比 | 无BOM的SKU数 ÷ 总SKU数 | `dim_sku` + `fact_recipe_bom` |
+| 重复BOM | 同一SKU+物料+版本出现多次 | 重复记录数 | `fact_recipe_bom` |
+| 零实际用量 | standard_net_quantity = 0的BOM数 | `count(*)` | `fact_recipe_bom` |
+| 成本率异常 | 实际成本率 > 100% | `actual_margin_rate_pct < 0` | `dish_cost_analysis_summary` |
+| 理论成本异常 | 理论毛利率为负 | `theoretical_margin_rate_pct < 0` | `dish_cost_analysis_summary` |
+| 负数量 | 理论/实际用量为负 | `theoretical_quantity < 0 OR actual_quantity < 0` | `material_detail` |
+| 明细与汇总不一致 | 原料明细合计 ≠ 菜品汇总成本 | 逐菜品校验 | `material_detail` + `summary` |
+| 销售匹配率 | 菜品匹配到销售数据的比例 | `matched_to_dish_sales_by_name` | 视图 |
+| 库存匹配率 | 物料匹配到库存数据的比例 | `matched_to_inventory_by_name` | 视图 |
+
+**当前数据概况**:
+- BOM覆盖率:78.04%(597/765个SKU有BOM)
+- 菜品匹配率:91.75%(723/788)
+- 物料匹配率:96.65%(3450/3568)
+
+---
+
+### 模块10:品类成本对比
+
+**解决的问题**:各品类的成本结构和毛利水平对比
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 品类平均理论毛利率 | 品类内所有菜品理论毛利率均值 | `avg(theoretical_margin_rate_pct)` | `dish_cost_analysis_summary` |
+| 品类平均实际毛利率 | 品类内所有菜品实际毛利率均值 | `avg(actual_margin_rate_pct)` | `dish_cost_analysis_summary` |
+| 品类销售额 | 品类内所有菜品销售额合计 | `sum(sales_amount)` | `dish_cost_analysis_summary` |
+| 品类成本差异 | 品类内实际成本-理论成本合计 | `sum(cost_variance_amount)` | `dish_cost_analysis_summary` |
+| 品类成本率 | 品类理论成本 ÷ 销售额 | 计算字段 | 计算 |
+| 品类菜品数 | 品类内SKU数量 | `count(*)` | `dish_cost_analysis_summary` |
+
+---
+
+### 模块11:物料损耗率分布
+
+**解决的问题**:物料损耗的整体分布和异常识别
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 损耗率分档 | 按损耗率区间统计 | 7档分布 | `material_detail` |
+| 平均损耗率 | 所有物料的平均损耗率 | `avg(loss_quantity_rate_pct)` | `material_detail` |
+| 最大损耗率 | 单条物料明细的最大损耗率 | `max(loss_quantity_rate_pct)` | `material_detail` |
+| 高损耗物料数 | 损耗率>20%的物料数 | `count(*)` | `material_detail` |
+| 涉及菜品数 | 某物料被多少菜品使用 | `count(DISTINCT dish)` | `material_detail` |
+| 总损耗量 | 某物料的总损耗数量 | `sum(loss_quantity)` | `material_detail` |
+| 物料类型损耗对比 | 原材料 vs 半成品的损耗率对比 | `avg(loss_rate) GROUP BY material_type` | `material_detail` |
+
+**损耗率分档**:
+- 无损耗(0%)
+- 轻微(-1~0%)
+- 轻度(-1~-10%)
+- 中度(-10~-30%)
+- 严重(-30~-100%)
+- 极重(< -100%)
+- 正向(>0%,实际低于理论)
+
+---
+
+### 模块12:菜品定价合理性
+
+**解决的问题**:售价与BOM标准成本的关系,定价是否合理
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 售价 | 菜品单价 | `price` | `dish_cost_analysis_summary` |
+| 理论成本率 | 理论成本 ÷ 售价 × 100% | 计算字段 | 计算 |
+| 实际成本率 | 实际成本 ÷ 售价 × 100% | 计算字段 | 计算 |
+| 毛利率分布 | 按毛利率区间统计菜品数 | 分档统计 | `dish_cost_analysis_summary` |
+| 低毛利菜品数 | 理论毛利率 < 50% | `count(*)` | `dish_cost_analysis_summary` |
+| 负毛利菜品数 | 实际毛利率 < 0 | `count(*)` | `dish_cost_analysis_summary` |
+| 定价偏离度 | 售价与同类菜品均价的偏差 | (售价 - 品类均价) ÷ 品类均价 | 计算 |
+
+---
+
+### 模块13:BOM驱动物料需求预测
+
+**解决的问题**:根据历史销量×BOM标准用量预测物料采购量
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 预计物料需求量 | BOM标准用量 × 历史销量 | `sum(standard_gross_quantity × sales_quantity)` | `fact_recipe_bom` + `summary` |
+| 涉及菜品数 | 使用该物料的SKU数 | `count(DISTINCT sku_code)` | `fact_recipe_bom` |
+| 物料单位 | 物料的标准单位 | `base_unit` | `dim_material` |
+| 物料类型 | 原材料/半成品 | `major_category` | `dim_material` |
+| 需求金额 | 预计需求量 × 物料单价 | 需物料单价数据 | 计算 |
+
+---
+
+### 模块14:物料匹配率监控
+
+**解决的问题**:物料名称标准化程度和库存匹配情况
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 已匹配物料数 | 成功匹配到库存的物料数 | `count(*) WHERE matched=true` | 视图 |
+| 未匹配物料数 | 未匹配到库存的物料数 | `count(*) WHERE matched=false` | 视图 |
+| 物料匹配率 | 已匹配 ÷ 总数 × 100% | 计算字段 | 计算 |
+| 菜品匹配率 | 已匹配销售 ÷ 总菜品 × 100% | 计算字段 | 计算 |
+| 未匹配物料清单 | 未匹配物料的名称和涉及菜品 | 明细列表 | 视图 |
+| 未匹配菜品清单 | 未匹配销售数据的菜品 | 明细列表 | 视图 |
+
+---
+
+## 第二优先级:门店级分析(基于现有门店成本数据)
+
+### 模块15:门店成本差异分布
+
+**解决的问题**:全部门店的成本异常分级分布
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 门店成本差异金额 | 实际食材成本 - 理论食材成本 | `food_cost_variance` | `v_store_theoretical_actual_cost_april` |
+| 差异分级 | 红/橙/绿/灰 | `variance_level` | `v_store_theoretical_actual_cost_april` |
+| 理论成本率 | 理论成本 ÷ 销售 × 100% | `theoretical_cost_rate_pct` | `v_store_theoretical_actual_cost_april` |
+| 实际食材成本率 | 实际食材成本 ÷ 销售 × 100% | `actual_food_cost_rate_pct` | `v_store_theoretical_actual_cost_april` |
+| 成本率偏差 | 实际成本率 - 理论成本率 | `cost_rate_gap_pct` | `v_store_theoretical_actual_cost_april` |
+| 可比状态 | 是否可比 | `comparison_status` | `v_store_theoretical_actual_cost_april` |
+
+**当前数据概况**:
+- 红色-严重超耗:35家门店,超耗¥203.6万
+- 橙色-明显超耗:40家门店,超耗¥135.1万
+- 绿色-基本正常:8家门店
+- 灰色-口径异常:6家门店
+
+---
+
+### 模块16:门店超耗金额排名
+
+**解决的问题**:哪些门店超耗最严重
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 门店超耗排名 | 按成本差异金额排序 | `ORDER BY food_cost_variance` | `v_store_theoretical_actual_cost_april` |
+| 门店超耗率 | 差异 ÷ 理论成本 × 100% | 计算字段 | 计算 |
+| 门店销售额 | 门店总实收 | `sales_received` | `v_store_theoretical_actual_cost_april` |
+| 门店账单数 | 门店总账单数 | `bill_count` | `v_store_theoretical_actual_cost_april` |
+| 门店理论成本 | 门店理论成本合计 | `theoretical_cost` | `v_store_theoretical_actual_cost_april` |
+| 门店实际成本 | 门店实际成本合计 | `actual_food_cost` | `v_store_theoretical_actual_cost_april` |
+
+---
+
+### 模块17:门店理论vs实际成本率对比
+
+**解决的问题**:门店成本率偏差的分布
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 理论成本率 | 理论成本 ÷ 销售 | `theoretical_cost_rate_pct` | `v_store_theoretical_actual_cost_april` |
+| 实际成本率 | 实际成本 ÷ 销售 | `actual_food_cost_rate_pct` | `v_store_theoretical_actual_cost_april` |
+| 成本率偏差 | 实际 - 理论 | `cost_rate_gap_pct` | `v_store_theoretical_actual_cost_april` |
+| 负库存项数 | 期末库存为负的物料数 | `negative_item_lines` | `v_store_theoretical_actual_cost_april` |
+| 负库存金额 | 负库存金额合计 | `negative_consumption_amount` | `v_store_theoretical_actual_cost_april` |
+
+---
+
+### 模块18:门店菜品成本交叉分析
+
+**解决的问题**:按门店看菜品成本和毛利
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 门店匹配菜品数 | 门店销售中匹配到成本数据的菜品数 | `count(DISTINCT dish_name)` | `dish_sales_details` + `summary` |
+| 门店匹配销售额 | 门店匹配菜品的销售额合计 | `sum(gross_amount)` | `dish_sales_details` + `summary` |
+| 门店菜品覆盖率 | 匹配菜品数 ÷ 门店总菜品数 | 计算字段 | 计算 |
+| 门店理论成本合计 | 门店匹配菜品的理论成本 | `sum(theoretical_cost)` | 关联查询 |
+| 门店实际成本合计 | 门店匹配菜品的实际成本 | `sum(actual_cost)` | 关联查询 |
+
+> 注:菜品成本报表无门店维度,此分析通过菜品名称关联销售明细间接估算,精度受限。
+
+---
+
+### 模块19:门店物料损耗排名
+
+**解决的问题**:按门店汇总物料损耗率,识别操作异常门店
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 门店物料损耗率 | 门店物料损耗量 ÷ 理论用量 | 间接估算 | `dish_sales_details` + `material_detail` |
+| 门店高损耗物料数 | 损耗率>20%的物料数 | 计算字段 | 计算 |
+| 门店损耗金额 | 损耗物料的金额合计 | 间接估算 | 关联查询 |
+
+> 注:受限于菜品成本报表无门店维度,此分析为间接估算。
+
+---
+
+## 第三优先级:BOM专项分析(基于构建的BOM数据)
+
+### 模块20:BOM覆盖率分析
+
+**解决的问题**:有多少SKU有BOM,缺BOM的SKU清单
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| BOM覆盖率 | 有BOM的SKU ÷ 总SKU × 100% | 计算字段 | `dim_sku` + `fact_recipe_bom` |
+| 有BOM的SKU数 | 在BOM表中出现的SKU数 | `count(DISTINCT sku_code)` | `fact_recipe_bom` |
+| 缺BOM的SKU数 | 不在BOM表中的SKU数 | 总SKU - 有BOM的SKU | 计算 |
+| 缺BOM SKU清单 | 按品类、销量排序 | 明细列表 | `dim_sku` + `fact_recipe_bom` |
+
+**当前数据概况**:
+- BOM覆盖率:78.04%(597/765)
+- 缺BOM的SKU:168个
+
+---
+
+### 模块21:BOM适用门店覆盖
+
+**解决的问题**:哪些SKU的BOM仅适用部分门店
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 全门店适用 | applicable_stores 为空(全部门店) | `applicable_stores IS NULL` | `fact_recipe_bom` |
+| 部分门店适用 | applicable_stores 有指定门店 | `applicable_stores IS NOT NULL` | `fact_recipe_bom` |
+| 门店缺失BOM | 某门店无对应SKU的BOM | 交叉校验 | `fact_recipe_bom` + `dim_store` |
+
+**当前状态**:所有BOM的 `applicable_stores` 均为空,表示全部门店适用。
+
+---
+
+### 模块22:高损耗BOM预警
+
+**解决的问题**:BOM标准损耗率超过阈值的SKU-物料组合
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| BOM损耗率 | BOM中记录的标准损耗率 | `waste_rate` | `fact_recipe_bom` |
+| BOM出成率 | BOM中记录的标准出成率 | `yield_rate` | `fact_recipe_bom` |
+| 高损耗BOM数 | waste_rate > 20%的BOM数 | `count(*)` | `fact_recipe_bom` |
+| 极端损耗BOM | waste_rate > 100%的BOM | 明细列表 | `fact_recipe_bom` |
+| 涉及菜品数 | 高损耗BOM涉及的SKU数 | `count(DISTINCT sku_code)` | `fact_recipe_bom` |
+
+---
+
+### 模块23:菜品物料构成分析
+
+**解决的问题**:每道菜品的物料构成和主要成本物料
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 物料数量 | 菜品BOM中的物料总数 | `count(*)` | `fact_recipe_bom` |
+| 主要成本物料 | 金额占比最大的物料 | 排序 | `material_detail` |
+| 物料成本占比 | 单物料金额 ÷ 菜品总成本 | 计算字段 | `material_detail` |
+| 物料类型构成 | 原材料 vs 半成品数量 | `count(*) GROUP BY material_type` | `material_detail` |
+| 配方复杂度评分 | 综合物料数/类型/损耗 | 加权计算 | 计算 |
+
+---
+
+### 模块24:成本差异vs销量散点图
+
+**解决的问题**:高销量高差异菜品优先整改
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| X轴:销量 | 菜品销售份数 | `sales_quantity` | `dish_cost_analysis_summary` |
+| Y轴:成本差异 | 实际成本 - 理论成本 | `cost_variance_amount` | `dish_cost_analysis_summary` |
+| 气泡大小:销售额 | 菜品销售额 | `sales_amount` | `dish_cost_analysis_summary` |
+| 颜色:品类 | 一级分类 | `category_level1` | `dish_cost_analysis_summary` |
+| 优先整改象限 | 高销量 + 高差异 | 右上角 | 散点图 |
+
+---
+
+### 模块25:理论/实际毛利率偏差分布
+
+**解决的问题**:毛利率偏差的分布和异常菜品
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 毛利率偏差 | 实际毛利率 - 理论毛利率 | 计算字段 | `dish_cost_analysis_summary` |
+| 偏差分档 | 按偏差区间统计 | 5档分布 | 计算 |
+| 严重偏低 | 偏差 < -30% | `count(*)` | 计算 |
+| 偏低 | 偏差 -30% ~ -10% | `count(*)` | 计算 |
+| 略低 | 偏差 -10% ~ 0% | `count(*)` | 计算 |
+| 略高 | 偏差 0 ~ 10% | `count(*)` | 计算 |
+| 高于理论 | 偏差 > 10% | `count(*)` | 计算 |
+
+**当前数据概况**:
+- 实际略高0-10%:191道
+- 实际高于理论>10%:177道
+- 实际略低0-10%:140道
+- 实际偏低10-30%:53道
+- 实际严重偏低>30%:40道
+
+---
+
+### 模块26:门店成本红橙绿分级地图
+
+**解决的问题**:89门店按成本异常级别地图展示
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 门店经纬度 | 门店地理位置 | `latitude_gcj02, longitude_gcj02` | `v_store_location_operating` |
+| 成本分级 | 红/橙/绿/灰 | `variance_level` | `v_store_theoretical_actual_cost_april` |
+| 超耗金额 | 门店成本差异金额 | `food_cost_variance` | `v_store_theoretical_actual_cost_april` |
+| 门店名称 | 门店名称 | `store_name` | `v_store_theoretical_actual_cost_april` |
+| 地图标注颜色 | 按分级着色 | 红/橙/绿/灰 | 可视化 |
+
+---
+
+### 模块27:BOM版本时效监控
+
+**解决的问题**:过期BOM/即将过期BOM/有效BOM统计
+
+| 指标 | 定义 | 计算方式 | 数据来源 |
+|------|------|---------|---------|
+| 有效BOM数 | expiry_date 为空或 > 当前日期 | `count(*)` | `fact_recipe_bom` |
+| 过期BOM数 | expiry_date < 当前日期 | `count(*)` | `fact_recipe_bom` |
+| 即将过期BOM数 | expiry_date 在30天内 | `count(*)` | `fact_recipe_bom` |
+| BOM版本数 | 不同版本数量 | `count(DISTINCT bom_version)` | `fact_recipe_bom` |
+
+**当前状态**:所有BOM均为v1.0版本,expiry_date为空(长期有效),无过期风险。
+
+---
+
+## 补齐数据后可增加的分析
+
+> 当前菜品成本报表缺少"月份+门店"字段,补齐后可增加以下分析:
+
+| # | 分析模块 | 需要补齐的字段 | 解决的问题 |
+|---|---------|--------------|-----------|
+| 28 | 门店-SKU-原料理论与实际差异 | 门店+月份 | 精确到门店级的单品成本差异 |
+| 29 | 同一SKU不同门店份量执行差异 | 门店+月份 | 识别份量不统一的门店 |
+| 30 | 原料价格差异和用量差异分解 | 月份+采购单价 | 区分价格因素和用量因素 |
+| 31 | 门店替代原料使用情况 | 门店+月份 | 门店是否擅自替换原料 |
+| 32 | BOM版本变更前后毛利变化 | 月份+BOM版本 | 配方调整对毛利的影响 |
+| 33 | 新品试销实际毛利 | 月份+上架日期 | 新品是否达到预期毛利 |
+| 34 | 报损/赠送/员工餐成本归属 | 门店+月份+用途 | 非销售耗用的成本归属 |
+| 35 | 门店真实SKU利润表 | 门店+月份 | 门店级单品盈亏 |
+| 36 | 替代料使用率 | `substitute_material`有值 | 替代料的实际使用频率和成本影响 |
+| 37 | 供应商集中度分析 | `dim_material.supplier_code` | 供应商依赖度和断供风险 |
+| 38 | BOM标准成本波动追踪 | 多版本BOM | 同一SKU不同版本的成本变化趋势 |
+| 39 | 配方变更对毛利影响 | 多版本BOM+月份 | 版本变更前后的成本/毛利变化 |
+
+**标准差异分解最终目标**:
+```
+实际成本差异
+= 采购价格差异
++ 原料用量差异
++ 替代料差异
++ 出成率差异
++ 报损盘亏
++ 赠送及员工餐
++ 调拨和分摊差异
+```
+
+---
+
+## 建议落地顺序
+
+### 第一批(公司级商品与供应链决策)
+1. 模块1:SKU理论与实际成本及毛利
+2. 模块2:SKU原料差异贡献
+3. 模块4:BOM复杂度与独有原料
+4. 模块6:SKU精简供应链影响模拟
+5. 模块8:包装耗材每单成本
+
+### 第二批(成本执行管理)
+6. 模块3:菜品盈利与菜单工程
+7. 模块7:原料共用度和标准化
+8. 模块9:BOM数据质量分析
+9. 模块10:品类成本对比
+10. 模块11:物料损耗率分布
+
+### 第三批(门店级分析)
+11. 模块15:门店成本差异分布
+12. 模块16:门店超耗金额排名
+13. 模块17:门店理论vs实际成本率
+14. 模块26:门店成本分级地图
+
+### 第四批(深度分析)
+15. 模块5:独有原料风险
+16. 模块12:菜品定价合理性
+17. 模块13:BOM驱动物料需求预测
+18. 模块14:物料匹配率监控
+19. 模块24:成本差异vs销量散点图
+20. 模块25:毛利率偏差分布
+
+### 分析闭环
+```
+卖得怎么样(模块3)
+→ 实际赚不赚钱(模块1)
+→ 为什么成本异常(模块2)
+→ 配方是否复杂(模块4)
+→ 是否应该保留(模块6)
+→ 下架后能减少什么成本(模块6)
+```
diff --git a/菜品调整指导与验证体系.md b/菜品调整指导与验证体系.md
new file mode 100644
index 0000000..ac0a4c5
--- /dev/null
+++ b/菜品调整指导与验证体系.md
@@ -0,0 +1,410 @@
+# 菜品调整指导与验证体系
+
+> 核心目标:从"看数据"升级为"调什么 → 怎么调 → 调完验证 → 持续优化"的闭环
+
+---
+
+## 一、问题分析
+
+当前系统已具备**分析能力**(成本差异、毛利排名、BOM复杂度等),但缺少:
+
+1. **决策指导**:分析结果出来后,具体应该调什么?调价?改配方?下架?
+2. **调整记录**:谁在什么时候对哪个菜品做了什么调整?
+3. **效果验证**:调整后效果如何?毛利提升了吗?成本下降了吗?
+4. **持续迭代**:验证结果反馈到下一轮分析,形成闭环
+
+数据限制:销售明细仅覆盖2026年4月(552万行),5月仅少量数据。菜品成本报表无月份字段。因此**验证阶段需要按周/旬对比**,而非按月。
+
+---
+
+## 二、整体架构
+
+```
+分析诊断 → 调整建议 → 决策记录 → 执行跟踪 → 效果验证 → 反馈迭代
+ ↑ |
+ └──────────────────────────────────────────────────────────────┘
+```
+
+### 闭环流程
+
+```
+1. 系统自动生成菜品诊断报告(每周/每月)
+ → 识别问题菜品:低毛利、高超耗、配方复杂、独有原料
+
+2. 系统生成调整建议(按优先级排序)
+ → 调价 / 改配方 / 减份量 / 下架 / 保持
+
+3. 商品部/运营部在系统中确认调整方案
+ → 记录:菜品、调整类型、调整前值、调整后值、执行日期、负责人
+
+4. 执行后系统自动跟踪关键指标
+ → 对比调整前后7天/14天/30天的销量、毛利、成本差异
+
+5. 系统生成效果验证报告
+ → 达标 / 未达标 / 需继续观察
+
+6. 验证结果反馈到下一轮诊断
+ → 未达标 → 重新分析原因 → 新一轮调整
+```
+
+---
+
+## 三、新增数据表
+
+### 3.1 菜品调整记录表
+
+```sql
+CREATE TABLE public.dish_adjustment_log (
+ id SERIAL PRIMARY KEY,
+ dish_code TEXT NOT NULL, -- 菜品编码
+ dish_name TEXT NOT NULL, -- 菜品名称
+ adjustment_type TEXT NOT NULL, -- 调整类型:price/recipe/portion/delisting/relaunch
+ -- 调整前快照
+ before_price NUMERIC,
+ before_theoretical_cost NUMERIC,
+ before_theoretical_margin NUMERIC,
+ before_sales_avg_daily NUMERIC, -- 调整前日均销量
+ before_cost_variance NUMERIC, -- 调整前成本差异
+ -- 调整后目标
+ after_price NUMERIC,
+ after_theoretical_cost NUMERIC,
+ after_target_margin NUMERIC, -- 目标毛利率
+ target_cost_reduction NUMERIC, -- 目标成本降幅
+ -- 执行信息
+ effective_date DATE NOT NULL, -- 执行日期
+ decided_by TEXT, -- 决策人
+ reason TEXT, -- 调整原因
+ diagnosis_id INTEGER, -- 关联诊断报告ID
+ status TEXT DEFAULT 'planned', -- planned/executing/completed/cancelled
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+```
+
+### 3.2 调整效果验证表
+
+```sql
+CREATE TABLE public.dish_adjustment_result (
+ id SERIAL PRIMARY KEY,
+ adjustment_id INTEGER NOT NULL REFERENCES public.dish_adjustment_log(id),
+ -- 验证周期
+ period_type TEXT NOT NULL, -- 7d/14d/30d
+ period_start DATE NOT NULL,
+ period_end DATE NOT NULL,
+ -- 调整后实际值
+ actual_sales_avg_daily NUMERIC, -- 实际日均销量
+ actual_sales_change_pct NUMERIC, -- 销量变化率
+ actual_margin NUMERIC, -- 实际毛利率
+ actual_margin_change NUMERIC, -- 毛利率变化(百分点)
+ actual_cost_variance NUMERIC, -- 实际成本差异
+ actual_cost_change_pct NUMERIC, -- 成本变化率
+ -- 评估
+ target_achieved BOOLEAN, -- 是否达标
+ assessment TEXT, -- 达标/未达标/需继续观察
+ notes TEXT, -- 备注
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+```
+
+### 3.3 菜品诊断快照表
+
+```sql
+CREATE TABLE public.dish_diagnosis_snapshot (
+ id SERIAL PRIMARY KEY,
+ diagnosis_date DATE NOT NULL,
+ dish_code TEXT NOT NULL,
+ dish_name TEXT NOT NULL,
+ -- 诊断指标快照
+ category_l1 TEXT,
+ sales_amount NUMERIC,
+ sales_quantity NUMERIC,
+ theoretical_margin_pct NUMERIC,
+ actual_margin_pct NUMERIC,
+ cost_variance_amount NUMERIC,
+ cost_tier TEXT, -- 正常/关注/整改/紧急/数据异常
+ bom_complexity_score NUMERIC,
+ unique_material_count INTEGER,
+ waste_rate_avg NUMERIC,
+ -- 诊断结论
+ diagnosis_type TEXT, -- low_margin/high_variance/over_complex/data_anomaly/unique_material_risk
+ diagnosis_detail TEXT, -- 详细诊断描述
+ suggested_action TEXT, -- 建议动作:price_up/price_down/recipe_optimize/portion_reduce/delist/fix_data/monitor
+ priority TEXT, -- P0/P1/P2/P3
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+```
+
+---
+
+## 四、调整建议引擎
+
+### 4.1 自动诊断规则
+
+系统根据分析数据自动生成诊断和建议:
+
+| 诊断类型 | 触发条件 | 建议动作 | 优先级 |
+|---------|---------|---------|--------|
+| **低毛利-定价偏低** | 理论毛利率 < 30% 且实际毛利率 > 理论 | 涨价(上调10-20%) | P1 |
+| **低毛利-成本偏高** | 理论毛利率 < 50% 且实际 < 理论 | 优化BOM/换供应商 | P1 |
+| **高超耗-份量超标** | 成本差异 > 0 且损耗率 > 20% | 减份量/加强操作规范 | P0 |
+| **高超耗-物料分摊** | 成本差异 > 0 且损耗率 > 100% | 先修复数据分摊 | P0 |
+| **配方复杂-低销量** | 物料数 > 15 且日销 < 5份 | 简化配方或下架 | P2 |
+| **独有原料-低收入** | 独有原料数 > 3 且月收入 < 5000 | 评估下架 | P2 |
+| **数据异常-成本率>100%** | 实际毛利率 < 0 | 先修复BOM/单位/分摊 | P0 |
+| **负毛利菜品** | 理论毛利率 < 0 | 重新核算成本或涨价 | P0 |
+| **正常但可优化** | 实际超理论 0-10% | 监控,暂不调整 | P3 |
+
+### 4.2 调整建议模板
+
+每种建议动作对应一个模板:
+
+**涨价建议**:
+```
+菜品:{dish_name}
+当前售价:{price}元 → 建议售价:{price * 1.15}元
+当前理论毛利率:{margin}% → 预期毛利率:{expected_margin}%
+预计月增收:{(new_price - old_price) * monthly_sales}元
+风险:销量可能下降5-15%
+建议:先在3-5家门店试调,观察2周销量变化
+```
+
+**减份量建议**:
+```
+菜品:{dish_name}
+超标原料:{material_name}
+理论用量:{theoretical_qty} → 建议标准用量:{reduced_qty}
+当前损耗率:{waste_rate}% → 预期损耗率:{target_waste_rate}%
+预计月节省:{savings}元
+风险:顾客感知,需同步调整售价或保持不变
+建议:先在1-2家门店试调,收集顾客反馈
+```
+
+**下架建议**:
+```
+菜品:{dish_name}
+月销售额:{sales}元(排名 bottom 10%)
+独有原料:{unique_materials}种
+下架后可释放:{releasable_materials}种物料
+月收入影响:{sales}元
+月毛利影响:{profit}元(可能为正,因为亏损菜品)
+库存处置:需处理{inventory_value}元独有原料库存
+建议:先评估是否有替代菜品可消化独有原料
+```
+
+---
+
+## 五、验证方法
+
+### 5.1 对比维度
+
+| 维度 | 调整前 | 调整后 | 对比方法 |
+|------|--------|--------|---------|
+| 日均销量 | 调整前7天均值 | 调整后7天/14天均值 | 变化率 = (后-前)/前 |
+| 毛利率 | 调整前理论/实际毛利率 | 调整后理论/实际毛利率 | 变化 = 后 - 前(百分点) |
+| 成本差异 | 调整前成本差异额 | 调整后成本差异额 | 变化率 = (后-前)/前 |
+| 物料损耗率 | 调整前损耗率 | 调整后损耗率 | 变化 = 后 - 前 |
+| 顾客反馈 | — | 差评/投诉数 | 定性评估 |
+
+### 5.2 验证周期
+
+| 周期 | 适用场景 | 说明 |
+|------|---------|------|
+| 7天 | 涨价/减份量 | 快速验证销量变化 |
+| 14天 | 配方优化 | 验证成本差异变化 |
+| 30天 | 下架/上新 | 验证整体收入影响 |
+
+### 5.3 达标标准
+
+| 调整类型 | 达标标准 |
+|---------|---------|
+| 涨价 | 销量下降 < 15% 且毛利额增加 |
+| 减份量 | 成本差异下降 > 30% 且无顾客投诉 |
+| 配方优化 | 实际成本率下降 > 3个百分点 |
+| 下架 | 独有原料库存清理完成且总收入影响 < 预期 |
+| 数据修复 | 成本率回归合理区间(0-80%) |
+
+### 5.4 数据来源
+
+| 指标 | 数据来源 | 频率 |
+|------|---------|------|
+| 销量/销售额 | `dish_sales_details` | 每日 |
+| 理论/实际成本 | `dish_cost_analysis_summary`(需多次导入) | 每月 |
+| 物料损耗 | `dish_cost_analysis_material_detail`(需多次导入) | 每月 |
+| 成本差异 | `v_store_theoretical_actual_cost_april` | 每月 |
+
+> **关键限制**:菜品成本报表目前为单次导入,无月份维度。要实现持续验证,需要**每月定期导入新的成本报表**,并打上月份标签。
+
+---
+
+## 六、前端展现
+
+### 新增Tab:调整管理
+
+在菜品成本分析页面新增第10个Tab:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 调整管理 │
+├─────────────────────────────────────────────────────────┤
+│ [子Tab] 待处理建议 | 执行中 | 已完成 | 效果验证 │
+├─────────────────────────────────────────────────────────┤
+│ │
+│ ── 待处理建议 ── │
+│ [MetricCard] P0紧急 [MetricCard] P1重点 │
+│ [MetricCard] P2改善 [MetricCard] P3监控 │
+│ │
+│ DataTable: 菜品 | 诊断类型 | 建议动作 | 优先级(Badge) │
+│ | 当前毛利率 | 预期效果 | [确认调整] [忽略] │
+│ │
+│ 点击"确认调整" → 弹出调整表单: │
+│ 调整类型: [下拉] 涨价/减份量/改配方/下架 │
+│ 调整前值: (自动填充) │
+│ 调整后值: [输入] │
+│ 执行日期: [日期选择] │
+│ 负责人: [输入] │
+│ 备注: [文本框] │
+│ [提交] → 保存到 dish_adjustment_log │
+│ │
+│ ── 执行中 ── │
+│ DataTable: 菜品 | 调整类型 | 执行日期 | 已执行天数 │
+│ | 调整前销量 | 当前销量 | 变化率 │
+│ | [查看详情] [标记完成] │
+│ │
+│ ── 已完成 ── │
+│ DataTable: 菜品 | 调整类型 | 执行日期 | 完成日期 │
+│ | 调整前毛利率 | 调整后毛利率 | 变化 │
+│ | 达标状态(Badge) | [查看验证报告] │
+│ │
+│ ── 效果验证 ── │
+│ 选中菜品后展示: │
+│ ┌──────────────────────────────────────────┐ │
+│ │ LineChart: X轴=日期, Y轴=日均销量 │ │
+│ │ 竖线标注: 调整执行日期 │ │
+│ │ 双线: 销量 + 毛利率 │ │
+│ └──────────────────────────────────────────┘ │
+│ [MetricCard] 销量变化 [MetricCard] 毛利变化 │
+│ [MetricCard] 成本差异变化 [MetricCard] 达标状态 │
+│ DataTable: 验证周期 | 销量 | 毛利率 | 成本差异 | 评估 │
+└─────────────────────────────────────────────────────────┘
+```
+
+### 新增API端点
+
+| 端点 | 方法 | 说明 |
+|------|------|------|
+| `/api/cost-analysis/diagnosis` | GET | 获取最新诊断建议列表 |
+| `/api/cost-analysis/diagnosis/generate` | POST | 触发生成诊断快照 |
+| `/api/cost-analysis/adjustment` | GET | 获取调整记录列表 |
+| `/api/cost-analysis/adjustment` | POST | 创建调整记录 |
+| `/api/cost-analysis/adjustment/:id` | PUT | 更新调整状态 |
+| `/api/cost-analysis/adjustment/:id/verify` | GET | 获取验证数据 |
+| `/api/cost-analysis/adjustment/:id/result` | POST | 提交验证结果 |
+
+---
+
+## 七、完整闭环示例
+
+### 示例:酱烧琵琶鸡腿饭(实际毛利率-246.50%)
+
+```
+第1步:诊断
+ → 系统识别:实际毛利率-246.50%,成本差异+33.6万
+ → 诊断类型:data_anomaly(成本率异常)
+ → 建议动作:fix_data(先修复BOM/单位/分摊)
+ → 优先级:P0
+
+第2步:排查
+ → 商品部核查发现:物料分摊错误,鸡腿用量归属到米饭而非鸡腿饭
+ → 修复BOM:调整物料归属
+
+第3步:记录调整
+ → 调整类型:recipe(配方修复)
+ → 执行日期:2026-08-01
+ → 调整前:实际毛利率-246.50%,成本差异+33.6万
+ → 目标:实际毛利率回归50-70%
+
+第4步:验证(2026-08-08,7天后)
+ → 对比8月1-7日 vs 7月25-31日销量
+ → 等待8月成本报表导入后对比毛利率
+ → 验证结果:待数据
+
+第5步:反馈
+ → 如果毛利率回归正常 → 标记达标 → 闭环结束
+ → 如果仍然异常 → 重新诊断 → 新一轮调整
+```
+
+### 示例:草原游牧羔羊肉串(超耗33.1万)
+
+```
+第1步:诊断
+ → 系统识别:实际毛利率51.10% vs 理论61.12%,成本差异+33.1万
+ → 诊断类型:high_variance(高超耗)
+ → 建议动作:recipe_optimize(优化BOM/加强操作规范)
+ → 优先级:P0
+
+第2步:分析原料差异
+ → 拆解:羔羊肉理论用量 vs 实际用量
+ → 发现:实际用量超理论15%,损耗率-15%
+ → 原因:份量超标或切割损耗大
+
+第3步:调整方案
+ → 方案A:加强操作规范,标准份量控制在±5%
+ → 方案B:更换供应商,降低采购规格偏差
+ → 选择方案A,先在TOP10超耗门店试执行
+
+第4步:记录调整
+ → 调整类型:portion(份量控制)
+ → 执行日期:2026-08-01
+ → 调整前:成本差异+33.1万/月
+ → 目标:成本差异降至+10万/月以内
+
+第5步:验证(2026-08-15,14天后)
+ → 对比8月1-14日 vs 7月18-31日
+ → 销量变化:观察是否因份量控制影响顾客体验
+ → 成本变化:等待8月成本报表导入
+
+第6步:反馈
+ → 达标 → 推广到全部门店
+ → 未达标 → 升级为方案B(换供应商)
+```
+
+---
+
+## 八、数据补充需求
+
+要实现持续验证闭环,需要补充以下数据:
+
+| 数据 | 当前状态 | 需要做什么 | 优先级 |
+|------|---------|-----------|--------|
+| 菜品成本报表(按月) | 仅4月1次 | 每月定期导入,增加月份字段 | 最高 |
+| 物料采购单价 | 无 | 导入采购系统数据 | 高 |
+| 门店级菜品成本 | 无 | 菜品成本报表增加门店维度 | 高 |
+| BOM版本变更历史 | 仅v1.0 | 每次BOM修改记录版本 | 中 |
+| 顾客反馈/差评 | 无 | 对接评价系统 | 中 |
+| 促销活动记录 | 无 | 记录促销菜品/时间/力度 | 低 |
+
+### 最小可行闭环(MVP)
+
+仅基于现有数据可实现的闭环:
+
+```
+诊断(现有数据)
+→ 生成建议(规则引擎)
+→ 人工确认调整(调整记录表)
+→ 销量验证(dish_sales_details,按周对比)
+→ 等待下月成本报表导入后验证成本
+```
+
+**销量验证可以立即做**,成本验证需要等下次成本报表导入。
+
+---
+
+## 九、落地计划
+
+| 阶段 | 内容 | 依赖 |
+|------|------|------|
+| **阶段1** | 创建3张表 + 诊断规则引擎 + 诊断API | 无 |
+| **阶段2** | 调整记录CRUD + 前端调整管理Tab | 阶段1 |
+| **阶段3** | 销量验证(基于dish_sales_details按周对比) | 阶段2 |
+| **阶段4** | 成本验证(需多次导入成本报表) | 成本报表按月导入 |
+| **阶段5** | 自动化反馈迭代 | 阶段3+4 |