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:
@@ -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 />} />
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user