fix: 修复门店评估数据不一致问题

- 恢复被误删的5家门店旧账单数据(58377行)
- 合并哈马尔罕大钟寺店费用到大钟寺店(0022)
- 修改v_store_operating_expense_monthly视图按sales_store_code聚合费用
- 修复利润机会池食材成本差异为负时opportunity不为负
- 修复前端totalOpportunity只累加正数机会
- 刷新bill_fact/mv_store_risk_rating_monthly等物化视图
- 新增PromotionPage和promotion路由
This commit is contained in:
freedakgmail
2026-08-11 07:26:11 +08:00
parent 353dad24a2
commit 3f1c291486
30 changed files with 4521 additions and 149 deletions
+2
View File
@@ -46,6 +46,7 @@ import { ProductLifecyclePage } from './pages/ProductLifecyclePage'
import { DataImportPage } from './pages/DataImportPage'
import { EnterprisePage } from './pages/EnterprisePage'
import { IntelligencePage } from './pages/IntelligencePage'
import { PromotionPage } from './pages/PromotionPage'
import { RoleRoute } from './components/RoleRoute'
const queryClient = new QueryClient({
@@ -133,6 +134,7 @@ export default function App() {
<Route path="/data-import" element={<RoleRoute roles={['hq','dept']}><DataImportPage /></RoleRoute>} />
<Route path="/enterprise" element={<RoleRoute roles={['hq','dept']}><EnterprisePage /></RoleRoute>} />
<Route path="/intelligence" element={<RoleRoute roles={['hq','dept']}><IntelligencePage /></RoleRoute>} />
<Route path="/promotion" element={<RoleRoute roles={['hq','dept']}><PromotionPage /></RoleRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+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, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2, Target, Bell, Timer, TrafficCone, Boxes, Download, Briefcase, Cpu } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2, Target, Bell, Timer, TrafficCone, Boxes, Download, Briefcase, Cpu, Tag } from 'lucide-react'
import { cn } from '@/lib/utils'
import { StatusBanner } from '@/components/StatusBanner'
@@ -69,6 +69,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
{ path: '/menu-engineering', label: '菜单工程', icon: Package, roles: ['hq', 'dept'] },
{ path: '/promotion', label: '促销增量', icon: Tag, roles: ['hq', 'dept'] },
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/employee-performance', label: '员工绩效', icon: CalendarClock, roles: ['hq', 'dept'] },
+12 -2
View File
@@ -73,42 +73,52 @@ export function BossPage() {
const { data: overview, isLoading: odLoading } = useQuery({
queryKey: ['overview', month],
queryFn: () => api.get('/overview', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: daily, isLoading: dailyLoading } = useQuery({
queryKey: ['overview/daily', month],
queryFn: () => api.get('/overview/daily', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: expenseData, isLoading: exLoading } = useQuery({
queryKey: ['store-expense/overview', month],
queryFn: () => api.get('/store-expense/overview', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: waterfallData, isLoading: wfLoading } = useQuery({
queryKey: ['overview/profit-waterfall', month],
queryFn: () => api.get('/overview/profit-waterfall', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority', month],
queryFn: () => api.get('/stores/priority', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: costOverview, isLoading: costLoading } = useQuery({
queryKey: ['cost-analysis/store-overview', month],
queryFn: () => api.get('/cost-analysis/store-overview', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: expenseStructure, isLoading: structLoading } = useQuery({
queryKey: ['store-expense/expense-structure', month],
queryFn: () => api.get('/store-expense/expense-structure', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: profitRankingData } = useQuery({
queryKey: ['overview/store-profit-ranking', month],
queryFn: () => api.get('/overview/store-profit-ranking', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: profitOppData } = useQuery({
queryKey: ['overview/profit-opportunity', month],
queryFn: () => api.get('/overview/profit-opportunity', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
@@ -123,7 +133,7 @@ export function BossPage() {
const structRows = (expenseStructure as any)?.data || []
const profitRanking = (profitRankingData as any)?.data || []
const profitOpp = (profitOppData as any)?.data?.items || []
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Number(o.opportunity || 0), 0)
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Math.max(Number(o.opportunity || 0), 0), 0)
// 风险分布
const riskSummary = riskRows.reduce((acc: any, r: any) => {
@@ -235,7 +245,7 @@ export function BossPage() {
{/* ① 核心经营指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<MetricCard title="营业收入" value={ex?.total_consumption} format="currency" description="全部门店消费金额合计" />
<MetricCard title="营业收入" value={od?.consumption} format="currency" description="全部门店消费金额合计" />
<MetricCard title="优惠" value={ex?.total_discount} format="currency" description="各类优惠扣减合计" />
<MetricCard title="实收总额" value={ex?.total_received} format="currency" trend={trendReceived} description={`全口径${ex?.total_stores}家门店。环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} />
<MetricCard title="门店贡献利润估算" value={ex?.actual_net_profit} format="currency" status={Number(ex?.actual_net_margin_pct) < 5 ? 'bad' : Number(ex?.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`贡献率 ${formatPercent(ex?.actual_net_margin_pct)}`} />
+57 -1
View File
@@ -87,6 +87,12 @@ export function EnterprisePage() {
enabled: tab === 'audit',
})
const { data: benchmarkData, isLoading: benchmarkLoading } = useQuery({
queryKey: ['enterprise/supplier-benchmark'],
queryFn: () => api.get('/enterprise/supplier-benchmark'),
enabled: tab === 'benchmark',
})
const approveMutation = useMutation({
mutationFn: ({ id, result, comment }: { id: number; result: string; comment?: string }) =>
api.post(`/enterprise/approvals/${id}/approve`, { result, comment }),
@@ -98,7 +104,7 @@ export function EnterprisePage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['enterprise/notifications'] }),
})
if ((tab === 'supplier' && supplierLoading) || (tab === 'approval' && approvalLoading) || (tab === 'notification' && notifLoading) || (tab === 'hr' && talentLoading) || (tab === 'lms' && courseLoading) || (tab === 'marketing' && campaignLoading) || (tab === 'finance' && budgetLoading) || (tab === 'loop' && loopLoading) || (tab === 'audit' && auditLoading)) {
if ((tab === 'supplier' && supplierLoading) || (tab === 'approval' && approvalLoading) || (tab === 'notification' && notifLoading) || (tab === 'hr' && talentLoading) || (tab === 'lms' && courseLoading) || (tab === 'marketing' && campaignLoading) || (tab === 'finance' && budgetLoading) || (tab === 'loop' && loopLoading) || (tab === 'audit' && auditLoading) || (tab === 'benchmark' && benchmarkLoading)) {
return <LoadingSpinner text="加载企业协同数据..." />
}
@@ -114,6 +120,9 @@ export function EnterprisePage() {
const budgets = (budgetData as any)?.data || []
const loops = (loopData as any)?.data || []
const auditLogs = (auditData as any)?.data || []
const benchmark = (benchmarkData as any)?.data || {}
const benchmarkSummary = benchmark.summary || {}
const benchmarks = benchmark.benchmarks || []
const gradeDist = suppliers.reduce((acc: Record<string, number>, r: any) => {
acc[r.grade] = (acc[r.grade] || 0) + 1
@@ -138,6 +147,7 @@ export function EnterprisePage() {
<Tabs tabs={[
{ key: 'supplier', label: '供应链SRM' },
{ key: 'benchmark', label: '供应商对标' },
{ key: 'approval', label: '审批流' },
{ key: 'notification', label: '消息中心' },
{ key: 'hr', label: 'HR人才' },
@@ -213,6 +223,52 @@ export function EnterprisePage() {
</>
)}
{tab === 'benchmark' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="对标SKU数" value={benchmarkSummary.total_skus || 0} format="number" description="有多供应商报价的SKU" />
<MetricCard title="价格差异大" value={benchmarkSummary.high_variance || 0} format="number" description="价差>20%的SKU" status="bad" />
<MetricCard title="有优化空间" value={benchmarkSummary.optimizable || 0} format="number" description="价差10-20%的SKU" status="warn" />
<MetricCard title="潜在节约" value={benchmarkSummary.total_potential_savings || 0} format="currency" description="最高价-最低价之和" status="good" />
</div>
<CollapsibleSection title={`供应商价格对标 (${benchmarks.length})`} subtitle="同SKU跨供应商价格对比,识别最优供应商">
<FilterableTable
data={benchmarks}
filterKey="benchmark_status"
filterLabel="全部状态"
sortOptions={[
{ key: 'price_variance_pct', label: '价差%' },
{ key: 'potential_savings_per_unit', label: '潜在节约' },
{ key: 'avg_price', label: '均价' },
{ key: 'supplier_count', label: '供应商数' },
]}
defaultSort="price_variance_pct"
defaultOrder="desc"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'sku_name', label: 'SKU名称' },
{ key: 'supplier_count', label: '供应商数', align: 'center', render: (r) => formatNumber(r.supplier_count) },
{ key: 'min_price', label: '最低价', align: 'right', render: (r) => <span className="text-green-600">{formatCurrency(r.min_price)}</span> },
{ key: 'max_price', label: '最高价', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.max_price)}</span> },
{ key: 'avg_price', label: '均价', align: 'right', render: (r) => formatCurrency(r.avg_price) },
{ key: 'price_variance_pct', label: '价差%', align: 'right', render: (r) => {
const v = Number(r.price_variance_pct || 0)
return <span className={v > 20 ? 'text-red-600 font-bold' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'best_supplier_name', label: '最优供应商' },
{ key: 'best_price', label: '最优价', align: 'right', render: (r) => <span className="text-green-600 font-medium">{formatCurrency(r.best_price)}</span> },
{ key: 'potential_savings_per_unit', label: '单件节约', align: 'right', render: (r) => formatCurrency(r.potential_savings_per_unit) },
{ key: 'benchmark_status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { '价格差异大': 'bg-red-500 text-white', '有优化空间': 'bg-yellow-500 text-white', '价格稳定': 'bg-green-500 text-white' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.benchmark_status] || ''}`}>{r.benchmark_status}</span>
}},
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'approval' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
+122 -5
View File
@@ -5,20 +5,31 @@ import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { Tabs } from '@/components/Tabs'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const URGENCY_COLORS: Record<string, string> = { '紧急': '#ef4444', '预警': '#eab308', '关注': '#3b82f6', '正常': '#22c55e' }
export function InventoryTurnoverPage() {
const [month, setMonth] = useState('2026-04')
const [tab, setTab] = useState('turnover')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/inventory/turnover', month],
queryFn: () => api.get('/analytics-enhanced/inventory/turnover', { params: { month } }),
enabled: tab === 'turnover',
})
if (isLoading) {
return <LoadingSpinner text="加载库存周转数据..." />
const { data: expiryData, isLoading: expiryLoading } = useQuery({
queryKey: ['analytics-enhanced/inventory/near-expiry', month],
queryFn: () => api.get('/analytics-enhanced/inventory/near-expiry', { params: { month } }),
enabled: tab === 'expiry',
})
if ((tab === 'turnover' && isLoading) || (tab === 'expiry' && expiryLoading)) {
return <LoadingSpinner text="加载库存数据..." />
}
const turnover = (data as any)?.data?.turnover || []
@@ -43,16 +54,120 @@ export function InventoryTurnoverPage() {
损耗金额: Number(r.waste_value || 0),
}))
const expiry = (expiryData as any)?.data || {}
const nearExpiry = expiry.near_expiry || []
const storeRisk = expiry.store_risk || []
const expirySummary = expiry.summary || {}
const urgencyPieData = [
{ name: '紧急', value: expirySummary.urgent || 0 },
{ name: '预警', value: expirySummary.warning || 0 },
{ name: '关注', value: expirySummary.watch || 0 },
].filter(d => d.value > 0)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · TOP · · {month}</p>
<p className="mt-0.5 text-xs text-muted-foreground"> · TOP · · {month}</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
<Tabs tabs={[
{ key: 'turnover', label: '库存周转' },
{ key: 'expiry', label: '临期预警' },
]} active={tab} onChange={setTab} />
{tab === 'expiry' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="临期商品数" value={expirySummary.total_items || 0} format="number" description="有库存的商品总数" />
<MetricCard title="紧急(≤3天)" value={expirySummary.urgent || 0} format="number" description="预计3天内消耗完" status="bad" />
<MetricCard title="预警(≤7天)" value={expirySummary.warning || 0} format="number" description="预计7天内消耗完" status="warn" />
<MetricCard title="潜在损失" value={expirySummary.potential_loss || 0} format="currency" description="紧急+预警商品50%估值" status="bad" />
</div>
{urgencyPieData.length > 0 && (
<CollapsibleSection title="临期紧急度分布" subtitle="按预计消耗天数分类">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={urgencyPieData} cx="50%" cy="50%" outerRadius={60} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{urgencyPieData.map((e, i) => <Cell key={i} fill={URGENCY_COLORS[e.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '商品数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`门店临期风险 (${storeRisk.length})`} subtitle="按临期风险金额排序">
<FilterableTable
data={storeRisk}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'at_risk_value', label: '风险金额' },
{ key: 'urgent_items', label: '紧急数' },
{ key: 'warning_items', label: '预警数' },
{ key: 'total_waste', label: '损耗金额' },
]}
defaultSort="at_risk_value"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店名称' },
{ key: 'urgent_items', label: '紧急(≤3天)', align: 'right', render: (r) => {
const v = Number(r.urgent_items || 0)
return <span className={v > 0 ? 'text-red-600 font-bold' : ''}>{formatNumber(v)}</span>
}},
{ key: 'warning_items', label: '预警(≤7天)', align: 'right', render: (r) => {
const v = Number(r.warning_items || 0)
return <span className={v > 0 ? 'text-yellow-600 font-medium' : ''}>{formatNumber(v)}</span>
}},
{ key: 'at_risk_value', label: '风险金额', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.at_risk_value)}</span> },
{ key: 'total_waste', label: '当月损耗', align: 'right', render: (r) => formatCurrency(r.total_waste) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title={`临期商品明细 (${nearExpiry.length})`} subtitle="按紧急度排序,含处置建议">
<FilterableTable
data={nearExpiry}
filterKey="urgency_level"
filterLabel="全部级别"
sortOptions={[
{ key: 'estimated_days_to_consume', label: '预计天数' },
{ key: 'stock_value', label: '库存金额' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="estimated_days_to_consume"
defaultOrder="asc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'material_name', label: '物料名称' },
{ key: 'category', label: '品类' },
{ key: 'stock_qty', label: '库存量', align: 'right', render: (r) => formatNumber(r.stock_qty) },
{ key: 'stock_value', label: '库存金额', align: 'right', render: (r) => formatCurrency(r.stock_value) },
{ key: 'estimated_days_to_consume', label: '预计天数', align: 'right', render: (r) => {
const v = Number(r.estimated_days_to_consume || 0)
return v ? <span className={v <= 3 ? 'text-red-600 font-bold' : v <= 7 ? 'text-yellow-600' : v <= 14 ? 'text-blue-600' : ''}>{v.toFixed(1)}</span> : '-'
}},
{ key: 'urgency_level', label: '紧急度', align: 'center', render: (r) => {
const colors: Record<string, string> = { '紧急': 'bg-red-500 text-white', '预警': 'bg-yellow-500 text-white', '关注': 'bg-blue-500 text-white', '正常': 'bg-green-500 text-white' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.urgency_level] || ''}`}>{r.urgency_level}</span>
}},
{ key: 'suggested_action', label: '建议措施' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'turnover' && (
<>
{/* 概览指标 */}
<CollapsibleSection title="库存总览" subtitle="全门店库存汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
@@ -154,6 +269,8 @@ export function InventoryTurnoverPage() {
]}
/>
</CollapsibleSection>
</>
)}
</div>
)
}
+2 -2
View File
@@ -8,8 +8,8 @@ interface LoginPageProps {
export function LoginPage({ onLogin }: LoginPageProps) {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [username, setUsername] = useState('平台管理员')
const [password, setPassword] = useState('123')
const [error, setError] = useState('')
const handleLogin = async () => {
+199
View File
@@ -0,0 +1,199 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line } from 'recharts'
export function PromotionPage() {
const [month, setMonth] = useState('2026-04')
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['promotion/overview', month],
queryFn: () => api.get('/promotion/overview', { params: { month } }),
})
const { data: planData, isLoading: planLoading } = useQuery({
queryKey: ['promotion/by-plan', month],
queryFn: () => api.get('/promotion/by-plan', { params: { month } }),
})
const { data: storeData, isLoading: storeLoading } = useQuery({
queryKey: ['promotion/by-store', month],
queryFn: () => api.get('/promotion/by-store', { params: { month } }),
})
const { data: trendData, isLoading: trendLoading } = useQuery({
queryKey: ['promotion/daily-trend', month],
queryFn: () => api.get('/promotion/daily-trend', { params: { month } }),
})
const pageLoading = overviewLoading || planLoading || storeLoading || trendLoading
if (pageLoading) {
return <LoadingSpinner text="加载促销增量利润数据..." />
}
const overview = (overviewData as any)?.data || {}
const plans = (planData as any)?.data || []
const stores = (storeData as any)?.data || []
const trends = (trendData as any)?.data || []
const trendChartData = trends.map((t: any) => ({
date: t.date.substring(8, 10),
促销客单: Math.round(Number(t.promo_avg_bill) * 100) / 100,
非促销客单: Math.round(Number(t.baseline_avg_bill) * 100) / 100,
增量: Math.round(Number(t.uplift) * 100) / 100,
}))
const planChartData = plans.slice(0, 10).map((p: any) => ({
name: p.marketing_plan?.length > 8 ? p.marketing_plan.substring(0, 8) + '...' : p.marketing_plan,
增量收入: Math.round(Number(p.incremental_revenue || 0)),
促销成本: Math.round(Number(p.promotion_cost || 0)),
净增量利润: Math.round(Number(p.net_incremental_profit || 0)),
}))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">vs非促销对比 · · ROI · {month}</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="促销增量总览" subtitle="促销 vs 非促销基准对比">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="促销账单数" value={overview.promo_bills || 0} format="number" description="有营销方案的账单数" />
<MetricCard title="促销占比" value={overview.promo_share_pct || 0} format="percent" description="促销账单占总账单比例" />
<MetricCard title="客单价提升" value={overview.bill_uplift_pct || 0} format="percent" description="促销客单价 vs 非促销客单价" status={(overview.bill_uplift_pct || 0) > 0 ? 'good' : 'bad'} />
<MetricCard title="促销ROI" value={overview.promo_roi || 0} format="number" description="增量利润/促销成本" status={(overview.promo_roi || 0) > 1 ? 'good' : 'warn'} />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="增量收入" value={overview.incremental_revenue || 0} format="currency" description="(促销客单-基准客单)×促销单数" status={(overview.incremental_revenue || 0) > 0 ? 'good' : 'bad'} />
<MetricCard title="促销成本" value={overview.promotion_cost || 0} format="currency" description="促销账单优惠总额" status="bad" />
<MetricCard title="净增量利润" value={overview.net_incremental_profit || 0} format="currency" description="增量收入-促销成本" status={(overview.net_incremental_profit || 0) > 0 ? 'good' : 'bad'} />
<MetricCard title="促销毛利率" value={overview.promo_margin_pct || 0} format="percent" description="促销账单理论毛利率" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="促销日趋势" subtitle="每日促销vs非促销客单价对比">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 100 ? `${v}` : v} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend />
<Line type="monotone" dataKey="促销客单" stroke="#22c55e" strokeWidth={2} />
<Line type="monotone" dataKey="非促销客单" stroke="#94a3b8" strokeWidth={2} />
<Line type="monotone" dataKey="增量" stroke="#3b82f6" strokeWidth={2} strokeDasharray="5 5" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="各促销方案增量对比" subtitle="TOP10方案的增量收入vs促销成本">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={planChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-20} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend />
<Bar dataKey="增量收入" fill="#22c55e" />
<Bar dataKey="促销成本" fill="#ef4444" />
<Bar dataKey="净增量利润" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 按方案明细 */}
<CollapsibleSection title={`促销方案对比 (${plans.length})`} subtitle="各营销方案的增量利润分析">
<FilterableTable
data={plans}
filterKey="marketing_plan"
filterLabel="全部方案"
sortOptions={[
{ key: 'total_revenue', label: '促销收入' },
{ key: 'bill_count', label: '账单数' },
{ key: 'net_incremental_profit', label: '净增量利润' },
{ key: 'promo_roi', label: 'ROI' },
{ key: 'bill_uplift_pct', label: '客单提升' },
]}
defaultSort="total_revenue"
defaultOrder="desc"
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'store_count', label: '覆盖门店', align: 'right', render: (r) => formatNumber(r.store_count) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'bill_uplift_pct', label: '客单提升', align: 'right', render: (r) => {
const v = Number(r.bill_uplift_pct || 0)
return <span className={v > 0 ? 'text-green-600' : 'text-red-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
}},
{ key: 'total_revenue', label: '促销收入', align: 'right', render: (r) => formatCurrency(r.total_revenue) },
{ key: 'total_discount', label: '优惠总额', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.total_discount)}</span> },
{ key: 'incremental_revenue', label: '增量收入', align: 'right', render: (r) => formatCurrency(r.incremental_revenue) },
{ key: 'net_incremental_profit', label: '净增量利润', align: 'right', render: (r) => {
const v = Number(r.net_incremental_profit || 0)
return <span className={v >= 0 ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>{formatCurrency(v)}</span>
}},
{ key: 'promo_roi', label: 'ROI', align: 'right', render: (r) => {
const v = Number(r.promo_roi || 0)
return <span className={v > 1 ? 'text-green-600 font-bold' : v > 0 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(2)}x</span>
}},
{ key: 'member_share_pct', label: '会员占比', align: 'right', render: (r) => `${Number(r.member_share_pct || 0).toFixed(1)}%` },
]}
/>
</CollapsibleSection>
{/* 按门店明细 */}
<CollapsibleSection title={`门店促销效果 (${stores.length})`} subtitle="各门店的促销增量利润">
<FilterableTable
data={stores}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'promo_revenue', label: '促销收入' },
{ key: 'net_incremental_profit', label: '净增量利润' },
{ key: 'uplift_pct', label: '客单提升' },
{ key: 'promo_roi', label: 'ROI' },
{ key: 'plan_count', label: '方案数' },
]}
defaultSort="net_incremental_profit"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店名称' },
{ key: 'plan_count', label: '方案数', align: 'center' },
{ key: 'promo_bills', label: '促销单数', align: 'right', render: (r) => formatNumber(r.promo_bills) },
{ key: 'promo_avg_bill', label: '促销客单', align: 'right', render: (r) => formatCurrency(r.promo_avg_bill) },
{ key: 'baseline_avg_bill', label: '基准客单', align: 'right', render: (r) => formatCurrency(r.baseline_avg_bill) },
{ key: 'uplift_pct', label: '客单提升', align: 'right', render: (r) => {
const v = Number(r.uplift_pct || 0)
return v ? <span className={v > 0 ? 'text-green-600' : 'text-red-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span> : '-'
}},
{ key: 'incremental_revenue', label: '增量收入', align: 'right', render: (r) => formatCurrency(r.incremental_revenue) },
{ key: 'promotion_cost', label: '促销成本', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.promotion_cost)}</span> },
{ key: 'net_incremental_profit', label: '净增量利润', align: 'right', render: (r) => {
const v = Number(r.net_incremental_profit || 0)
return <span className={v >= 0 ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>{formatCurrency(v)}</span>
}},
{ key: 'promo_roi', label: 'ROI', align: 'right', render: (r) => {
const v = Number(r.promo_roi || 0)
return v ? <span className={v > 1 ? 'text-green-600 font-bold' : v > 0 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(2)}x</span> : '-'
}},
]}
/>
</CollapsibleSection>
</div>
)
}
+60 -14
View File
@@ -21,13 +21,17 @@ function SectionLoading({ text }: { text: string }) {
}
export function RiskPage() {
const [riskTab, setRiskTab] = useState<'anomaly' | 'zero' | 'cashier'>('anomaly')
const [riskTab, setRiskTab] = useState<'anomaly' | 'full_discount' | 'empty_bill' | 'cashier'>('anomaly')
const [month, setMonth] = useState('2026-04')
const [anomalyPage, setAnomalyPage] = useState(1)
const [anomalyStore, setAnomalyStore] = useState('')
const [anomalyReason, setAnomalyReason] = useState('')
const [anomalyCashier, setAnomalyCashier] = useState('')
const [debouncedCashier, setDebouncedCashier] = useState('')
const [anomalySort, setAnomalySort] = useState('consumption')
const [anomalyOrder, setAnomalyOrder] = useState<'asc' | 'desc'>('desc')
const [zeroPage, setZeroPage] = useState(1)
const [zeroType, setZeroType] = useState<'全额优惠' | '无消费无优惠'>('全额优惠')
useEffect(() => {
const t = setTimeout(() => setDebouncedCashier(anomalyCashier), 400)
@@ -35,15 +39,14 @@ export function RiskPage() {
}, [anomalyCashier])
const { data: anomalyData, isLoading: anomalyLoading, isFetching: anomalyFetching } = useQuery({
queryKey: ['risk/anomaly', month, anomalyPage, anomalyStore, anomalyReason, debouncedCashier],
queryFn: () => api.get('/risk/anomaly', { params: { month, page: anomalyPage, page_size: ANOMALY_PAGE_SIZE, ...(anomalyStore ? { store: anomalyStore } : {}), ...(anomalyReason ? { reason: anomalyReason } : {}), ...(debouncedCashier ? { cashier: debouncedCashier } : {}) } }),
queryKey: ['risk/anomaly', month, anomalyPage, anomalyStore, anomalyReason, debouncedCashier, anomalySort, anomalyOrder],
queryFn: () => api.get('/risk/anomaly', { params: { month, page: anomalyPage, page_size: ANOMALY_PAGE_SIZE, sort: anomalySort, order: anomalyOrder, ...(anomalyStore ? { store: anomalyStore } : {}), ...(anomalyReason ? { reason: anomalyReason } : {}), ...(debouncedCashier ? { cashier: debouncedCashier } : {}) } }),
placeholderData: keepPreviousData,
})
const { data: zeroData, isLoading: zeroLoading, isFetching: zeroFetching } = useQuery({
queryKey: ['risk/zero-received', month],
queryFn: () => api.get('/risk/zero-received', { params: { month } }),
staleTime: 5 * 60 * 1000,
queryKey: ['risk/zero-received', month, zeroType, zeroPage],
queryFn: () => api.get('/risk/zero-received', { params: { month, type: zeroType, page: zeroPage, page_size: ANOMALY_PAGE_SIZE } }),
placeholderData: keepPreviousData,
})
@@ -57,13 +60,14 @@ export function RiskPage() {
const anomalyRows = (anomalyData as any)?.data || []
const anomalyMeta = (anomalyData as any)?.meta || { total: 0 }
const zeroRows = (zeroData as any)?.data || []
const zeroMeta = (zeroData as any)?.meta || { total: 0, full_discount: 0, empty_bill: 0 }
const cashierRows = (cashierData as any)?.data || []
const storeNames: string[] = Array.from(new Set<string>(cashierRows.map((r: any) => r.store_name as string).filter(Boolean))).sort()
const initialLoading = !anomalyData && !zeroData && !cashierData
const totalAnomaly = anomalyMeta.total || anomalyRows.length
const totalZero = zeroRows.length
const totalZero = zeroMeta.total || zeroRows.length
const highRiskCashiers = cashierRows.filter((r: any) => Number(r.anomaly_rate_pct || 0) >= 10).length
const avgAnomalyRate = cashierRows.length > 0
? cashierRows.reduce((s: number, r: any) => s + Number(r.anomaly_rate_pct || 0), 0) / cashierRows.length
@@ -87,9 +91,10 @@ export function RiskPage() {
{/* 概览指标 */}
<CollapsibleSection title="风险概览" subtitle="异常账单与收银操作风险汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<MetricCard title="异常账单数" value={totalAnomaly} format="number" description="触发异常规则的账单数量,需逐单核查" />
<MetricCard title="零实收账单" value={totalZero} format="number" description="实收为0的账单,可能为全额优惠或操作异常" />
<MetricCard title="全额优惠账单" value={zeroMeta.full_discount || 0} format="number" description="消费>0但实收=0,需核查是否违规免单" />
<MetricCard title="空单账单" value={zeroMeta.empty_bill || 0} format="number" description="无消费无优惠的空单,需排查操作异常" />
<MetricCard title="高风险收银员(≥10%)" value={highRiskCashiers} format="number" description="异常操作率≥10%的收银员数量" />
<MetricCard title="平均异常率" value={avgAnomalyRate} format="percent" description="所有收银员的平均异常操作率" />
</div>
@@ -118,8 +123,11 @@ export function RiskPage() {
<button onClick={() => setRiskTab('anomaly')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'anomaly' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({totalAnomaly})
</button>
<button onClick={() => setRiskTab('zero')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'zero' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({totalZero})
<button onClick={() => { setRiskTab('full_discount'); setZeroType('全额优惠'); setZeroPage(1) }} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'full_discount' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({zeroMeta.full_discount || 0})
</button>
<button onClick={() => { setRiskTab('empty_bill'); setZeroType('无消费无优惠'); setZeroPage(1) }} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'empty_bill' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({zeroMeta.empty_bill || 0})
</button>
<button onClick={() => setRiskTab('cashier')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'cashier' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({cashierRows.length})
@@ -161,6 +169,10 @@ export function RiskPage() {
searchPlaceholder="搜索收银员..."
serverSearchValue={anomalyCashier}
onServerSearchChange={(v) => { setAnomalyCashier(v); setAnomalyPage(1) }}
sort={anomalySort}
order={anomalyOrder}
onSortChange={(v) => { setAnomalySort(v); setAnomalyPage(1) }}
onOrderChange={(v) => { setAnomalyOrder(v); setAnomalyPage(1) }}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
@@ -183,10 +195,45 @@ export function RiskPage() {
</>
)}
{riskTab === 'zero' && (
zeroFetching ? <SectionLoading text="加载零实收数据..." /> :
{riskTab === 'full_discount' && (
zeroFetching ? <SectionLoading text="加载全额优惠数据..." /> :
<FilterableTable
data={zeroRows}
serverSide
page={zeroPage}
onPageChange={setZeroPage}
pageSize={ANOMALY_PAGE_SIZE}
total={zeroMeta.total}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="consumption"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
{riskTab === 'empty_bill' && (
zeroFetching ? <SectionLoading text="加载空单数据..." /> :
<FilterableTable
data={zeroRows}
serverSide
page={zeroPage}
onPageChange={setZeroPage}
pageSize={ANOMALY_PAGE_SIZE}
total={zeroMeta.total}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
@@ -202,7 +249,6 @@ export function RiskPage() {
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'zero_received_type', label: '零实收类型' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
+122 -9
View File
@@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts'
import { Tabs } from '@/components/Tabs'
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -14,11 +15,19 @@ const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#e
export function StoreGradePage() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [selectedStore, setSelectedStore] = useState<any>(null)
const [tab, setTab] = useState('grade')
const queryClient = useQueryClient()
const { data: gradeData, isLoading: gradeLoading } = useQuery({
queryKey: ['store-grade/grades', month],
queryFn: () => api.get('/store-grade/grades', { params: { month: month + '-01' } }),
enabled: tab === 'grade',
})
const { data: closureData, isLoading: closureLoading } = useQuery({
queryKey: ['store-grade/closure-analysis', month],
queryFn: () => api.get('/store-grade/closure-analysis', { params: { month: month + '-01' } }),
enabled: tab === 'closure',
})
const autoGradeMutation = useMutation({
@@ -53,16 +62,31 @@ export function StoreGradePage() {
const dGradeCount = gradeDistribution.D || 0
const redRiskCount = riskDistribution['红色'] || 0
if (gradeLoading) {
if (gradeLoading && tab === 'grade') {
return <LoadingSpinner text="加载门店分级数据..." />
}
if (closureLoading && tab === 'closure') {
return <LoadingSpinner text="加载关停测算数据..." />
}
const closure = (closureData as any)?.data || {}
const closureSummary = closure.summary || {}
const closureStores = closure.stores || []
const closurePieData = [
{ name: '建议关停', value: closureSummary.recommend_close || 0 },
{ name: '整改观察', value: closureSummary.recommend_rectify || 0 },
{ name: '持续经营', value: (closureSummary.total_stores || 0) - (closureSummary.recommend_close || 0) - (closureSummary.recommend_rectify || 0) },
].filter(d => d.value > 0)
const CLOSURE_COLORS: Record<string, string> = { '建议关停': '#ef4444', '整改观察': '#eab308', '持续经营': '#22c55e' }
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · {month}</p>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · {month}</p>
</div>
<div className="flex items-center gap-2">
<input
@@ -71,16 +95,103 @@ export function StoreGradePage() {
onChange={(e) => setMonth(e.target.value)}
className="rounded border px-2 py-1 text-sm"
/>
<button
onClick={() => autoGradeMutation.mutate()}
disabled={autoGradeMutation.isPending}
className="rounded bg-primary px-3 py-1 text-sm text-primary-foreground disabled:opacity-50"
>
{autoGradeMutation.isPending ? '分级中...' : '自动分级'}
</button>
{tab === 'grade' && (
<button
onClick={() => autoGradeMutation.mutate()}
disabled={autoGradeMutation.isPending}
className="rounded bg-primary px-3 py-1 text-sm text-primary-foreground disabled:opacity-50"
>
{autoGradeMutation.isPending ? '分级中...' : '自动分级'}
</button>
)}
</div>
</div>
<Tabs tabs={[
{ key: 'grade', label: '门店分级' },
{ key: 'closure', label: '关停测算' },
]} active={tab} onChange={setTab} />
{tab === 'closure' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店总数" value={closureSummary.total_stores || 0} format="number" description="参与测算的门店总数" />
<MetricCard title="建议关停" value={closureSummary.recommend_close || 0} format="number" description="持续亏损且D级门店" status="bad" />
<MetricCard title="整改观察" value={closureSummary.recommend_rectify || 0} format="number" description="亏损但有基础的门店" status="warn" />
<MetricCard title="关停可月省" value={closureSummary.total_monthly_savings || 0} format="currency" description="关停门店每月可节省亏损" status="good" />
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="关停总成本" value={closureSummary.total_closure_cost || 0} format="currency" description="遣散+违约+资产处置" status="bad" />
<MetricCard title="12月预计亏损" value={closureSummary.total_projected_loss || 0} format="currency" description="不关停继续经营的预计亏损" status="bad" />
</div>
{closurePieData.length > 0 && (
<CollapsibleSection title="关停建议分布" subtitle="按盈利状况和综合评分分类">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={closurePieData} cx="50%" cy="50%" outerRadius={60} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{closurePieData.map((e, i) => <Cell key={i} fill={CLOSURE_COLORS[e.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`关停测算明细 (${closureStores.length})`} subtitle="含关停成本、继续经营亏损、回本期、建议">
<FilterableTable
data={closureStores}
filterKey="recommendation"
filterLabel="全部建议"
sortOptions={[
{ key: 'monthly_profit', label: '月利润' },
{ key: 'total_closure_cost', label: '关停成本' },
{ key: 'projected_12m_loss', label: '12月亏损' },
{ key: 'payback_months', label: '回本月数' },
{ key: 'overall_score', label: '综合分' },
]}
defaultSort="monthly_profit"
defaultOrder="asc"
columns={[
{ key: 'store_name', label: '门店名称' },
{ key: 'region', label: '区域' },
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
}},
{ key: 'monthly_revenue', label: '月收入', align: 'right', render: (r) => formatCurrency(r.monthly_revenue) },
{ key: 'monthly_profit', label: '月利润', align: 'right', render: (r) => {
const v = Number(r.monthly_profit || 0)
return <span className={v < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'monthly_rent', label: '月租', align: 'right', render: (r) => formatCurrency(r.monthly_rent) },
{ key: 'monthly_wage', label: '月人工', align: 'right', render: (r) => formatCurrency(r.monthly_wage) },
{ key: 'total_closure_cost', label: '关停成本', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.total_closure_cost)}</span> },
{ key: 'projected_12m_loss', label: '12月亏损', align: 'right', render: (r) => {
const v = Number(r.projected_12m_loss || 0)
return v ? <span className="text-red-600">{formatCurrency(v)}</span> : '-'
}},
{ key: 'monthly_savings_if_closed', label: '月节省', align: 'right', render: (r) => {
const v = Number(r.monthly_savings_if_closed || 0)
return v ? <span className="text-green-600">{formatCurrency(v)}</span> : '-'
}},
{ key: 'payback_months', label: '回本(月)', align: 'right', render: (r) => r.payback_months ? `${Number(r.payback_months).toFixed(1)}` : '-' },
{ key: 'recommendation', label: '建议', align: 'center', render: (r) => {
const colors: Record<string, string> = { '建议关停': 'bg-red-500 text-white', '整改观察3个月': 'bg-yellow-500 text-white', '重点整改': 'bg-blue-500 text-white', '持续经营': 'bg-green-500 text-white' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.recommendation] || ''}`}>{r.recommendation}</span>
}},
{ key: 'reason', label: '原因' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'grade' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店总数" value={gradeRows.length} format="number" description="参与分级的门店总数" />
<MetricCard title="平均综合分" value={avgScore} format="number" description="所有门店综合评分平均值" status={avgScore >= 75 ? 'good' : avgScore >= 60 ? 'warn' : 'bad'} />
@@ -152,6 +263,8 @@ export function StoreGradePage() {
</CollapsibleSection>
{selectedStore && <StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />}
</>
)}
</div>
)
}