feat: 整合菜单7组 + 新增营收分析页面(渠道/时段/门店排名/日度趋势)
This commit is contained in:
@@ -24,6 +24,7 @@ import { SiteSelectionPage } from '@/pages/SiteSelectionPage'
|
||||
import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage'
|
||||
import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
|
||||
import { BossPage } from '@/pages/BossPage'
|
||||
import { RevenuePage } from '@/pages/RevenuePage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -69,6 +70,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/boss" element={<BossPage />} />
|
||||
<Route path="/revenue" element={<RevenuePage />} />
|
||||
<Route path="/regional" element={<RegionalPage />} />
|
||||
<Route path="/store" element={<StorePage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
|
||||
@@ -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 } 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 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -23,11 +23,16 @@ interface MenuGroup {
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
title: '经营管理',
|
||||
title: '经营总览',
|
||||
items: [
|
||||
{ path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] },
|
||||
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
|
||||
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '角色工作台',
|
||||
items: [
|
||||
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
|
||||
@@ -35,17 +40,33 @@ const menuGroups: MenuGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '业务模块',
|
||||
title: '收入分析',
|
||||
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: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] },
|
||||
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
|
||||
{ path: '/revenue', label: '营收分析', icon: BarChart3, 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '成本与费用',
|
||||
items: [
|
||||
{ path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost', label: '成本库存', icon: Utensils, roles: ['hq', 'dept'] },
|
||||
{ path: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '运营分析',
|
||||
items: [
|
||||
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
||||
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
|
||||
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '风险与选址',
|
||||
items: [
|
||||
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
|
||||
{ path: '/site-selection', label: '门店选址', icon: MapPin, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
@@ -54,7 +75,7 @@ const menuGroups: MenuGroup[] = [
|
||||
items: [
|
||||
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
|
||||
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/ontology', label: '本体标准', icon: Database, roles: ['hq', 'dept'] },
|
||||
{ path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend, ComposedChart, Area } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { BarChart3, TrendingDown, Users, Receipt } from 'lucide-react'
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
wechat: '#07c160',
|
||||
alipay: '#1677ff',
|
||||
meituan_delivery: '#ffd700',
|
||||
meituan: '#ff6b35',
|
||||
taobao_delivery: '#ff4400',
|
||||
douyin: '#000000',
|
||||
cash: '#52c41a',
|
||||
jd_delivery: '#e1251b',
|
||||
unionpay: '#f5222d',
|
||||
credit: '#722ed1',
|
||||
}
|
||||
|
||||
export function RevenuePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: dailyData, isLoading: dailyLoading } = useQuery({
|
||||
queryKey: ['revenue/daily-summary'],
|
||||
queryFn: () => api.get('/revenue/daily-summary'),
|
||||
})
|
||||
const { data: channelData, isLoading: channelLoading } = useQuery({
|
||||
queryKey: ['revenue/channel'],
|
||||
queryFn: () => api.get('/revenue/channel'),
|
||||
})
|
||||
const { data: mealPeriodData, isLoading: mealLoading } = useQuery({
|
||||
queryKey: ['revenue/meal-period'],
|
||||
queryFn: () => api.get('/revenue/meal-period'),
|
||||
})
|
||||
const { data: storeRankingData, isLoading: rankLoading } = useQuery({
|
||||
queryKey: ['revenue/store-ranking'],
|
||||
queryFn: () => api.get('/revenue/store-ranking'),
|
||||
})
|
||||
|
||||
const pageLoading = dailyLoading || channelLoading || mealLoading || rankLoading
|
||||
|
||||
const dailyRows = (dailyData as any)?.data || []
|
||||
const channelRows = (channelData as any)?.data || []
|
||||
const mealRows = (mealPeriodData as any)?.data || []
|
||||
const rankRows = (storeRankingData as any)?.data || []
|
||||
|
||||
// 核心指标
|
||||
const totalReceived = dailyRows.reduce((s: number, r: any) => s + Number(r.received || 0), 0)
|
||||
const totalBills = dailyRows.reduce((s: number, r: any) => s + Number(r.bill_count || 0), 0)
|
||||
const totalDiscounts = dailyRows.reduce((s: number, r: any) => s + Number(r.discounts || 0), 0)
|
||||
const totalGuests = dailyRows.reduce((s: number, r: any) => s + Number(r.guests || 0), 0)
|
||||
const avgBillValue = totalBills > 0 ? totalReceived / totalBills : 0
|
||||
const avgDiscountRate = totalReceived > 0 ? totalDiscounts / (totalReceived + totalDiscounts) * 100 : 0
|
||||
const avgGuestValue = totalGuests > 0 ? totalReceived / totalGuests : 0
|
||||
|
||||
// 日度趋势数据
|
||||
const dailyChart = dailyRows.map((r: any) => ({
|
||||
date: r.business_date?.substring(5, 10),
|
||||
received: Number(r.received || 0),
|
||||
bills: Number(r.bill_count || 0),
|
||||
avg_bill: Number(r.avg_bill_value || 0),
|
||||
discount_rate: Number(r.avg_discount_rate_pct || 0),
|
||||
}))
|
||||
|
||||
// 环比
|
||||
const last7 = dailyRows.slice(-7)
|
||||
const prev7 = dailyRows.slice(-14, -7)
|
||||
const sumKey = (arr: any[], key: string) => arr.reduce((s, r) => s + Number(r[key] || 0), 0)
|
||||
const trend = (curr: number, prev: number) => prev > 0 ? Math.round((curr - prev) / prev * 1000) / 10 : 0
|
||||
const trendReceived = trend(sumKey(last7, 'received'), sumKey(prev7, 'received'))
|
||||
const trendBills = trend(sumKey(last7, 'bill_count'), sumKey(prev7, 'bill_count'))
|
||||
|
||||
// 渠道汇总
|
||||
const channelTotals: Record<string, number> = {}
|
||||
channelRows.forEach((r: any) => {
|
||||
Object.keys(r).forEach((k) => {
|
||||
if (k !== 'business_date') {
|
||||
channelTotals[k] = (channelTotals[k] || 0) + Number(r[k] || 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
const channelPie = Object.entries(channelTotals)
|
||||
.filter(([k, v]) => v > 0)
|
||||
.map(([k, v]) => ({ name: k, value: Math.round(v * 100) / 100 }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
const channelLabels: Record<string, string> = {
|
||||
wechat: '微信',
|
||||
alipay: '支付宝',
|
||||
meituan_delivery: '美团外卖',
|
||||
meituan: '美团堂食',
|
||||
taobao_delivery: '淘宝外卖',
|
||||
douyin: '抖音',
|
||||
cash: '现金',
|
||||
jd_delivery: '京东外卖',
|
||||
unionpay: '银联',
|
||||
credit: '挂账',
|
||||
}
|
||||
|
||||
// 渠道日度趋势(前5大渠道)
|
||||
const topChannels = channelPie.slice(0, 5).map((c) => c.name)
|
||||
const channelTrend = channelRows.map((r: any) => {
|
||||
const row: any = { date: r.business_date?.substring(5, 10) }
|
||||
topChannels.forEach((ch) => { row[ch] = Number(r[ch] || 0) })
|
||||
return row
|
||||
})
|
||||
|
||||
// 时段分布
|
||||
const mealColors = ['#f59e0b', '#3b82f6', '#22c55e', '#8b5cf6', '#ec4899', '#06b6d4']
|
||||
const mealPie = mealRows.map((r: any, i: number) => ({
|
||||
name: r.meal_period,
|
||||
value: Number(r.received || 0),
|
||||
bills: Number(r.bill_count || 0),
|
||||
avg_bill: Number(r.avg_bill_value || 0),
|
||||
fill: mealColors[i % mealColors.length],
|
||||
}))
|
||||
|
||||
// 门店排名 TOP10 / BOTTOM10
|
||||
const top10 = rankRows.slice(0, 10).map((r: any) => ({
|
||||
name: r.store_name?.substring(0, 6) || '',
|
||||
value: Number(r.received || 0),
|
||||
code: r.store_code,
|
||||
}))
|
||||
const bottom10 = rankRows.filter((r: any) => Number(r.received) > 0).slice(-10).reverse().map((r: any) => ({
|
||||
name: r.store_name?.substring(0, 6) || '',
|
||||
value: Number(r.received || 0),
|
||||
code: r.store_code,
|
||||
}))
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载营收分析..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="text-blue-500" size={24} />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">营收分析</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">收入结构 · 渠道分布 · 时段分析 · 门店排名 · 2026年4月</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="实收总额" value={totalReceived} format="currency" trend={trendReceived} description={`公式:全部门店实收合计。环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} />
|
||||
<MetricCard title="账单数" value={totalBills} format="number" trend={trendBills} description={`公式:全部门店账单数合计。环比上周 ${trendBills > 0 ? '↑' : '↓'} ${Math.abs(trendBills)}%`} />
|
||||
<MetricCard title="客单价" value={avgBillValue} format="currency" description={`公式:实收 ÷ 账单数。建议值:≥ 35元 为健康`} />
|
||||
<MetricCard title="优惠总额" value={totalDiscounts} format="currency" status={avgDiscountRate > 25 ? 'bad' : avgDiscountRate > 20 ? 'warn' : 'good'} description={`优惠率 ${formatPercent(avgDiscountRate)} · 公式:优惠率 = 优惠额 ÷ (实收 + 优惠额) × 100%。建议值:≤ 18% 为健康,18-22% 需关注,> 25% 需整改`} />
|
||||
</div>
|
||||
|
||||
{/* 日度营收趋势 */}
|
||||
<CollapsibleSection title="日度营收趋势" subtitle="实收 · 账单数 · 客单价 · 优惠率">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => label}
|
||||
formatter={(v: any, name: any) => {
|
||||
const labels: Record<string, string> = { received: '实收', bills: '账单数', avg_bill: '客单价', discount_rate: '优惠率' }
|
||||
if (name === 'received') return [formatCurrency(v), '实收']
|
||||
if (name === 'bills') return [formatNumber(v), '账单数']
|
||||
if (name === 'avg_bill') return [formatCurrency(v), '客单价']
|
||||
if (name === 'discount_rate') return [`${v}%`, '优惠率']
|
||||
return [v, labels[name] || name]
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => { const m: Record<string, string> = { received: '实收', bills: '账单数', avg_bill: '客单价', discount_rate: '优惠率' }; return m[v] || v }} />
|
||||
<Bar yAxisId="left" dataKey="received" fill="#3b82f6" radius={[4, 4, 0, 0]} name="received" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="avg_bill" stroke="#22c55e" name="avg_bill" dot={false} strokeWidth={2} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="discount_rate" stroke="#ef4444" name="discount_rate" dot={false} strokeWidth={2} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 渠道收入结构 + 渠道趋势 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="渠道收入结构" subtitle="各支付/外卖渠道占比">
|
||||
{channelPie.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label={(e: any) => `${channelLabels[e.name] || e.name}`}>
|
||||
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), channelLabels[name] || name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => channelLabels[v] || v} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="渠道收入趋势" subtitle="前5大渠道日度收入">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={channelTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), channelLabels[name] || name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v) => channelLabels[v] || v} />
|
||||
{topChannels.map((ch) => (
|
||||
<Line key={ch} type="monotone" dataKey={ch} stroke={CHANNEL_COLORS[ch] || '#999'} dot={false} strokeWidth={2} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* 时段收入分布 */}
|
||||
<CollapsibleSection title="时段收入分布" subtitle="早市/午市/下午茶/晚市/夜市">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie data={mealPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label={(e: any) => `${e.name}`}>
|
||||
{mealPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-2">
|
||||
{mealRows.map((r: any, i: number) => (
|
||||
<div key={r.meal_period} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="h-4 w-4 rounded" style={{ backgroundColor: mealColors[i % mealColors.length] }} />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{r.meal_period}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatNumber(r.bill_count)} 笔 · 客单价 {formatCurrency(r.avg_bill_value)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold">{formatCurrency(r.received)}</p>
|
||||
<p className="text-xs text-muted-foreground">{totalReceived > 0 ? formatPercent(Number(r.received) / totalReceived * 100) : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店营收排名 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="营收 TOP10" subtitle="赚钱最多的10家门店">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={top10} layout="vertical" margin={{ left: 70 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={70} />
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} onClick={(d: any) => d.code && navigate(`/stores/${d.code}`)}>
|
||||
{top10.map((_: any, i: number) => <Cell key={i} fill="#22c55e" className="cursor-pointer" />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="营收 BOTTOM10" subtitle="营收最低的10家门店">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={bottom10} layout="vertical" margin={{ left: 70 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={70} />
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} onClick={(d: any) => d.code && navigate(`/stores/${d.code}`)}>
|
||||
{bottom10.map((_: any, i: number) => <Cell key={i} fill="#ef4444" className="cursor-pointer" />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* 门店营收明细表 */}
|
||||
<CollapsibleSection title="门店营收明细" subtitle="全部门店营收排名">
|
||||
<div className="overflow-auto" style={{ maxHeight: 400 }}>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
<th className="text-left p-2">排名</th>
|
||||
<th className="text-left p-2">门店</th>
|
||||
<th className="text-right p-2">实收</th>
|
||||
<th className="text-right p-2">账单数</th>
|
||||
<th className="text-right p-2">客单价</th>
|
||||
<th className="text-right p-2">优惠率</th>
|
||||
<th className="text-right p-2">客流量</th>
|
||||
<th className="text-right p-2">理论毛利率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rankRows.map((s: any, i: number) => (
|
||||
<tr
|
||||
key={s.store_code}
|
||||
className="cursor-pointer border-b hover:bg-muted/50"
|
||||
onClick={() => navigate(`/stores/${s.store_code}`)}
|
||||
>
|
||||
<td className="p-2 font-bold">{i + 1}</td>
|
||||
<td className="p-2 font-medium">{s.store_name}</td>
|
||||
<td className="text-right p-2">{formatCurrency(s.received)}</td>
|
||||
<td className="text-right p-2">{formatNumber(s.bill_count)}</td>
|
||||
<td className="text-right p-2">{formatCurrency(s.avg_bill_value)}</td>
|
||||
<td className="text-right p-2">
|
||||
<span className={Number(s.avg_discount_rate_pct) > 25 ? 'text-red-600 font-bold' : Number(s.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}>
|
||||
{formatPercent(s.avg_discount_rate_pct)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right p-2">{formatNumber(s.guests)}</td>
|
||||
<td className="text-right p-2">{formatPercent(s.avg_theoretical_margin_pct)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -564,4 +564,100 @@ router.get('/overview/store-profit-ranking', async (req: AuthRequest, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ============ 营收分析 ============
|
||||
|
||||
// 渠道收入结构
|
||||
router.get('/revenue/channel', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT
|
||||
business_date,
|
||||
round(cash::numeric, 2) AS cash,
|
||||
round(alipay::numeric, 2) AS alipay,
|
||||
round(wechat::numeric, 2) AS wechat,
|
||||
round(meituan::numeric, 2) AS meituan,
|
||||
round(unionpay::numeric, 2) AS unionpay,
|
||||
round(douyin::numeric, 2) AS douyin,
|
||||
round(credit::numeric, 2) AS credit,
|
||||
round(jd_delivery::numeric, 2) AS jd_delivery,
|
||||
round(meituan_delivery::numeric, 2) AS meituan_delivery,
|
||||
round(taobao_delivery::numeric, 2) AS taobao_delivery
|
||||
FROM analytics.v_channel_daily
|
||||
WHERE business_date >= DATE '2026-04-01'
|
||||
ORDER BY business_date
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 时段收入分布
|
||||
router.get('/revenue/meal-period', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT
|
||||
meal_period,
|
||||
sum(bill_count)::bigint AS bill_count,
|
||||
round(sum(received)::numeric, 2) AS received,
|
||||
round(avg(avg_bill_value)::numeric, 2) AS avg_bill_value
|
||||
FROM analytics.v_meal_period_daily
|
||||
WHERE business_date >= DATE '2026-04-01'
|
||||
GROUP BY meal_period
|
||||
ORDER BY sum(received) DESC
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 门店营收排名
|
||||
router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT
|
||||
store_code,
|
||||
store_name,
|
||||
sum(bill_count)::bigint AS bill_count,
|
||||
round(sum(received)::numeric, 2) AS received,
|
||||
round(sum(discounts)::numeric, 2) AS discounts,
|
||||
round(avg(discount_rate_pct)::numeric, 2) AS avg_discount_rate_pct,
|
||||
round(avg(avg_bill_value)::numeric, 2) AS avg_bill_value,
|
||||
round(sum(guests)::numeric, 0) AS guests,
|
||||
round(avg(theoretical_margin_pct)::numeric, 2) AS avg_theoretical_margin_pct
|
||||
FROM analytics.v_store_daily
|
||||
WHERE business_date >= DATE '2026-04-01'
|
||||
GROUP BY store_code, store_name
|
||||
ORDER BY sum(received) DESC
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 日度营收汇总
|
||||
router.get('/revenue/daily-summary', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT
|
||||
business_date,
|
||||
sum(bill_count)::bigint AS bill_count,
|
||||
round(sum(received)::numeric, 2) AS received,
|
||||
round(sum(discounts)::numeric, 2) AS discounts,
|
||||
round(avg(discount_rate_pct)::numeric, 2) AS avg_discount_rate_pct,
|
||||
round(avg(avg_bill_value)::numeric, 2) AS avg_bill_value,
|
||||
round(sum(guests)::numeric, 0) AS guests
|
||||
FROM analytics.v_store_daily
|
||||
WHERE business_date >= DATE '2026-04-01'
|
||||
GROUP BY business_date
|
||||
ORDER BY business_date
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user