From 1a4aca28c04b8a6e91bfce9bd60731828b5208c8 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 30 Jul 2026 16:58:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=93=B6=E8=A1=8C?= =?UTF-8?q?=E6=8E=88=E4=BF=A1=E6=95=B0=E6=8D=AE=E6=8A=A5=E5=91=8A=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20-=20=E5=8F=AF=E9=85=8D=E7=BD=AE=E8=AF=84=E5=88=86?= =?UTF-8?q?=E8=A7=84=E5=88=99+5=E5=A4=A7=E5=88=86=E6=9E=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= 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 | 3 +- client/src/pages/BankPage.tsx | 405 +++++++++++++++++++++++++++++++ server/src/routes/data.ts | 79 ++++++ 4 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/BankPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 2724508..144ed7a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -25,6 +25,7 @@ import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage' import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage' import { BossPage } from '@/pages/BossPage' import { RevenuePage } from '@/pages/RevenuePage' +import { BankPage } from '@/pages/BankPage' import { LoginPage } from '@/pages/LoginPage' const queryClient = new QueryClient({ @@ -71,6 +72,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 60a102e..dd6b3c8 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, BarChart3, Receipt, Utensils } 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 } from 'lucide-react' import { cn } from '@/lib/utils' interface LayoutProps { @@ -28,6 +28,7 @@ const menuGroups: MenuGroup[] = [ { path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] }, { path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] }, { path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] }, + { path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] }, ], }, { diff --git a/client/src/pages/BankPage.tsx b/client/src/pages/BankPage.tsx new file mode 100644 index 0000000..d530ec7 --- /dev/null +++ b/client/src/pages/BankPage.tsx @@ -0,0 +1,405 @@ +import { useState, useEffect, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } 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 { Landmark, Settings2, RotateCcw } from 'lucide-react' + +// ============ 评分规则类型 ============ +interface ScoringRule { + weight: number + excellent: number + pass: number + higherIsBetter: boolean +} + +interface ScoringConfig { + netMargin: ScoringRule + stability: ScoringRule + storeHealth: ScoringRule + scale: ScoringRule + discountRate: ScoringRule + memberShare: ScoringRule +} + +const DEFAULT_CONFIG: ScoringConfig = { + netMargin: { weight: 25, excellent: 10, pass: 5, higherIsBetter: true }, + stability: { weight: 20, excellent: 0.15, pass: 0.3, higherIsBetter: false }, + storeHealth: { weight: 20, excellent: 10, pass: 25, higherIsBetter: false }, + scale: { weight: 15, excellent: 500000, pass: 200000, higherIsBetter: true }, + discountRate: { weight: 10, excellent: 18, pass: 25, higherIsBetter: false }, + memberShare: { weight: 10, excellent: 15, pass: 5, higherIsBetter: true }, +} + +const CHANNEL_LABELS: Record = { + cash: '现金', alipay: '支付宝', wechat: '微信', meituan: '美团堂食', unionpay: '银联', + douyin: '抖音', credit: '挂账', jd_delivery: '京东外卖', meituan_delivery: '美团外卖', taobao_delivery: '淘宝外卖', +} + +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', +} + +const RISK_COLORS: Record = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } + +function loadConfig(): ScoringConfig { + try { + const saved = localStorage.getItem('bank-scoring-config') + if (saved) return { ...DEFAULT_CONFIG, ...JSON.parse(saved) } + } catch {} + return DEFAULT_CONFIG +} + +function saveConfig(config: ScoringConfig) { + localStorage.setItem('bank-scoring-config', JSON.stringify(config)) +} + +// 计算单项得分 (0-100) +function scoreItem(value: number, rule: ScoringRule): number { + if (rule.higherIsBetter) { + if (value >= rule.excellent) return 100 + if (value <= rule.pass) return 0 + return Math.round((value - rule.pass) / (rule.excellent - rule.pass) * 100) + } else { + if (value <= rule.excellent) return 100 + if (value >= rule.pass) return 0 + return Math.round((rule.pass - value) / (rule.pass - rule.excellent) * 100) + } +} + +// 计算信用等级 +function getGrade(totalScore: number): { grade: string; color: string; label: string } { + if (totalScore >= 85) return { grade: 'A', color: '#22c55e', label: '优秀 - 建议授信' } + if (totalScore >= 70) return { grade: 'B', color: '#3b82f6', label: '良好 - 可授信' } + if (totalScore >= 50) return { grade: 'C', color: '#eab308', label: '一般 - 谨慎授信' } + return { grade: 'D', color: '#ef4444', label: '风险 - 不建议授信' } +} + +export function BankPage() { + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [showConfig, setShowConfig] = useState(false) + + useEffect(() => { setConfig(loadConfig()) }, []) + + const { data: reportData, isLoading } = useQuery({ + queryKey: ['bank/report'], + queryFn: () => api.get('/bank/report'), + }) + + const d = (reportData as any)?.data + const ov = d?.overview + const daily = d?.daily || [] + const wf = d?.waterfall + const risk = d?.risk || [] + const channel = d?.channel || {} + const stores = d?.stores || [] + const stability = d?.stability + + // 计算各项指标值 + const metrics = useMemo(() => { + if (!ov || !wf || !stability) return null + const netMargin = wf.received > 0 ? (wf.net_profit / wf.received * 100) : 0 + const cv = stability.cv + const redStores = risk.filter((r: any) => r.risk_level === '红色').reduce((s: number, r: any) => s + r.store_count, 0) + const totalStores = risk.reduce((s: number, r: any) => s + r.store_count, 0) + const redPct = totalStores > 0 ? (redStores / totalStores * 100) : 0 + const scale = ov.received + const discountRate = ov.discount_rate_pct + const memberShare = ov.member_share_pct + return { netMargin, cv, redPct, scale, discountRate, memberShare } + }, [ov, wf, stability, risk]) + + // 计算评分 + const scoring = useMemo(() => { + if (!metrics) return null + const items = [ + { key: 'netMargin', label: '净利率', value: metrics.netMargin, unit: '%', rule: config.netMargin, score: scoreItem(metrics.netMargin, config.netMargin) }, + { key: 'stability', label: '营收稳定性', value: metrics.cv, unit: '', rule: config.stability, score: scoreItem(metrics.cv, config.stability) }, + { key: 'storeHealth', label: '门店健康度', value: metrics.redPct, unit: '%', rule: config.storeHealth, score: scoreItem(metrics.redPct, config.storeHealth) }, + { key: 'scale', label: '规模体量', value: metrics.scale, unit: '元', rule: config.scale, score: scoreItem(metrics.scale, config.scale) }, + { key: 'discountRate', label: '优惠率', value: metrics.discountRate, unit: '%', rule: config.discountRate, score: scoreItem(metrics.discountRate, config.discountRate) }, + { key: 'memberShare', label: '会员占比', value: metrics.memberShare, unit: '%', rule: config.memberShare, score: scoreItem(metrics.memberShare, config.memberShare) }, + ] + const totalWeight = items.reduce((s, i) => s + i.rule.weight, 0) + const totalScore = totalWeight > 0 ? Math.round(items.reduce((s, i) => s + i.score * i.rule.weight, 0) / totalWeight) : 0 + return { items, totalScore, totalWeight, grade: getGrade(totalScore) } + }, [metrics, config]) + + // 利润瀑布数据 + const waterfallChart = useMemo(() => { + if (!wf) return [] + return [ + { name: '实收', value: wf.received, base: 0, fill: '#3b82f6' }, + { name: '食材', value: -wf.food_cost, base: wf.received, fill: '#f97316' }, + { name: '人工', value: -wf.wage, base: wf.received - wf.food_cost, fill: '#3b82f6' }, + { name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage, fill: '#8b5cf6' }, + { name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#06b6d4' }, + { name: '其他', value: -(wf.dorm + wf.commission + wf.other_expense), base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#64748b' }, + { name: '净利润', value: wf.net_profit, base: 0, fill: wf.net_profit >= 0 ? '#22c55e' : '#ef4444' }, + ] + }, [wf]) + + // 渠道饼图 + const channelPie = useMemo(() => { + return Object.entries(channel).map(([k, v]) => ({ name: k, value: v as number })).sort((a, b) => b.value - a.value) + }, [channel]) + + // 风险饼图 + const riskPie = useMemo(() => { + return risk.map((r: any) => ({ name: r.risk_level, value: r.store_count, fill: RISK_COLORS[r.risk_level] || '#999' })) + }, [risk]) + + // 日度趋势 + const dailyChart = useMemo(() => { + return daily.map((r: any) => ({ date: r.business_date?.substring(5, 10), received: r.received, bills: r.bill_count })) + }, [daily]) + + const top10 = stores.slice(0, 10) + const totalChannel = channelPie.reduce((s, c) => s + c.value, 0) + + if (isLoading) return + + const updateRule = (key: keyof ScoringConfig, field: keyof ScoringRule, value: number) => { + const newConfig = { ...config, [key]: { ...config[key], [field]: value } } + setConfig(newConfig) + saveConfig(newConfig) + } + + const resetConfig = () => { + setConfig(DEFAULT_CONFIG) + saveConfig(DEFAULT_CONFIG) + } + + return ( +
+ {/* 标题 */} +
+
+

银行授信数据报告

+

企业经营数据 · 信用评估 · 2026年4月

+
+ +
+ + {/* 评分规则配置面板 */} + {showConfig && ( + +
+
+ {(Object.keys(config) as (keyof ScoringConfig)[]).map(key => { + const rule = config[key] + const labels: Record = { netMargin: '净利率(%)', stability: '营收稳定性(CV)', storeHealth: '门店健康度(红色占比%)', scale: '规模体量(元)', discountRate: '优惠率(%)', memberShare: '会员占比(%)' } + return ( +
+

{labels[key]}

+
+ + updateRule(key, 'weight', parseFloat(e.target.value) || 0)} + className="w-full rounded border px-2 py-1 text-xs" /> +
+
+ + updateRule(key, 'excellent', parseFloat(e.target.value) || 0)} + className="w-full rounded border px-2 py-1 text-xs" /> +
+
+ + updateRule(key, 'pass', parseFloat(e.target.value) || 0)} + className="w-full rounded border px-2 py-1 text-xs" /> +
+
+ ) + })} +
+
+ 权重总和: {scoring?.totalWeight || 0}% + +
+
+
+ )} + + {/* ① 企业经营概况 */} + +
+ + + + +
+
+ + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + [formatCurrency(v), '日实收']} /> + + + +
+
+ + {/* ② 盈利能力分析 */} + + {wf && ( + <> +
+ 0 ? 'good' : 'bad'} description={`净利率 ${wf.received > 0 ? formatPercent(wf.net_profit / wf.received * 100) : '-'}`} /> + 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" /> + 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" /> + 0 ? wf.net_profit / wf.received * 100 : 0} format="percent" description="净利润占实收比例" /> +
+
+ + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + [formatCurrency(v), '金额']} /> + { + const { x, y, width, height, payload, fill } = props + if (!payload.value || payload.value === 0) return + const scale = height / payload.value + const newY = y - payload.base * scale + return + }}> + {waterfallChart.map((entry, i) => )} + + + +
+ + )} +
+ + {/* ③ 门店资产质量 */} + +
+
+ + + `${e.name}: ${e.value}家`}> + {riskPie.map((entry: any, i: number) => )} + + [`${v}家`, name]} /> + + + +
+
+ + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + + [formatCurrency(v), '实收']} /> + + + +
+
+
+ + {/* ④ 经营稳定性指标 */} + +
+ + 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" /> + 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" /> + +
+ {channelPie.length > 0 && ( +
+ + + `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}> + {channelPie.map((entry: any, i: number) => )} + + [formatCurrency(v), CHANNEL_LABELS[name] || name]} /> + CHANNEL_LABELS[v] || v} /> + + +
+ )} +
+ + {/* ⑤ 银行授信评估摘要 */} + + {scoring && ( + <> + {/* 信用等级总览 */} +
+
+ {scoring.grade.grade} +
+
+

{scoring.grade.label}

+

{scoring.totalScore} / 100 分

+

综合信用评分 · 权重总和 {scoring.totalWeight}%

+
+
+ + {/* 各指标得分明细 */} +
+ + + + + + + + + + + + + + {scoring.items.map(item => ( + + + + + + + + + + ))} + +
指标实际值优秀阈值及格阈值权重得分加权得分
{item.label}{item.unit === '元' ? formatCurrency(item.value) : `${item.value.toFixed(2)}${item.unit}`}{item.unit === '元' ? formatCurrency(item.rule.excellent) : `${item.rule.excellent}${item.unit}`}{item.unit === '元' ? formatCurrency(item.rule.pass) : `${item.rule.pass}${item.unit}`}{item.rule.weight}% + = 80 ? '#22c55e' : item.score >= 50 ? '#eab308' : '#ef4444' }}>{item.score} + {(item.score * item.rule.weight / 100).toFixed(1)}
+
+ + {/* 风险提示 */} +
+

风险提示

+ {scoring.items.filter(i => i.score < 60).map(i => ( +
+ + {i.label}:得分 {i.score},实际值 {i.unit === '元' ? formatCurrency(i.value) : `${i.value.toFixed(2)}${i.unit}`},{i.rule.higherIsBetter ? `建议提升至 ${i.unit === '元' ? formatCurrency(i.rule.excellent) : i.rule.excellent + i.unit} 以上` : `建议控制在 ${i.rule.excellent}${i.unit} 以内`} +
+ ))} + {scoring.items.filter(i => i.score < 60).length === 0 && ( +
+ + 各项指标均达标,经营状况良好 +
+ )} +
+ + )} +
+
+ ) +} diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index e1f365e..8be775c 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -660,4 +660,83 @@ router.get('/revenue/daily-summary', async (req: AuthRequest, res) => { } }) +// 银行授信报告 +router.get('/bank/report', async (req: AuthRequest, res) => { + try { + const [overview, daily, waterfall, risk, channel, storeRanking] = await Promise.all([ + query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = DATE '2026-04-01'`), + query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = DATE '2026-04-01' ORDER BY business_date`), + query(` + WITH full_scope AS ( + SELECT r.received, COALESCE(e.actual_food_cost,0) AS food_cost, COALESCE(e.wage_expense,0) AS wage, COALESCE(e.rent_expense,0) AS rent, + COALESCE(e.utility_expense,0) AS utility, COALESCE(e.dorm_expense,0) AS dorm, COALESCE(e.delivery_commission_expense,0) AS commission, + COALESCE(e.card_fee_expense,0) AS card_fee, COALESCE(e.repair_clean_expense,0) AS repair, COALESCE(e.operating_expense,0) AS operating_expense + FROM analytics.mv_store_risk_rating r + LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = DATE '2026-04-01' + WHERE r.received IS NOT NULL + ) + SELECT round(sum(received)::numeric,2) AS received, round(sum(food_cost)::numeric,2) AS food_cost, round(sum(wage)::numeric,2) AS wage, + round(sum(rent)::numeric,2) AS rent, round(sum(utility)::numeric,2) AS utility, round(sum(dorm)::numeric,2) AS dorm, + round(sum(commission)::numeric,2) AS commission, round(sum(card_fee+repair)::numeric,2) AS other_expense, + round(sum(operating_expense)::numeric,2) AS total_expense, + round(sum(received)-sum(food_cost)-sum(operating_expense),2) AS net_profit + FROM full_scope + `), + query(`SELECT risk_level, count(*) AS store_count, round(sum(received)::numeric,2) AS total_received, round(avg(theoretical_margin_pct)::numeric,2) AS avg_margin_pct, round(avg(discount_rate_pct)::numeric,2) AS avg_discount_pct FROM analytics.mv_store_risk_rating WHERE received IS NOT NULL GROUP BY risk_level ORDER BY risk_level`), + query(`SELECT round(sum(cash)::numeric,2) AS cash, round(sum(alipay)::numeric,2) AS alipay, round(sum(wechat)::numeric,2) AS wechat, round(sum(meituan)::numeric,2) AS meituan, round(sum(unionpay)::numeric,2) AS unionpay, round(sum(douyin)::numeric,2) AS douyin, round(sum(credit)::numeric,2) AS credit, round(sum(jd_delivery)::numeric,2) AS jd_delivery, round(sum(meituan_delivery)::numeric,2) AS meituan_delivery, round(sum(taobao_delivery)::numeric,2) AS taobao_delivery FROM analytics.v_channel_daily WHERE business_date >= DATE '2026-04-01'`), + query(`SELECT store_code, store_name, round(received::numeric,2) AS received, bill_count, round(avg_bill_value::numeric,2) AS avg_bill_value, round(discount_rate_pct::numeric,2) AS discount_rate_pct, round(theoretical_margin_pct::numeric,2) AS theoretical_margin_pct, risk_level FROM analytics.mv_store_risk_rating WHERE received IS NOT NULL ORDER BY received DESC`), + ]) + + const ov = overview.rows[0] as any + const dailyRows = daily.rows.map((r: any) => ({ + ...r, + received: parseFloat(r.received), + bill_count: parseInt(r.bill_count), + avg_bill_value: parseFloat(r.avg_bill_value), + discount_rate_pct: parseFloat(r.discount_rate_pct), + })) + const wf = waterfall.rows[0] as any + const riskRows = risk.rows.map((r: any) => ({ + ...r, + store_count: parseInt(r.store_count), + total_received: parseFloat(r.total_received), + })) + const ch = channel.rows[0] as any + const channelTotals: Record = {} + if (ch) { + for (const [k, v] of Object.entries(ch)) { + const val = parseFloat(v as string) + if (val > 0) channelTotals[k] = val + } + } + const storeRows = storeRanking.rows.map((r: any) => ({ + ...r, + received: parseFloat(r.received), + bill_count: parseInt(r.bill_count), + avg_bill_value: parseFloat(r.avg_bill_value), + discount_rate_pct: parseFloat(r.discount_rate_pct), + theoretical_margin_pct: parseFloat(r.theoretical_margin_pct), + })) + + // 计算日度营收波动率 + const receivedArr = dailyRows.map((d: any) => d.received) + const meanRev = receivedArr.reduce((a: number, b: number) => a + b, 0) / (receivedArr.length || 1) + const variance = receivedArr.reduce((s: number, v: number) => s + Math.pow(v - meanRev, 2), 0) / (receivedArr.length || 1) + const stdDev = Math.sqrt(variance) + const cv = meanRev > 0 ? stdDev / meanRev : 0 + + sendSuccess(res, { + overview: { ...ov, received: parseFloat(ov?.received), bill_count: parseInt(ov?.bill_count), avg_bill_value: parseFloat(ov?.avg_bill_value), discount_rate_pct: parseFloat(ov?.discount_rate_pct), theoretical_margin_pct: parseFloat(ov?.theoretical_margin_pct), member_bills: parseInt(ov?.member_bills), member_share_pct: parseFloat(ov?.member_share_pct) }, + daily: dailyRows, + waterfall: { ...wf, received: parseFloat(wf?.received), food_cost: parseFloat(wf?.food_cost), wage: parseFloat(wf?.wage), rent: parseFloat(wf?.rent), utility: parseFloat(wf?.utility), dorm: parseFloat(wf?.dorm), commission: parseFloat(wf?.commission), other_expense: parseFloat(wf?.other_expense), total_expense: parseFloat(wf?.total_expense), net_profit: parseFloat(wf?.net_profit) }, + risk: riskRows, + channel: channelTotals, + stores: storeRows, + stability: { mean_daily_revenue: meanRev, std_dev: stdDev, cv: cv, days: receivedArr.length }, + }) + } catch (err: any) { + sendError(res, err.message) + } +}) + export default router