feat: 菜品成本分析模块 - 10个Tab + 31个分析API + 7个调整管理API

- 后端: 新增 cost-analysis.ts 路由,含31个分析端点和7个调整管理端点
- 前端: 新增 CostAnalysisPage 主页面 + 10个Tab组件
  - Tab1-9: 成本总览/菜品盈利/原料差异/BOM配方/供应链/包装耗材/数据质量/门店成本/可视化探索
  - Tab10: 调整管理(诊断快照+调整记录+效果验证)
- 修复: 毛利率和损耗率从简单平均改为加权计算
- 新增: Tabs组件、侧边栏菜单项、路由注册
- 新增: 3张数据库表(诊断快照/调整记录/验证结果)
This commit is contained in:
freedakgmail
2026-07-28 16:56:45 +08:00
parent 722db28d06
commit 3d89baf65d
22 changed files with 4137 additions and 5 deletions
+11 -4
View File
@@ -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",
+1
View File
@@ -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",
+2
View File
@@ -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() {
<Route path="/monthly-review" element={<MonthlyReviewPage />} />
<Route path="/sku" element={<SKUPage />} />
<Route path="/cost" element={<CostPage />} />
<Route path="/cost-analysis" element={<CostAnalysisPage />} />
<Route path="/platform" element={<PlatformPage />} />
<Route path="/member" element={<MemberPage />} />
<Route path="/risk" element={<RiskPage />} />
+2 -1
View File
@@ -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'] },
+28
View File
@@ -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 (
<div className="flex flex-wrap gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => onChange(tab.key)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
active === tab.key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</button>
))}
</div>
)
}
@@ -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<string, string> = {
'price_up': '涨价',
'price_down': '降价',
'recipe_optimize': '优化配方',
'portion_reduce': '减份量',
'delist': '下架',
'evaluate_delist': '评估下架',
'fix_data': '修复数据',
'monitor': '监控',
'keep': '保持',
}
const STATUS_LABELS: Record<string, string> = {
'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<number | null>(null)
const [formData, setFormData] = useState<any>({})
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 (
<div className="space-y-4">
<div className="flex gap-2">
<button onClick={() => setSubTab('diagnosis')} className={`rounded px-4 py-2 text-sm font-medium ${subTab === 'diagnosis' ? 'bg-primary text-primary-foreground' : 'border'}`}></button>
<button onClick={() => setSubTab('adjustment')} className={`rounded px-4 py-2 text-sm font-medium ${subTab === 'adjustment' ? 'bg-primary text-primary-foreground' : 'border'}`}></button>
<button onClick={() => setSubTab('verify')} className={`rounded px-4 py-2 text-sm font-medium ${subTab === 'verify' ? 'bg-primary text-primary-foreground' : 'border'}`}></button>
</div>
{subTab === 'diagnosis' && (
<>
<div className="flex items-center gap-3">
<button
onClick={() => generateDiagnosis.mutate()}
disabled={generateDiagnosis.isPending}
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{generateDiagnosis.isPending ? '生成中...' : '生成诊断快照'}
</button>
{generateDiagnosis.data && <span className="text-sm text-green-600"> {(generateDiagnosis.data as any)?.data?.generated} </span>}
<select value={diagPriority} onChange={(e) => { setDiagPriority(e.target.value); setDiagPage(1) }} className="rounded border px-2 py-1.5 text-sm">
<option value=""></option>
<option value="P0">P0</option>
<option value="P1">P1</option>
<option value="P2">P2</option>
<option value="P3">P3</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="P0紧急" value={p0Count} format="number" />
<MetricCard title="P1重点" value={p1Count} format="number" />
<MetricCard title="诊断总数" value={diagMeta.total} format="number" />
</div>
{ld ? <LoadingSpinner text="加载诊断..." /> : (
<>
<Pagination page={diagPage} pageSize={PAGE_SIZE} total={diagMeta.total} onPageChange={setDiagPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_l1', label: '品类' },
{ key: 'diagnosis_type', label: '诊断类型' },
{ key: 'diagnosis_detail', label: '诊断详情' },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => 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) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium border ${priorityColor(r.priority)}`}>{r.priority}</span>
)},
{ key: 'action', label: '操作', render: (r) => (
<button onClick={() => handleCreateAdjustment(r)} className="text-xs text-blue-600 hover:underline"></button>
)},
]}
data={diagData}
/>
</div>
</>
)}
{showForm && (
<CollapsibleSection title="创建调整记录" defaultOpen={true}>
<div className="grid grid-cols-2 gap-3">
<div><label className="text-xs text-muted-foreground"></label><input value={formData.dish_code || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div><label className="text-xs text-muted-foreground"></label><input value={formData.dish_name || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div>
<label className="text-xs text-muted-foreground"></label>
<select value={formData.adjustment_type || ''} onChange={(e) => setFormData({ ...formData, adjustment_type: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm">
<option value="price"></option>
<option value="recipe"></option>
<option value="portion"></option>
<option value="delisting"></option>
<option value="relaunch"></option>
</select>
</div>
<div><label className="text-xs text-muted-foreground"></label><input type="date" value={formData.effective_date || ''} onChange={(e) => setFormData({ ...formData, effective_date: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground">(%)</label><input type="number" value={formData.before_theoretical_margin || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div><label className="text-xs text-muted-foreground">(%)</label><input type="number" value={formData.after_target_margin || ''} onChange={(e) => setFormData({ ...formData, after_target_margin: Number(e.target.value) })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground"></label><input value={formData.decided_by || ''} onChange={(e) => setFormData({ ...formData, decided_by: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground"></label><input value={formData.reason || ''} onChange={(e) => setFormData({ ...formData, reason: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
</div>
<div className="mt-3 flex gap-2">
<button onClick={handleSubmitForm} disabled={createAdjustment.isPending} className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50">
{createAdjustment.isPending ? '提交中...' : '提交'}
</button>
<button onClick={() => setShowForm(false)} className="rounded border px-4 py-2 text-sm"></button>
</div>
</CollapsibleSection>
)}
</>
)}
{subTab === 'adjustment' && (
<>
<div className="flex items-center gap-2">
<select value={adjStatus} onChange={(e) => { setAdjStatus(e.target.value); setAdjPage(1) }} className="rounded border px-2 py-1.5 text-sm">
<option value=""></option>
<option value="planned"></option>
<option value="executing"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</select>
</div>
{la ? <LoadingSpinner text="加载调整记录..." /> : (
<>
<Pagination page={adjPage} pageSize={PAGE_SIZE} total={adjMeta.total} onPageChange={setAdjPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => 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) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
{ key: 'actions', label: '操作', render: (r) => (
<div className="flex gap-2">
{r.status === 'planned' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'executing' })} className="text-xs text-blue-600 hover:underline"></button>}
{r.status === 'executing' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'completed' })} className="text-xs text-green-600 hover:underline"></button>}
<button onClick={() => { setVerifyId(r.id); setSubTab('verify') }} className="text-xs text-blue-600 hover:underline"></button>
</div>
)},
]}
data={adjData}
/>
</div>
</>
)}
</>
)}
{subTab === 'verify' && (
<>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">ID:</label>
<input type="number" value={verifyId || ''} onChange={(e) => setVerifyId(e.target.value ? Number(e.target.value) : null)} placeholder="输入ID" className="rounded border px-3 py-1.5 text-sm w-32" />
</div>
{verifyId && (
lv ? <LoadingSpinner text="加载验证数据..." /> : verifyData ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="调整前日均销量" value={verifyData.before_avg_daily} format="number" />
<MetricCard title="调整后日均销量" value={verifyData.after_avg_daily} format="number" />
<MetricCard title="销量变化率" value={verifyData.sales_change_pct} format="percent" description="(后-前)/前" />
<MetricCard title="执行日期" value={verifyData.adjustment?.effective_date} format="text" />
</div>
<CollapsibleSection title="销量趋势图" subtitle="调整前后每日销量对比">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={[
...verifyData.before.map((d: any) => ({ ...d, period: '调整前' })),
...verifyData.after.map((d: any) => ({ ...d, period: '调整后' })),
]}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" tick={{ fontSize: 9 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Legend />
<Line dataKey="bills" name="日销量(账单数)" stroke="#3b82f6" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="调整详情" defaultOpen={false}>
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
{ key: 'effective_date', label: '执行日期' },
{ key: 'reason', label: '调整原因' },
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
]}
data={[verifyData.adjustment]}
/>
</CollapsibleSection>
</>
) : <p className="text-sm text-muted-foreground">ID查看验证数据</p>
)}
</>
)}
</div>
)
}
@@ -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 <LoadingSpinner text="加载BOM数据..." />
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="BOM覆盖率" value={ov.coverage_pct} format="percent" description="有BOM的SKU占总SKU比例" />
<MetricCard title="有BOM的SKU" value={ov.sku_with_bom} format="number" />
<MetricCard title="缺BOM的SKU" value={ov.sku_without_bom} format="number" />
<MetricCard title="BOM总行数" value={ov.total_bom_rows} format="number" />
<MetricCard title="高损耗BOM数" value={ov.high_loss_bom} format="number" description="损耗率>20%" />
<MetricCard title="平均物料数/SKU" value={ov.avg_materials_per_sku} format="number" />
</div>
<CollapsibleSection title="BOM复杂度分布" subtitle="按物料数量分档">
<ResponsiveContainer width="100%" height={200}>
<BarChart data={complexityDist}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="band" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="cnt" name="SKU数" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="BOM复杂度明细" subtitle="按物料数降序">
<Pagination page={complexPage} pageSize={PAGE_SIZE} total={compMeta.total} onPageChange={setComplexPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名', render: (r) => (
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => setSelectedSku(r.sku_code)}>{r.dish_name}</span>
)},
{ 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 ? <span className="text-red-600">{r.high_loss_count}</span> : '0' },
{ key: 'complexity_level', label: '复杂度', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${complexityColor(r.complexity_level)}`}>{r.complexity_level}</span>
)},
]}
data={compData}
/>
</div>
</CollapsibleSection>
{selectedSku && (
<CollapsibleSection title={`菜品物料构成: ${selectedSku}`} subtitle="点击菜品名查看物料详情" defaultOpen={true}>
{l5 ? <LoadingSpinner text="加载物料构成..." /> : (
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'gross_qty', label: '标准毛用量', align: 'right', render: (r) => 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}
/>
)}
</CollapsibleSection>
)}
<CollapsibleSection title="高损耗BOM预警" subtitle="损耗率 ≥ 20%的BOM记录" defaultOpen={false}>
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'material_name', label: '物料' },
{ key: 'waste_rate', label: '损耗率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.waste_rate)}</span> },
{ key: 'yield_rate', label: '出成率', align: 'right', render: (r) => formatPercent(r.yield_rate) },
{ key: 'unit', label: '单位', align: 'center' },
{ key: 'alert_level', label: '预警级别', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${alertColor(r.alert_level)}`}>{r.alert_level}</span>
)},
]}
data={lossData}
/>
</CollapsibleSection>
<CollapsibleSection title="缺BOM的SKU清单" subtitle="优先补齐高销量SKU的BOM" defaultOpen={false}>
<Pagination page={missingPage} pageSize={PAGE_SIZE} total={missMeta.total} onPageChange={setMissingPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => 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}
/>
</div>
</CollapsibleSection>
</div>
)
}
@@ -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 <LoadingSpinner text="加载散点图数据..." />
const scatterData = (scatter as any)?.data || []
const categories: string[] = [...new Set(scatterData.map((r: any) => r.category_level1).filter(Boolean))] as string[]
return (
<div className="space-y-4">
<CollapsibleSection title="成本差异 vs 销量散点图" subtitle="右上角=高销量高差异(优先整改),左上角=低销量高差异(考虑下架)">
<ResponsiveContainer width="100%" height={500}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 30, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="x" name="销量" tick={{ fontSize: 10 }} label={{ value: '销量', position: 'bottom', offset: 15, fontSize: 12 }} />
<YAxis type="number" dataKey="y" name="成本差异" tick={{ fontSize: 10 }} label={{ value: '成本差异(元)', angle: -90, position: 'insideLeft', offset: 10, fontSize: 12 }} />
<ZAxis type="number" dataKey="size" range={[10, 200]} name="销售额" />
<ReferenceLine y={0} stroke="#666" strokeDasharray="3 3" />
<Tooltip content={({ active, payload }: any) => {
if (active && payload?.length) {
const d = payload[0].payload
return <div className="rounded border bg-card p-2 text-xs shadow-sm">
<p className="font-medium">{d.dish_name}</p>
<p>: {d.category_level1}</p>
<p>: {formatNumber(d.x)}</p>
<p>: {formatCurrency(d.y)}</p>
<p>: {formatCurrency(d.size)}</p>
</div>
}
return null
}} />
{categories.map((cat, i) => (
<Scatter key={cat as string} name={cat as string} data={scatterData.filter((d: any) => d.category_level1 === cat)} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} fillOpacity={0.6} />
))}
</ScatterChart>
</ResponsiveContainer>
<div className="mt-2 flex flex-wrap gap-2">
{categories.map((cat, i) => (
<span key={cat as string} className="flex items-center gap-1 text-xs">
<span className="h-3 w-3 rounded-full" style={{ background: CATEGORY_COLORS[i % CATEGORY_COLORS.length] }} />{cat as string}
</span>
))}
</div>
</CollapsibleSection>
</div>
)
}
@@ -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 <LoadingSpinner text="加载原料差异数据..." />
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 (
<div className="space-y-4">
<CollapsibleSection title="菜品原料差异分解" subtitle="输入菜品编码查看原料级差异">
<div className="mb-3 flex items-center gap-2">
<input
value={searchInput}
onChange={(e) => 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"
/>
<button onClick={() => setDishCode(searchInput)} className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground"></button>
</div>
{dishCode && (
varianceData.length === 0 && !lv ? (
<p className="text-sm text-muted-foreground"> {dishCode} </p>
) : lv ? (
<LoadingSpinner text="加载原料差异..." />
) : (
<>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={varianceData.slice(0, 15)} layout="vertical" margin={{ left: 80 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="material_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip content={({ active, payload }: any) => {
if (active && payload?.length) {
const d = payload[0].payload
return <div className="rounded border bg-card p-2 text-xs shadow-sm">
<p className="font-medium">{d.material_name}</p>
<p>: {formatCurrency(d.loss_amount)}</p>
<p>: {formatPercent(d.loss_rate)}</p>
</div>
}
return null
}} />
<Bar dataKey="loss_amount" name="金额差异">
{varianceData.slice(0, 15).map((d: any, i: number) => (
<Cell key={i} fill={Number(d.loss_amount) < 0 ? '#ef4444' : '#22c55e'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '原料' },
{ key: 'material_unit', label: '单位', align: 'center' },
{ key: 'theo_qty', label: '理论用量', align: 'right', render: (r) => 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 <span className={v < -20 ? 'text-red-600' : v < 0 ? 'text-yellow-600' : 'text-green-600'}>{formatPercent(v)}</span>
}},
{ key: 'loss_amount', label: '金额差异', align: 'right', render: (r) => formatCurrency(r.loss_amount) },
{ key: 'reason', label: '原因', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${reasonColor(r.reason)}`}>{r.reason}</span>
)},
]}
data={varianceData}
/>
</div>
</>
)
)}
</CollapsibleSection>
<CollapsibleSection title="物料损耗率分布" subtitle="全部物料明细的损耗率分档统计" defaultOpen={false}>
<div className="flex gap-4">
<ResponsiveContainer width="40%" height={250}>
<PieChart>
<Pie data={distData} dataKey="cnt" nameKey="loss_band" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.cnt}`}>
{distData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1">
<DataTable
columns={[
{ key: 'loss_band', label: '损耗区间' },
{ key: 'cnt', label: '明细数', align: 'right' },
]}
data={distData}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="超耗物料TOP榜" subtitle="按损耗量排序">
<Pagination page={page} pageSize={PAGE_SIZE} total={topData.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => 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) => <span className="text-red-600">{formatPercent(r.avg_loss_rate)}</span> },
{ 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)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="物料类型损耗对比" subtitle="原材料 vs 半成品" defaultOpen={false}>
<DataTable
columns={[
{ key: 'material_type', label: '物料类型' },
{ key: 'cnt', label: '明细数', align: 'right' },
{ key: 'unique_materials', label: '物料种类', align: 'right' },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => formatPercent(r.avg_loss_rate) },
{ key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) },
]}
data={typeData}
/>
</CollapsibleSection>
</div>
)
}
@@ -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 <LoadingSpinner text="加载成本总览..." />
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总销售额" value={ov.total_sales} format="currency" />
<MetricCard title="理论成本合计" value={ov.total_theo_cost} format="currency" />
<MetricCard title="实际成本合计" value={ov.total_actual_cost} format="currency" />
<MetricCard title="成本差异" value={ov.total_variance} format="currency" description="实际成本 - 理论成本" />
<MetricCard title="平均理论毛利率" value={ov.avg_theo_margin} format="percent" />
<MetricCard title="平均实际毛利率" value={ov.avg_actual_margin} format="percent" />
<MetricCard title="低毛利菜品数" value={ov.low_margin_dishes} format="number" description="理论毛利率 < 50%" />
<MetricCard title="超理论菜品数" value={ov.over_cost_dishes} format="number" description="实际成本超过理论成本" />
</div>
<CollapsibleSection title="品类成本对比" subtitle="按一级品类汇总理论vs实际毛利率">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={cats.slice(0, 10)} margin={{ top: 10, right: 10, bottom: 10, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="category_level1" tick={{ fontSize: 10 }} angle={-30} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 10 }} unit="%" />
<Tooltip content={({ active, payload }: any) => {
if (active && payload?.length) {
const d = payload[0].payload
return <div className="rounded border bg-card p-2 text-xs shadow-sm">
<p className="font-medium">{d.category_level1}</p>
<p>: {Number(d.avg_theo_margin).toFixed(2)}%</p>
<p>: {Number(d.avg_actual_margin).toFixed(2)}%</p>
<p>: {formatCurrency(d.total_sales)}</p>
<p>: {formatCurrency(d.total_variance)}</p>
</div>
}
return null
}} />
<Bar dataKey="avg_theo_margin" name="理论毛利率" fill="#3b82f6" />
<Bar dataKey="avg_actual_margin" name="实际毛利率" fill="#f97316" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
columns={[
{ key: 'category_level1', label: '品类' },
{ key: 'dishes', label: '菜品数', align: 'right' },
{ key: 'avg_theo_margin', label: '理论毛利率', align: 'right', render: (r) => 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 <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
]}
data={cats}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="毛利率偏差分布" subtitle="实际毛利率 vs 理论毛利率偏差" defaultOpen={false}>
<div className="flex gap-4">
<ResponsiveContainer width="40%" height={250}>
<PieChart>
<Pie data={devs} dataKey="cnt" nameKey="deviation_band" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.deviation_band}: ${e.cnt}`}>
{devs.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1">
<DataTable
columns={[
{ key: 'deviation_band', label: '偏差区间' },
{ key: 'cnt', label: '菜品数', align: 'right' },
]}
data={devs}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="成本差异TOP榜" subtitle="实际成本超理论最严重的菜品">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ 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: 'cost_variance', label: '成本差异', align: 'right', render: (r) => {
const v = Number(r.cost_variance || 0)
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'cost_tier', label: '分层', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${tierColor(r.cost_tier)}`}>{r.cost_tier}</span>
)},
]}
data={tops}
/>
</CollapsibleSection>
</div>
)
}
@@ -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 <LoadingSpinner text="加载包装耗材数据..." />
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="包装物料种类" value={ovData.packaging_types} format="number" />
<MetricCard title="包装总成本" value={ovData.total_cost} format="currency" />
<MetricCard title="总损耗量" value={ovData.total_loss_qty} format="number" />
<MetricCard title="高损耗包装数" value={ovData.high_loss_count} format="number" description="损耗率>20%" />
</div>
<CollapsibleSection title="包装物料明细" subtitle="餐盒、餐具、打包袋等耗材使用情况">
<Pagination page={page} pageSize={PAGE_SIZE} total={meta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => 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 <span className={v < -20 ? 'text-red-600' : v < -5 ? 'text-yellow-600' : 'text-green-600'}>{formatPercent(v)}</span>
}},
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{r.status}</span>
)},
]}
data={detailData}
/>
</div>
</CollapsibleSection>
</div>
)
}
@@ -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<string, string> = {
'明星盈利品': '#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 <LoadingSpinner text="加载菜品盈利数据..." />
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<string, string> = {
'明星盈利品': '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 (
<div className="space-y-4">
<CollapsibleSection title="菜单工程矩阵" subtitle="销量 × 实际毛利率四象限分析">
<ResponsiveContainer width="100%" height={400}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="sales_quantity" name="销量" tick={{ fontSize: 10 }} />
<YAxis type="number" dataKey="actual_margin" name="实际毛利率" unit="%" tick={{ fontSize: 10 }} domain={[-100, 100]} />
<ZAxis type="number" dataKey="sales_amount" range={[20, 400]} name="销售额" />
<Tooltip content={({ active, payload }: any) => {
if (active && payload?.length) {
const d = payload[0].payload
return <div className="rounded border bg-card p-2 text-xs shadow-sm">
<p className="font-medium">{d.dish_name}</p>
<p>: {formatNumber(d.sales_quantity)}</p>
<p>: {formatPercent(d.actual_margin)}</p>
<p>: {formatCurrency(d.sales_amount)}</p>
<p>: {d.menu_type}</p>
</div>
}
return null
}} />
{Object.entries(MENU_COLORS).map(([type, color]) => (
<Scatter key={type} name={type} data={matrixData.filter((d: any) => d.menu_type === type)} fill={color} />
))}
</ScatterChart>
</ResponsiveContainer>
<div className="mt-2 flex flex-wrap gap-3">
{Object.entries(MENU_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1 text-xs">
<span className="h-3 w-3 rounded-full" style={{ background: color }} />{type}
</span>
))}
</div>
</CollapsibleSection>
<CollapsibleSection title="菜品盈利明细" subtitle="按销售额降序">
<div className="mb-3 flex items-center gap-2">
<label className="text-xs text-muted-foreground">:</label>
<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-xs">
<option value=""></option>
{categories.map((c: any) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={profitMeta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => 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}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="低毛利菜品预警" subtitle="理论毛利率 < 50% 且销售额 > 1000" defaultOpen={false}>
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'price', label: '售价', align: 'right', render: (r) => 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) => <span className="text-red-600">{formatPercent(r.theo_margin)}</span> },
{ 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}
/>
</CollapsibleSection>
</div>
)
}
@@ -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 <LoadingSpinner text="加载数据质量..." />
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="BOM覆盖率" value={ovData.bom_coverage} format="percent" description="有BOM的SKU占总SKU比例" />
<MetricCard title="菜品匹配率" value={dishMatchRate.toFixed(2)} format="percent" />
<MetricCard title="物料匹配率" value={matMatchRate.toFixed(2)} format="percent" />
</div>
<CollapsibleSection title="BOM数据质量检查" subtitle="每月固定检查项">
<DataTable
columns={[
{ key: 'check', label: '检查项' },
{ key: 'value', label: '值', align: 'right' },
{ key: 'abnormal', label: '异常说明' },
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${checkColor(r.status)}`}>{r.status}</span>
)},
]}
data={qualityChecks}
/>
</CollapsibleSection>
<CollapsibleSection title="未匹配菜品清单" subtitle="菜品成本报表中未匹配到销售数据的菜品" defaultOpen={false}>
<Pagination page={dishPage} pageSize={PAGE_SIZE} total={dishMeta.total} onPageChange={setDishPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'dish_code', label: '编码' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
data={dishData}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="未匹配物料清单" subtitle="未匹配到库存数据的物料" defaultOpen={false}>
<Pagination page={matPage} pageSize={PAGE_SIZE} total={matMeta.total} onPageChange={setMatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料名' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.loss_amount) },
]}
data={matData}
/>
</div>
</CollapsibleSection>
</div>
)
}
@@ -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<string, string> = {
'红色-严重超耗': '#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 <LoadingSpinner text="加载门店成本数据..." />
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="门店总数" value={ovData.total_stores} format="number" />
<MetricCard title="红色-严重超耗" value={ovData.red_count} format="number" />
<MetricCard title="橙色-明显超耗" value={ovData.orange_count} format="number" />
<MetricCard title="绿色-基本正常" value={ovData.green_count} format="number" />
<MetricCard title="灰色-口径异常" value={ovData.gray_count} format="number" />
<MetricCard title="总超耗金额" value={ovData.total_variance} format="currency" />
</div>
<CollapsibleSection title="门店成本差异分布" subtitle="按红橙绿灰分级">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
{pieData.map((d, i) => <Cell key={i} fill={LEVEL_COLORS[d.name]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="门店超耗排名" subtitle="按成本差异金额降序">
<Pagination page={page} pageSize={PAGE_SIZE} total={meta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => 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 <span className={v > 20 ? 'text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{formatPercent(v)}</span>
}},
{ key: 'variance_amount', label: '超耗金额', align: 'right', render: (r) => {
const v = Number(r.variance_amount || 0)
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'negative_item_lines', label: '负库存项', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'variance_level', label: '分级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${levelColor(r.variance_level)}`}>{r.variance_level}</span>
)},
]}
data={rankData}
/>
</div>
</CollapsibleSection>
</div>
)
}
@@ -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<any>(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 <LoadingSpinner text="加载供应链数据..." />
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 (
<div className="space-y-4">
<CollapsibleSection title="原料共用度" subtitle="被大量SKU共用的核心原料 vs 独有原料">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sharingData} layout="vertical" margin={{ left: 80 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="material_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip content={({ active, payload }: any) => {
if (active && payload?.length) {
const d = payload[0].payload
return <div className="rounded border bg-card p-2 text-xs shadow-sm">
<p className="font-medium">{d.material_name}</p>
<p> {d.sku_count} SKU使用</p>
<p>: {d.sharing_type}</p>
</div>
}
return null
}} />
<Bar dataKey="sku_count" name="被引用SKU数">
{sharingData.map((d: any, i: number) => (
<Cell key={i} fill={d.sharing_type === '核心原料' ? '#ef4444' : d.sharing_type === '独有原料' ? '#a3a3a3' : '#3b82f6'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'sku_count', label: '被引用SKU数', align: 'right', render: (r) => formatNumber(r.sku_count) },
{ key: 'sharing_type', label: '分类', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${sharingColor(r.sharing_type)}`}>{r.sharing_type}</span>
)},
]}
data={sharingData}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="独有原料风险" subtitle="低收入SKU占用专用原料">
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskMeta.total} onPageChange={setRiskPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'unique_material_count', label: '独有原料数', align: 'right', render: (r) => <span className={r.unique_material_count > 3 ? 'text-red-600' : ''}>{formatNumber(r.unique_material_count)}</span> },
{ key: 'risk_level', label: '风险等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${riskLevelColor(r.risk_level)}`}>{r.risk_level}</span>
)},
]}
data={riskData}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="SKU精简模拟器" subtitle="输入SKU编码模拟下架影响" defaultOpen={false}>
<div className="mb-3 flex flex-col gap-2">
<textarea
value={skuInput}
onChange={(e) => setSkuInput(e.target.value)}
placeholder="输入SKU编码,逗号或换行分隔,如: 66920, 12051, 51040"
className="rounded border px-3 py-2 text-sm w-full h-20"
/>
<button onClick={handleSimulate} disabled={simLoading} className="self-start rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50">
{simLoading ? '模拟中...' : '模拟下架影响'}
</button>
</div>
{simResult && !simResult.error && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 mb-3">
<MetricCard title="收入影响" value={simResult.summary?.revenue_impact} format="currency" />
<MetricCard title="毛利影响" value={simResult.summary?.profit_impact} format="currency" />
<MetricCard title="可减少原料数" value={simResult.summary?.releasable_materials} format="number" />
<MetricCard title="仍需采购的共用原料" value={simResult.summary?.shared_materials} format="number" />
<MetricCard title="负毛利菜品数" value={simResult.summary?.negative_margin_count} format="number" />
</div>
{simResult.releasable_materials?.length > 0 && (
<DataTable
columns={[
{ key: 'material_name', label: '可释放物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'inventory_value', label: '库存金额', align: 'right', render: (r) => formatCurrency(r.inventory_value) },
]}
data={simResult.releasable_materials}
/>
)}
</>
)}
{simResult?.error && <p className="text-sm text-red-600">{simResult.error}</p>}
</CollapsibleSection>
<CollapsibleSection title="BOM驱动物料需求预测" subtitle="按历史销量×标准用量估算" defaultOpen={false}>
<Pagination page={demandPage} pageSize={PAGE_SIZE} total={demandData.length} onPageChange={setDemandPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'base_unit', label: '单位', align: 'center' },
{ key: 'estimated_demand', label: '预计需求量', align: 'right', render: (r) => 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)}
/>
</div>
</CollapsibleSection>
</div>
)
}
+51
View File
@@ -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 (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">BOM与成本报表的多维度运营分析</p>
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="mt-4">
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'profitability' && <ProfitabilityTab />}
{activeTab === 'material' && <MaterialTab />}
{activeTab === 'bom' && <BomTab />}
{activeTab === 'supply' && <SupplyTab />}
{activeTab === 'packaging' && <PackagingTab />}
{activeTab === 'quality' && <QualityTab />}
{activeTab === 'store' && <StoreTab />}
{activeTab === 'explore' && <ExploreTab />}
{activeTab === 'adjustment' && <AdjustmentTab />}
</div>
</div>
)
}
+2
View File
@@ -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)
+978
View File
@@ -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
+525
View File
@@ -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 |
+173
View File
@@ -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%,重新核算成本或调整售价
+607
View File
@@ -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 |
| 低销高利品 | 低销售 | 高毛利 | 改善陈列和推荐 |
| 低销低利品 | 低销售 | 低毛利 | 可能占用独有原料,优先下架 |
| 数据异常品 | — | 成本率异常 | 先修复数据 |
---
### 模块4BOM复杂度分析
**解决的问题**:哪些菜品配方太复杂、培训和执行难度高
| 指标 | 定义 | 计算方式 | 数据来源 |
|------|------|---------|---------|
| 原料种数 | 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抽核查
---
### 模块9BOM数据质量分析
**解决的问题**:配方、单位和分摊数据是否可靠
| 指标 | 定义 | 计算方式 | 数据来源 |
|------|------|---------|---------|
| 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数据)
### 模块20BOM覆盖率分析
**解决的问题**:有多少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的SKU168个
---
### 模块21BOM适用门店覆盖
**解决的问题**:哪些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` |
| 地图标注颜色 | 按分级着色 | 红/橙/绿/灰 | 可视化 |
---
### 模块27BOM版本时效监控
**解决的问题**:过期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. 模块1SKU理论与实际成本及毛利
2. 模块2SKU原料差异贡献
3. 模块4BOM复杂度与独有原料
4. 模块6SKU精简供应链影响模拟
5. 模块8:包装耗材每单成本
### 第二批(成本执行管理)
6. 模块3:菜品盈利与菜单工程
7. 模块7:原料共用度和标准化
8. 模块9BOM数据质量分析
9. 模块10:品类成本对比
10. 模块11:物料损耗率分布
### 第三批(门店级分析)
11. 模块15:门店成本差异分布
12. 模块16:门店超耗金额排名
13. 模块17:门店理论vs实际成本率
14. 模块26:门店成本分级地图
### 第四批(深度分析)
15. 模块5:独有原料风险
16. 模块12:菜品定价合理性
17. 模块13BOM驱动物料需求预测
18. 模块14:物料匹配率监控
19. 模块24:成本差异vs销量散点图
20. 模块25:毛利率偏差分布
### 分析闭环
```
卖得怎么样(模块3
→ 实际赚不赚钱(模块1
→ 为什么成本异常(模块2
→ 配方是否复杂(模块4
→ 是否应该保留(模块6
→ 下架后能减少什么成本(模块6
```
+410
View File
@@ -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-087天后)
→ 对比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-1514天后)
→ 对比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 |