From 166424788278c3acd932835eb01f2d21f368273b Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 30 Jul 2026 15:01:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B4=E5=90=88=E8=8F=9C=E5=8D=957?= =?UTF-8?q?=E7=BB=84=20+=20=E6=96=B0=E5=A2=9E=E8=90=A5=E6=94=B6=E5=88=86?= =?UTF-8?q?=E6=9E=90=E9=A1=B5=E9=9D=A2(=E6=B8=A0=E9=81=93/=E6=97=B6?= =?UTF-8?q?=E6=AE=B5/=E9=97=A8=E5=BA=97=E6=8E=92=E5=90=8D/=E6=97=A5?= =?UTF-8?q?=E5=BA=A6=E8=B6=8B=E5=8A=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 2 + client/src/components/Layout.tsx | 43 +++-- client/src/pages/RevenuePage.tsx | 320 +++++++++++++++++++++++++++++++ server/src/routes/data.ts | 96 ++++++++++ 4 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 client/src/pages/RevenuePage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 9ad1f5f..2724508 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 9b2ebfc..60a102e 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { ReactNode, useState } from 'react' import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, 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'] }, ], }, ] diff --git a/client/src/pages/RevenuePage.tsx b/client/src/pages/RevenuePage.tsx new file mode 100644 index 0000000..a6ac52f --- /dev/null +++ b/client/src/pages/RevenuePage.tsx @@ -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 = { + 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 = {} + 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 = { + 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 + } + + return ( +
+ {/* 标题 */} +
+ +
+

营收分析

+

收入结构 · 渠道分布 · 时段分析 · 门店排名 · 2026年4月

+
+
+ + {/* 核心指标 */} +
+ 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> + 0 ? '↑' : '↓'} ${Math.abs(trendBills)}%`} /> + + 25 ? 'bad' : avgDiscountRate > 20 ? 'warn' : 'good'} description={`优惠率 ${formatPercent(avgDiscountRate)} · 公式:优惠率 = 优惠额 ÷ (实收 + 优惠额) × 100%。建议值:≤ 18% 为健康,18-22% 需关注,> 25% 需整改`} /> +
+ + {/* 日度营收趋势 */} + + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + + label} + formatter={(v: any, name: any) => { + const labels: Record = { 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] + }} + /> + { const m: Record = { received: '实收', bills: '账单数', avg_bill: '客单价', discount_rate: '优惠率' }; return m[v] || v }} /> + + + + + + + + {/* 渠道收入结构 + 渠道趋势 */} +
+ + {channelPie.length > 0 && ( + + + `${channelLabels[e.name] || e.name}`}> + {channelPie.map((entry: any, i: number) => )} + + [formatCurrency(v), channelLabels[name] || name]} /> + channelLabels[v] || v} /> + + + )} + + + + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + [formatCurrency(v), channelLabels[name] || name]} /> + channelLabels[v] || v} /> + {topChannels.map((ch) => ( + + ))} + + + +
+ + {/* 时段收入分布 */} + +
+ + + `${e.name}`}> + {mealPie.map((entry: any, i: number) => )} + + [formatCurrency(v), name]} /> + + + +
+ {mealRows.map((r: any, i: number) => ( +
+
+
+

{r.meal_period}

+

{formatNumber(r.bill_count)} 笔 · 客单价 {formatCurrency(r.avg_bill_value)}

+
+
+

{formatCurrency(r.received)}

+

{totalReceived > 0 ? formatPercent(Number(r.received) / totalReceived * 100) : '-'}

+
+
+ ))} +
+
+ + + {/* 门店营收排名 */} +
+ + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + + [formatCurrency(v), '实收']} /> + d.code && navigate(`/stores/${d.code}`)}> + {top10.map((_: any, i: number) => )} + + + + + + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + + [formatCurrency(v), '实收']} /> + d.code && navigate(`/stores/${d.code}`)}> + {bottom10.map((_: any, i: number) => )} + + + + +
+ + {/* 门店营收明细表 */} + +
+ + + + + + + + + + + + + + + {rankRows.map((s: any, i: number) => ( + navigate(`/stores/${s.store_code}`)} + > + + + + + + + + + + ))} + +
排名门店实收账单数客单价优惠率客流量理论毛利率
{i + 1}{s.store_name}{formatCurrency(s.received)}{formatNumber(s.bill_count)}{formatCurrency(s.avg_bill_value)} + 25 ? 'text-red-600 font-bold' : Number(s.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}> + {formatPercent(s.avg_discount_rate_pct)} + + {formatNumber(s.guests)}{formatPercent(s.avg_theoretical_margin_pct)}
+
+
+
+ ) +} diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index 1b3557e..e1f365e 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -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