feat: 新增银行授信数据报告页面 - 可配置评分规则+5大分析模块

This commit is contained in:
freedakgmail
2026-07-30 16:58:32 +08:00
parent 7d1eba43c0
commit 1a4aca28c0
4 changed files with 488 additions and 1 deletions
+2
View File
@@ -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() {
<Route path="/" element={<DashboardPage />} />
<Route path="/boss" element={<BossPage />} />
<Route path="/revenue" element={<RevenuePage />} />
<Route path="/bank" element={<BankPage />} />
<Route path="/regional" element={<RegionalPage />} />
<Route path="/store" element={<StorePage />} />
<Route path="/tasks" element={<TasksPage />} />
+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 } 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'] },
],
},
{
+405
View File
@@ -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<string, string> = {
cash: '现金', alipay: '支付宝', wechat: '微信', meituan: '美团堂食', unionpay: '银联',
douyin: '抖音', credit: '挂账', jd_delivery: '京东外卖', meituan_delivery: '美团外卖', taobao_delivery: '淘宝外卖',
}
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',
}
const RISK_COLORS: Record<string, string> = { '红色': '#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<ScoringConfig>(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 <LoadingSpinner text="加载银行授信数据..." />
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 (
<div className="space-y-4">
{/* 标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold"><Landmark size={20} /> </h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · 20264</p>
</div>
<button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs hover:bg-accent">
<Settings2 size={14} />
</button>
</div>
{/* 评分规则配置面板 */}
{showConfig && (
<CollapsibleSection title="评分规则配置" subtitle="调整各指标权重和阈值,实时重新计算信用等级" defaultOpen={true}>
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
{(Object.keys(config) as (keyof ScoringConfig)[]).map(key => {
const rule = config[key]
const labels: Record<string, string> = { netMargin: '净利率(%)', stability: '营收稳定性(CV)', storeHealth: '门店健康度(红色占比%)', scale: '规模体量(元)', discountRate: '优惠率(%)', memberShare: '会员占比(%)' }
return (
<div key={key} className="rounded-lg border p-3 space-y-2">
<p className="text-xs font-bold">{labels[key]}</p>
<div>
<label className="text-[10px] text-muted-foreground">(%)</label>
<input type="number" value={rule.weight} onChange={e => updateRule(key, 'weight', parseFloat(e.target.value) || 0)}
className="w-full rounded border px-2 py-1 text-xs" />
</div>
<div>
<label className="text-[10px] text-muted-foreground">{rule.higherIsBetter ? '优秀阈值' : '优秀阈值(≤)'}</label>
<input type="number" step="0.01" value={rule.excellent} onChange={e => updateRule(key, 'excellent', parseFloat(e.target.value) || 0)}
className="w-full rounded border px-2 py-1 text-xs" />
</div>
<div>
<label className="text-[10px] text-muted-foreground">{rule.higherIsBetter ? '及格阈值(≥)' : '及格阈值(≥)'}</label>
<input type="number" step="0.01" value={rule.pass} onChange={e => updateRule(key, 'pass', parseFloat(e.target.value) || 0)}
className="w-full rounded border px-2 py-1 text-xs" />
</div>
</div>
)
})}
</div>
<div className="flex items-center gap-4">
<span className="text-xs">: <strong className={scoring?.totalWeight === 100 ? 'text-green-600' : 'text-red-600'}>{scoring?.totalWeight || 0}%</strong></span>
<button onClick={resetConfig} className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs hover:bg-accent">
<RotateCcw size={12} />
</button>
</div>
</div>
</CollapsibleSection>
)}
{/* ① 企业经营概况 */}
<CollapsibleSection title="企业经营概况" subtitle="月度核心指标与日度营收趋势">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="月度实收" value={ov?.received} format="currency" description={`${formatNumber(ov?.bill_count)} 笔账单`} />
<MetricCard title="客单价" value={ov?.avg_bill_value} format="currency" description="实收 ÷ 账单数" />
<MetricCard title="理论毛利率" value={ov?.theoretical_margin_pct} format="percent" description="菜品定价毛利空间" />
<MetricCard title="门店数量" value={stores.length} unit="家" description={`活跃经营门店总数`} />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<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) => [formatCurrency(v), '日实收']} />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="日实收" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</CollapsibleSection>
{/* ② 盈利能力分析 */}
<CollapsibleSection title="盈利能力分析" subtitle="利润瀑布 · 净利率 · 成本费用结构">
{wf && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="净利润" value={wf.net_profit} format="currency" status={wf.net_profit > 0 ? 'good' : 'bad'} description={`净利率 ${wf.received > 0 ? formatPercent(wf.net_profit / wf.received * 100) : '-'}`} />
<MetricCard title="食材成本率" value={wf.received > 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" />
<MetricCard title="费用率" value={wf.received > 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" />
<MetricCard title="实收分配" value={wf.received > 0 ? wf.net_profit / wf.received * 100 : 0} format="percent" description="净利润占实收比例" />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
const { x, y, width, height, payload, fill } = props
if (!payload.value || payload.value === 0) return <rect />
const scale = height / payload.value
const newY = y - payload.base * scale
return <rect x={x} y={newY} width={width} height={height} fill={fill} rx={4} ry={4} />
}}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
</CollapsibleSection>
{/* ③ 门店资产质量 */}
<CollapsibleSection title="门店资产质量" subtitle="风险等级分布 · 营收TOP10">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [`${v}`, name]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top10} layout="vertical" margin={{ top: 5, right: 10, left: 80, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="store_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
<Bar dataKey="received" fill="#3b82f6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CollapsibleSection>
{/* ④ 经营稳定性指标 */}
<CollapsibleSection title="经营稳定性指标" subtitle="营收波动率 · 优惠率 · 会员占比 · 渠道结构">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="日度营收波动率" value={stability ? stability.cv * 100 : 0} format="percent" status={stability && stability.cv < 0.2 ? 'good' : stability && stability.cv < 0.3 ? 'warn' : 'bad'} description={`变异系数CV · 日均${formatCurrency(stability?.mean_daily_revenue)}`} />
<MetricCard title="优惠率" value={ov?.discount_rate_pct} format="percent" status={Number(ov?.discount_rate_pct) > 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" />
<MetricCard title="会员消费占比" value={ov?.member_share_pct} format="percent" status={Number(ov?.member_share_pct) > 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" />
<MetricCard title="活跃渠道数" value={channelPie.length} unit="个" description="有交易记录的支付渠道" />
</div>
{channelPie.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => CHANNEL_LABELS[v] || v} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CollapsibleSection>
{/* ⑤ 银行授信评估摘要 */}
<CollapsibleSection title="银行授信评估摘要" subtitle="基于可配置评分规则的信用等级评估">
{scoring && (
<>
{/* 信用等级总览 */}
<div className="flex items-center gap-6 rounded-lg border p-4" style={{ borderColor: scoring.grade.color + '40' }}>
<div className="flex h-20 w-20 items-center justify-center rounded-full text-4xl font-bold" style={{ backgroundColor: scoring.grade.color + '20', color: scoring.grade.color }}>
{scoring.grade.grade}
</div>
<div className="flex-1">
<p className="text-lg font-bold" style={{ color: scoring.grade.color }}>{scoring.grade.label}</p>
<p className="mt-1 text-2xl font-bold">{scoring.totalScore}<span className="text-sm text-muted-foreground"> / 100 </span></p>
<p className="text-xs text-muted-foreground"> · {scoring.totalWeight}%</p>
</div>
</div>
{/* 各指标得分明细 */}
<div className="mt-4 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4"></th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody>
{scoring.items.map(item => (
<tr key={item.key} className="border-b">
<td className="py-2 pr-4 font-medium">{item.label}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.value) : `${item.value.toFixed(2)}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.excellent) : `${item.rule.excellent}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.pass) : `${item.rule.pass}${item.unit}`}</td>
<td className="py-2 pr-4">{item.rule.weight}%</td>
<td className="py-2 pr-4">
<span className="font-bold" style={{ color: item.score >= 80 ? '#22c55e' : item.score >= 50 ? '#eab308' : '#ef4444' }}>{item.score}</span>
</td>
<td className="py-2">{(item.score * item.rule.weight / 100).toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 风险提示 */}
<div className="mt-4 space-y-2">
<p className="text-xs font-bold"></p>
{scoring.items.filter(i => i.score < 60).map(i => (
<div key={i.key} className="flex items-center gap-2 rounded border border-yellow-200 bg-yellow-50/40 p-2 text-xs">
<span className="text-yellow-600"></span>
<span><strong>{i.label}</strong> {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} 以内`}</span>
</div>
))}
{scoring.items.filter(i => i.score < 60).length === 0 && (
<div className="flex items-center gap-2 rounded border border-green-200 bg-green-50/40 p-2 text-xs">
<span className="text-green-600"></span>
<span></span>
</div>
)}
</div>
</>
)}
</CollapsibleSection>
</div>
)
}
+79
View File
@@ -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<string, number> = {}
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