feat: 态势感知模块 - 实时监控面板+后端API
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>马兰拉面数字化运营管理平台</title>
|
||||
<title>连锁运营企业态势感知系统</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DataQualityPage } from '@/pages/DataQualityPage'
|
||||
import { OntologyPage } from '@/pages/OntologyPage'
|
||||
import { SiteSelectionPage } from '@/pages/SiteSelectionPage'
|
||||
import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage'
|
||||
import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -85,6 +86,7 @@ export default function App() {
|
||||
<Route path="/ontology" element={<OntologyPage />} />
|
||||
<Route path="/site-selection" element={<SiteSelectionPage />} />
|
||||
<Route path="/smart-scheduling" element={<SmartSchedulingPage />} />
|
||||
<Route path="/situational-awareness" element={<SituationalAwarenessPage />} />
|
||||
<Route path="/login" element={<Navigate to="/" />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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 } from 'lucide-react'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -25,6 +25,7 @@ const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
title: '经营管理',
|
||||
items: [
|
||||
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
|
||||
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||
@@ -82,7 +83,7 @@ export function Layout({ children, user, onLogout }: LayoutProps) {
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}>
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<span className="text-lg font-bold">马兰运营平台</span>
|
||||
<span className="text-lg font-bold">态势感知系统</span>
|
||||
</div>
|
||||
<nav className="space-y-4 p-3">
|
||||
{visibleGroups.map((group) => (
|
||||
|
||||
@@ -53,6 +53,11 @@ export function DashboardPage() {
|
||||
queryFn: () => api.get('/platform/economics'),
|
||||
})
|
||||
|
||||
const { data: alertsData } = useQuery({
|
||||
queryKey: ['sa-alerts'],
|
||||
queryFn: () => api.get('/situational-awareness/alerts'),
|
||||
})
|
||||
|
||||
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
|
||||
|
||||
const od = (overview as any)?.data
|
||||
@@ -62,6 +67,8 @@ export function DashboardPage() {
|
||||
const lh = (loopHealth as any)?.data
|
||||
const quadrantRows = (quadrantData as any)?.data || []
|
||||
const platformRows = (platformData as any)?.data || []
|
||||
const alerts = ((alertsData as any)?.data?.alerts || []) as any[]
|
||||
const alertSummary = (alertsData as any)?.data
|
||||
|
||||
const QUADRANT_COLORS: Record<string, string> = {
|
||||
'明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7',
|
||||
@@ -220,6 +227,35 @@ export function DashboardPage() {
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 态势感知预警 */}
|
||||
{alerts.length > 0 && (
|
||||
<CollapsibleSection
|
||||
title="态势感知预警"
|
||||
subtitle="营收异动 · 成本异动 · 人力异动 · 平台依赖 · 任务逾期"
|
||||
headerRight={
|
||||
<div className="flex gap-2">
|
||||
{alertSummary?.red > 0 && <span className="rounded-md bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">红色 {alertSummary.red}</span>}
|
||||
{alertSummary?.orange > 0 && <span className="rounded-md bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-700">橙色 {alertSummary.orange}</span>}
|
||||
<span className="text-xs text-muted-foreground">共 {alertSummary?.total || 0} 条</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{alerts.slice(0, 6).map((a, i) => (
|
||||
<div key={i} className={`flex items-start gap-2 rounded-md border p-2.5 ${a.level === 'red' ? 'border-red-200 bg-red-50/50' : a.level === 'orange' ? 'border-orange-200 bg-orange-50/50' : 'border-yellow-200 bg-yellow-50/50'}`}>
|
||||
<span className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${a.level === 'red' ? 'text-red-700' : a.level === 'orange' ? 'text-orange-700' : 'text-yellow-700'}`}>
|
||||
{a.level === 'red' ? '红' : a.level === 'orange' ? '橙' : '黄'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">{a.title}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 图表区 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="日度实收趋势" subtitle="近30天实收变化">
|
||||
|
||||
@@ -27,7 +27,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30">
|
||||
<div className="w-80 rounded-lg border bg-card p-6 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-xl font-bold">马兰拉面数字化运营平台</h1>
|
||||
<h1 className="mb-6 text-center text-xl font-bold">连锁运营企业态势感知系统</h1>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">用户名</label>
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Legend, Cell } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { formatCurrency, cn } from '@/lib/utils'
|
||||
import { Activity, AlertTriangle, Link2, TrendingUp } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// ============ 可复用分页筛选排序表格 ============
|
||||
|
||||
interface FilterableTableProps {
|
||||
data: any[]
|
||||
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
|
||||
onRowClick?: (row: any) => void
|
||||
filterKey?: string
|
||||
filterLabel?: string
|
||||
sortOptions: { key: string; label: string }[]
|
||||
defaultSort?: string
|
||||
defaultOrder?: 'asc' | 'desc'
|
||||
statusFilterKey?: string
|
||||
statusFilterLabel?: string
|
||||
statusOptions?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
function FilterableTable({
|
||||
data, columns, onRowClick,
|
||||
filterKey, filterLabel,
|
||||
sortOptions, defaultSort, defaultOrder = 'desc',
|
||||
statusFilterKey, statusFilterLabel, statusOptions,
|
||||
}: FilterableTableProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sort, setSort] = useState(defaultSort || sortOptions[0]?.key || '')
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>(defaultOrder)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const filterValues = useMemo(() => {
|
||||
if (!filterKey) return []
|
||||
return [...new Set(data.map((r: any) => r[filterKey]).filter(Boolean))].sort() as string[]
|
||||
}, [data, filterKey])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let r = data
|
||||
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
|
||||
if (statusFilter && statusFilterKey) r = r.filter((row: any) => row[statusFilterKey] === statusFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0)
|
||||
const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0)
|
||||
return order === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [data, filter, filterKey, sort, order, statusFilter, statusFilterKey])
|
||||
|
||||
const total = filtered.length
|
||||
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{filterKey && (
|
||||
<select value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">{filterLabel || '全部'}</option>
|
||||
{filterValues.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{statusFilterKey && statusOptions && (
|
||||
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">{statusFilterLabel || '全部状态'}</option>
|
||||
{statusOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className="mx-1 text-muted-foreground">|</span>
|
||||
{sortOptions.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
||||
按{s.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="rounded bg-muted px-3 py-1 text-xs">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={paged} onRowClick={onRowClick} />
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'health', label: '健康度评分', icon: Activity },
|
||||
{ key: 'alerts', label: '预警中心', icon: AlertTriangle },
|
||||
{ key: 'correlation', label: '关联分析', icon: Link2 },
|
||||
{ key: 'forecast', label: '趋势预测', icon: TrendingUp },
|
||||
]
|
||||
|
||||
const ALERT_LEVEL_STYLE: Record<string, string> = {
|
||||
red: 'border-red-300 bg-red-50',
|
||||
orange: 'border-orange-300 bg-orange-50',
|
||||
yellow: 'border-yellow-300 bg-yellow-50',
|
||||
}
|
||||
const ALERT_LEVEL_TEXT: Record<string, string> = {
|
||||
red: 'text-red-700',
|
||||
orange: 'text-orange-700',
|
||||
yellow: 'text-yellow-700',
|
||||
}
|
||||
const ALERT_LEVEL_LABEL: Record<string, string> = {
|
||||
red: '红色',
|
||||
orange: '橙色',
|
||||
yellow: '黄色',
|
||||
}
|
||||
const ALERT_TYPE_LABEL: Record<string, string> = {
|
||||
revenue: '营收异动',
|
||||
cost: '成本异动',
|
||||
hr: '人力异动',
|
||||
platform: '平台依赖',
|
||||
task: '任务逾期',
|
||||
}
|
||||
|
||||
export function SituationalAwarenessPage() {
|
||||
const [tab, setTab] = useState('health')
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">态势感知</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">门店健康度 · 自动化预警 · 跨模块关联 · 趋势预测</p>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon
|
||||
const active = tab === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
||||
active ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === 'health' && <HealthScoreTab navigate={navigate} />}
|
||||
{tab === 'alerts' && <AlertsTab />}
|
||||
{tab === 'correlation' && <CorrelationTab navigate={navigate} />}
|
||||
{tab === 'forecast' && <ForecastTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Tab 1: 门店健康度综合评分 ============
|
||||
|
||||
function HealthScoreTab({ navigate }: { navigate: (path: string) => void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['sa-health-score'],
|
||||
queryFn: () => api.get('/situational-awareness/health-score'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载健康度评分..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
|
||||
const statusCount = rows.reduce((acc: any, r: any) => {
|
||||
acc[r.health_status] = (acc[r.health_status] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const scoreBuckets = [
|
||||
{ range: '75-100', label: '健康', count: 0, color: '#22c55e' },
|
||||
{ range: '55-74', label: '亚健康', count: 0, color: '#eab308' },
|
||||
{ range: '0-54', label: '需干预', count: 0, color: '#ef4444' },
|
||||
]
|
||||
rows.forEach((r: any) => {
|
||||
const s = Number(r.health_score)
|
||||
if (s >= 75) scoreBuckets[0].count++
|
||||
else if (s >= 55) scoreBuckets[1].count++
|
||||
else scoreBuckets[2].count++
|
||||
})
|
||||
|
||||
const healthStatusColor: Record<string, string> = {
|
||||
'健康': 'bg-green-100 text-green-700 border-green-300',
|
||||
'亚健康': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'需干预': 'bg-red-100 text-red-700 border-red-300',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 概览卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{scoreBuckets.map((b) => (
|
||||
<div key={b.range} className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{b.label}</p>
|
||||
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: b.color }} />
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold" style={{ color: b.color }}>{b.count}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">评分 {b.range}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 评分分布柱状图 */}
|
||||
<CollapsibleSection title="健康度评分分布" subtitle="按评分区间统计门店数量">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={scoreBuckets}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="门店数" radius={[4, 4, 0, 0]}>
|
||||
{scoreBuckets.map((b, i) => <Cell key={i} fill={b.color} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店评分明细表 */}
|
||||
<CollapsibleSection title={`门店健康度明细 (${rows.length})`} subtitle="综合营收规模、成本控制、风险等级、会员复购、任务执行、会员占比六维度评分">
|
||||
<FilterableTable
|
||||
data={rows}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'health_score', label: '健康分' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'avg_margin', label: '毛利率' },
|
||||
{ key: 'over_cost_rate', label: '超耗率' },
|
||||
{ key: 'repeat_rate_pct', label: '复购率' },
|
||||
{ key: 'task_completion_rate', label: '完成率' },
|
||||
]}
|
||||
statusFilterKey="health_status"
|
||||
statusFilterLabel="全部状态"
|
||||
statusOptions={[
|
||||
{ value: '健康', label: '健康' },
|
||||
{ value: '亚健康', label: '亚健康' },
|
||||
{ value: '需干预', label: '需干预' },
|
||||
]}
|
||||
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'health_score', label: '健康分', align: 'center', render: (r) => (
|
||||
<span className={cn('font-bold', Number(r.health_score) >= 75 ? 'text-green-600' : Number(r.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}>
|
||||
{r.health_score}
|
||||
</span>
|
||||
)},
|
||||
{ key: 'health_status', label: '状态', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', healthStatusColor[r.health_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.health_status}
|
||||
</span>
|
||||
)},
|
||||
{ key: 'risk_level', label: '风险', align: 'center', render: (r) => r.risk_level ? <Badge type="risk" text={r.risk_level} /> : '-' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
|
||||
{ key: 'avg_margin', label: '毛利率', align: 'right', render: (r) => `${r.avg_margin}%` },
|
||||
{ key: 'over_cost_rate', label: '超耗率', align: 'right', render: (r) => `${r.over_cost_rate}%` },
|
||||
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => `${r.repeat_rate_pct}%` },
|
||||
{ key: 'task_completion_rate', label: '任务完成率', align: 'right', render: (r) => `${r.task_completion_rate}%` },
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Tab 2: 预警中心 ============
|
||||
|
||||
function AlertsTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['sa-alerts'],
|
||||
queryFn: () => api.get('/situational-awareness/alerts'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载预警信息..." />
|
||||
|
||||
const d = (data as any)?.data
|
||||
const alerts = d?.alerts || []
|
||||
|
||||
// 按类型分组统计
|
||||
const typeCount = alerts.reduce((acc: any, a: any) => {
|
||||
acc[a.type] = (acc[a.type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 预警概览 */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">总预警</p>
|
||||
<p className="mt-2 text-2xl font-bold">{d?.total || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50/50 p-4 shadow-sm">
|
||||
<p className="text-sm text-red-700">红色预警</p>
|
||||
<p className="mt-2 text-2xl font-bold text-red-600">{d?.red || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50/50 p-4 shadow-sm">
|
||||
<p className="text-sm text-orange-700">橙色预警</p>
|
||||
<p className="mt-2 text-2xl font-bold text-orange-600">{d?.orange || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">预警类型</p>
|
||||
<p className="mt-2 text-2xl font-bold">{Object.keys(typeCount).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型分布 */}
|
||||
<CollapsibleSection title="预警类型分布" subtitle="按预警类型统计数量">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(typeCount).map(([type, count]) => (
|
||||
<span key={type} className="inline-flex items-center gap-1.5 rounded-md border bg-muted/50 px-3 py-1.5 text-sm">
|
||||
<span className="font-medium">{ALERT_TYPE_LABEL[type] || type}</span>
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-bold text-primary">{count as number}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 预警列表 */}
|
||||
<CollapsibleSection title={`预警明细 (${alerts.length})`} subtitle="按严重程度排序">
|
||||
<AlertList alerts={alerts} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertList({ alerts }: { alerts: any[] }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [levelFilter, setLevelFilter] = useState('')
|
||||
|
||||
const stores = useMemo(() => [...new Set(alerts.map((a: any) => a.store).filter(Boolean))].sort(), [alerts])
|
||||
const types = useMemo(() => [...new Set(alerts.map((a: any) => a.type).filter(Boolean))].sort(), [alerts])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let r = alerts
|
||||
if (storeFilter) r = r.filter((a: any) => a.store === storeFilter)
|
||||
if (typeFilter) r = r.filter((a: any) => a.type === typeFilter)
|
||||
if (levelFilter) r = r.filter((a: any) => a.level === levelFilter)
|
||||
const levelOrder: Record<string, number> = { red: 0, orange: 1, yellow: 2 }
|
||||
return [...r].sort((a: any, b: any) => (levelOrder[a.level] ?? 9) - (levelOrder[b.level] ?? 9))
|
||||
}, [alerts, storeFilter, typeFilter, levelFilter])
|
||||
|
||||
const total = filtered.length
|
||||
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
if (alerts.length === 0) return <p className="py-8 text-center text-muted-foreground">暂无预警</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{stores.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={typeFilter} onChange={(e) => { setTypeFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">全部类型</option>
|
||||
{types.map((t) => <option key={t} value={t}>{ALERT_TYPE_LABEL[t] || t}</option>)}
|
||||
</select>
|
||||
<select value={levelFilter} onChange={(e) => { setLevelFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">全部等级</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="orange">橙色</option>
|
||||
<option value="yellow">黄色</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{paged.map((a: any, i: number) => (
|
||||
<div key={i} className={cn('flex items-start gap-3 rounded-md border p-3', ALERT_LEVEL_STYLE[a.level] || 'border-gray-200 bg-gray-50')}>
|
||||
<span className={cn('mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold', ALERT_LEVEL_TEXT[a.level] || 'text-gray-600')}>
|
||||
{ALERT_LEVEL_LABEL[a.level] || a.level}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{a.title}</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{ALERT_TYPE_LABEL[a.type] || a.type}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{a.detail}</p>
|
||||
</div>
|
||||
{a.store && a.store !== '全局' && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{a.store}</span>
|
||||
)}
|
||||
{a.date && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{a.date}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Tab 3: 跨模块关联分析 ============
|
||||
|
||||
function CorrelationTab({ navigate }: { navigate: (path: string) => void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['sa-correlation'],
|
||||
queryFn: () => api.get('/situational-awareness/correlation'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载关联分析..." />
|
||||
|
||||
const d = (data as any)?.data
|
||||
const costRiskRows = d?.cost_risk || []
|
||||
const channelRiskRows = d?.channel_risk || []
|
||||
const hrRevenueRows = d?.hr_revenue || []
|
||||
const staffingRows = d?.staffing_efficiency || []
|
||||
|
||||
const correlationStatusColor: Record<string, string> = {
|
||||
'成本失控+高风险': 'bg-red-100 text-red-700 border-red-300',
|
||||
'高风险': 'bg-red-100 text-red-700 border-red-300',
|
||||
'成本偏高+中风险': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'成本失控': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'正常': 'bg-green-100 text-green-700 border-green-300',
|
||||
}
|
||||
const channelStatusColor: Record<string, string> = {
|
||||
'渠道依赖预警': 'bg-red-100 text-red-700 border-red-300',
|
||||
'渠道风险': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'会员驱动型': 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
'均衡型': 'bg-green-100 text-green-700 border-green-300',
|
||||
}
|
||||
const hrStatusColor: Record<string, string> = {
|
||||
'出勤低+有营收': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'出勤低+营收低': 'bg-red-100 text-red-700 border-red-300',
|
||||
'旷工严重': 'bg-red-100 text-red-700 border-red-300',
|
||||
'正常': 'bg-green-100 text-green-700 border-green-300',
|
||||
}
|
||||
const matchStatusColor: Record<string, string> = {
|
||||
'高峰人手不足': 'bg-red-100 text-red-700 border-red-300',
|
||||
'低谷人员冗余': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'无在岗数据': 'bg-gray-100 text-gray-600 border-gray-300',
|
||||
'配置合理': 'bg-green-100 text-green-700 border-green-300',
|
||||
}
|
||||
|
||||
// 散点图数据:成本-风险
|
||||
const costRiskScatter = costRiskRows.map((r: any) => ({
|
||||
name: r.store_name,
|
||||
x: Number(r.avg_daily_received || 0),
|
||||
y: Number(r.over_cost_rate || 0),
|
||||
risk: r.risk_level,
|
||||
fill: r.risk_level === '红色' ? '#ef4444' : r.risk_level === '黄色' ? '#eab308' : '#22c55e',
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 成本-风险散点图 */}
|
||||
<CollapsibleSection title="成本-风险关联分布" subtitle="日均实收 × 超耗率,颜色表示风险等级">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" dataKey="x" name="日均实收" tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 10 }} />
|
||||
<YAxis type="number" dataKey="y" name="超耗率" unit="%" tick={{ fontSize: 10 }} />
|
||||
<ZAxis range={[40, 40]} />
|
||||
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, n: string) => n === '超耗率' ? `${Number(v).toFixed(1)}%` : n === '日均实收' ? `¥${Number(v).toFixed(0)}` : v} />
|
||||
<Scatter data={costRiskScatter} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 成本-风险明细表 */}
|
||||
<CollapsibleSection title={`成本-风险关联 (${costRiskRows.length})`} subtitle="门店成本控制与风险等级的关联分析">
|
||||
<FilterableTable
|
||||
data={costRiskRows}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'avg_margin', label: '毛利率' },
|
||||
{ key: 'over_cost_rate', label: '超耗率' },
|
||||
]}
|
||||
statusFilterKey="risk_level"
|
||||
statusFilterLabel="全部风险"
|
||||
statusOptions={[
|
||||
{ value: '红色', label: '红色' },
|
||||
{ value: '黄色', label: '黄色' },
|
||||
{ value: '绿色', label: '绿色' },
|
||||
]}
|
||||
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'risk_level', label: '风险', align: 'center', render: (r) => r.risk_level ? <Badge type="risk" text={r.risk_level} /> : '-' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'avg_margin', label: '毛利率', align: 'right', render: (r) => `${r.avg_margin}%` },
|
||||
{ key: 'over_cost_rate', label: '超耗率', align: 'right', render: (r) => `${r.over_cost_rate}%` },
|
||||
{ key: 'correlation_status', label: '关联状态', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', correlationStatusColor[r.correlation_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.correlation_status}
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 渠道-会员关联 */}
|
||||
<CollapsibleSection title={`渠道-会员关联 (${channelRiskRows.length})`} subtitle="平台依赖度与会员占比的三角分析">
|
||||
<FilterableTable
|
||||
data={channelRiskRows}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'platform_share', label: '平台占比' },
|
||||
{ key: 'member_share_pct', label: '会员占比' },
|
||||
{ key: 'repeat_rate_pct', label: '复购率' },
|
||||
]}
|
||||
statusFilterKey="channel_status"
|
||||
statusFilterLabel="全部状态"
|
||||
statusOptions={[
|
||||
{ value: '渠道依赖预警', label: '渠道依赖预警' },
|
||||
{ value: '渠道风险', label: '渠道风险' },
|
||||
{ value: '会员驱动型', label: '会员驱动型' },
|
||||
{ value: '均衡型', label: '均衡型' },
|
||||
]}
|
||||
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'platform_share', label: '平台占比', align: 'right', render: (r) => `${r.platform_share}%` },
|
||||
{ key: 'member_share_pct', label: '会员占比', align: 'right', render: (r) => `${r.member_share_pct}%` },
|
||||
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => `${r.repeat_rate_pct}%` },
|
||||
{ key: 'channel_status', label: '渠道状态', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', channelStatusColor[r.channel_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.channel_status}
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 考勤-营收关联 */}
|
||||
<CollapsibleSection title={`考勤-营收关联 (${hrRevenueRows.length})`} subtitle="人员出勤与营收产出的关联分析">
|
||||
<FilterableTable
|
||||
data={hrRevenueRows}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'emp_count', label: '员工数' },
|
||||
{ key: 'avg_attend', label: '平均出勤' },
|
||||
{ key: 'total_absent', label: '旷工天数' },
|
||||
{ key: 'revenue_per_emp', label: '人效' },
|
||||
]}
|
||||
statusFilterKey="hr_status"
|
||||
statusFilterLabel="全部状态"
|
||||
statusOptions={[
|
||||
{ value: '出勤低+有营收', label: '出勤低+有营收' },
|
||||
{ value: '出勤低+营收低', label: '出勤低+营收低' },
|
||||
{ value: '旷工严重', label: '旷工严重' },
|
||||
{ value: '正常', label: '正常' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'emp_count', label: '员工数', align: 'center' },
|
||||
{ key: 'avg_attend', label: '平均出勤', align: 'right', render: (r) => r.avg_attend },
|
||||
{ key: 'total_absent', label: '旷工天数', align: 'center' },
|
||||
{ key: 'low_attend_rate', label: '低出勤率', align: 'right', render: (r) => `${r.low_attend_rate}%` },
|
||||
{ key: 'revenue_per_emp', label: '人效', align: 'right', render: (r) => formatCurrency(r.revenue_per_emp) },
|
||||
{ key: 'hr_status', label: '状态', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', hrStatusColor[r.hr_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.hr_status}
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 客流-人力匹配 */}
|
||||
<CollapsibleSection title={`客流-人力匹配 (${staffingRows.length})`} subtitle="每小时账单数与在岗人数的匹配度" defaultOpen={false}>
|
||||
<FilterableTable
|
||||
data={staffingRows}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'bills', label: '账单数' },
|
||||
{ key: 'staff', label: '在岗人数' },
|
||||
{ key: 'bills_per_staff', label: '人均产出' },
|
||||
]}
|
||||
statusFilterKey="match_status"
|
||||
statusFilterLabel="全部状态"
|
||||
statusOptions={[
|
||||
{ value: '高峰人手不足', label: '高峰人手不足' },
|
||||
{ value: '低谷人员冗余', label: '低谷人员冗余' },
|
||||
{ value: '配置合理', label: '配置合理' },
|
||||
{ value: '无在岗数据', label: '无在岗数据' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'hour', label: '时段', align: 'center', render: (r) => `${r.hour}:00` },
|
||||
{ key: 'bills', label: '账单数', align: 'right' },
|
||||
{ key: 'staff', label: '在岗人数', align: 'right' },
|
||||
{ key: 'bills_per_staff', label: '人均产出', align: 'right' },
|
||||
{ key: 'match_status', label: '匹配状态', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', matchStatusColor[r.match_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.match_status}
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Tab 4: 趋势预测 ============
|
||||
|
||||
function ForecastTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['sa-forecast'],
|
||||
queryFn: () => api.get('/situational-awareness/forecast'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载趋势预测..." />
|
||||
|
||||
const d = (data as any)?.data
|
||||
const trafficRows = d?.traffic_forecast || []
|
||||
const costTrendRows = d?.cost_trend || []
|
||||
const turnoverRows = d?.turnover_alert || []
|
||||
|
||||
// 工作日/周末分开
|
||||
const weekdayData = trafficRows.filter((r: any) => r.day_type === '工作日')
|
||||
const weekendData = trafficRows.filter((r: any) => r.day_type === '周末')
|
||||
|
||||
const stabilityColor: Record<string, string> = {
|
||||
'波动大': 'bg-red-100 text-red-700 border-red-300',
|
||||
'中等波动': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'稳定': 'bg-green-100 text-green-700 border-green-300',
|
||||
}
|
||||
const trendStatusColor: Record<string, string> = {
|
||||
'恶化': 'bg-red-100 text-red-700 border-red-300',
|
||||
'关注': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'正常': 'bg-green-100 text-green-700 border-green-300',
|
||||
'数据异常': 'bg-gray-100 text-gray-600 border-gray-300',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 客流预测图 */}
|
||||
<CollapsibleSection title="客流预测(分时段)" subtitle="基于历史4周数据,按工作日/周末×小时维度P85分位预测">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={weekdayData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" tickFormatter={(v) => `${v}:00`} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip labelFormatter={(v) => `${v}:00`} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Line type="monotone" dataKey="avg_bills" name="工作日均值" stroke="#3b82f6" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="p85_bills" name="工作日P85" stroke="#3b82f6" strokeDasharray="5 5" dot={false} />
|
||||
<Line type="monotone" dataKey="p50_bills" name="工作日P50" stroke="#93c5fd" dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={weekendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" tickFormatter={(v) => `${v}:00`} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip labelFormatter={(v) => `${v}:00`} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Line type="monotone" dataKey="avg_bills" name="周末均值" stroke="#f97316" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="p85_bills" name="周末P85" stroke="#f97316" strokeDasharray="5 5" dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 客流稳定性明细 */}
|
||||
<CollapsibleSection title={`客流稳定性明细 (${trafficRows.length})`} subtitle="各时段波动率与稳定性评估">
|
||||
<FilterableTable
|
||||
data={trafficRows}
|
||||
filterKey="day_type"
|
||||
filterLabel="全部类型"
|
||||
sortOptions={[
|
||||
{ key: 'hour', label: '时段' },
|
||||
{ key: 'avg_bills', label: '均值' },
|
||||
{ key: 'p85_bills', label: 'P85' },
|
||||
{ key: 'volatility', label: '波动率' },
|
||||
]}
|
||||
statusFilterKey="stability"
|
||||
statusFilterLabel="全部稳定性"
|
||||
statusOptions={[
|
||||
{ value: '稳定', label: '稳定' },
|
||||
{ value: '中等波动', label: '中等波动' },
|
||||
{ value: '波动大', label: '波动大' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'day_type', label: '日类型', align: 'center' },
|
||||
{ key: 'hour', label: '时段', align: 'center', render: (r) => `${r.hour}:00` },
|
||||
{ key: 'avg_bills', label: '均值', align: 'right' },
|
||||
{ key: 'p50_bills', label: 'P50', align: 'right' },
|
||||
{ key: 'p85_bills', label: 'P85', align: 'right' },
|
||||
{ key: 'volatility', label: '波动率', align: 'right', render: (r) => r.volatility },
|
||||
{ key: 'stability', label: '稳定性', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', stabilityColor[r.stability] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.stability}
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 成本趋势 */}
|
||||
<CollapsibleSection title={`成本趋势 TOP20 (${costTrendRows.length})`} subtitle="菜品成本差异恶化排名">
|
||||
<FilterableTable
|
||||
data={costTrendRows}
|
||||
filterKey="dish_name"
|
||||
filterLabel="全部菜品"
|
||||
sortOptions={[
|
||||
{ key: 'sales_amount', label: '销售额' },
|
||||
{ key: 'theo_margin', label: '理论毛利率' },
|
||||
{ key: 'actual_margin', label: '实际毛利率' },
|
||||
{ key: 'variance', label: '差异金额' },
|
||||
]}
|
||||
statusFilterKey="trend_status"
|
||||
statusFilterLabel="全部趋势"
|
||||
statusOptions={[
|
||||
{ value: '恶化', label: '恶化' },
|
||||
{ value: '关注', label: '关注' },
|
||||
{ value: '正常', label: '正常' },
|
||||
{ value: '数据异常', label: '数据异常' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'dish_name', label: '菜品' },
|
||||
{ key: 'category_level1', label: '分类' },
|
||||
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
|
||||
{ key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => `${r.theo_margin}%` },
|
||||
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => `${r.actual_margin}%` },
|
||||
{ key: 'variance', label: '差异金额', align: 'right', render: (r) => formatCurrency(r.variance) },
|
||||
{ key: 'trend_status', label: '趋势', align: 'center', render: (r) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', trendStatusColor[r.trend_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.trend_status}
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 人员流失预警 */}
|
||||
<CollapsibleSection title={`人员流失预警 (${turnoverRows.length})`} subtitle="有离职人员的门店流失率排名">
|
||||
<FilterableTable
|
||||
data={turnoverRows}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
{ key: 'left_count', label: '离职人数' },
|
||||
{ key: 'new_count', label: '新入职' },
|
||||
{ key: 'turnover_rate', label: '流失率' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_emp', label: '总人数', align: 'center' },
|
||||
{ key: 'left_count', label: '离职人数', align: 'center' },
|
||||
{ key: 'new_count', label: '新入职', align: 'center' },
|
||||
{ key: 'turnover_rate', label: '流失率', align: 'right', render: (r) => (
|
||||
<span className={Number(r.turnover_rate) > 20 ? 'font-bold text-red-600' : Number(r.turnover_rate) > 10 ? 'text-orange-600' : ''}>
|
||||
{r.turnover_rate}%
|
||||
</span>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -126,10 +126,10 @@ export function StoreDetailPage() {
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={daily}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="business_date" tick={{ fontSize: 11 }} />
|
||||
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" />
|
||||
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -9,6 +9,7 @@ import taskRoutes from './routes/tasks.js'
|
||||
import costAnalysisRoutes from './routes/cost-analysis.js'
|
||||
import storeExpenseRoutes from './routes/store-expense.js'
|
||||
import smartSchedulingRoutes from './routes/smart-scheduling.js'
|
||||
import situationalAwarenessRoutes from './routes/situational-awareness.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = parseInt(process.env.PORT || '3333')
|
||||
@@ -37,6 +38,7 @@ app.use('/api/tasks', taskRoutes)
|
||||
app.use('/api/cost-analysis', costAnalysisRoutes)
|
||||
app.use('/api/store-expense', storeExpenseRoutes)
|
||||
app.use('/api/smart-scheduling', smartSchedulingRoutes)
|
||||
app.use('/api/situational-awareness', situationalAwarenessRoutes)
|
||||
|
||||
app.use(notFoundHandler)
|
||||
app.use(errorHandler)
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ============ P0: 门店健康度综合评分 ============
|
||||
|
||||
router.get('/health-score', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
WITH base AS (
|
||||
SELECT store_code, store_name, received, bill_count,
|
||||
avg_bill_value, avg_daily_received, theoretical_margin_pct,
|
||||
member_bill_share_pct, risk_level
|
||||
FROM analytics.mv_store_risk_rating
|
||||
WHERE received IS NOT NULL
|
||||
),
|
||||
max_rev AS (
|
||||
SELECT max(avg_daily_received) AS max_avg FROM base WHERE avg_daily_received > 0
|
||||
),
|
||||
cost AS (
|
||||
SELECT store_code,
|
||||
round(sum(CASE WHEN variance_to_theoretical_pct > 0 THEN 1 ELSE 0 END)::numeric / nullif(count(*), 0) * 100, 1) AS over_cost_rate
|
||||
FROM analytics.v_store_theoretical_actual_cost_april
|
||||
GROUP BY store_code
|
||||
),
|
||||
member AS (
|
||||
SELECT store_code, repeat_rate_pct
|
||||
FROM analytics.v_store_repeat_summary_monthly
|
||||
),
|
||||
task AS (
|
||||
SELECT store_code,
|
||||
count(*) AS total_tasks,
|
||||
count(*) FILTER (WHERE status = '已验收' AND verification_result = '达标') AS passed_tasks,
|
||||
round(count(*) FILTER (WHERE status IN ('已验收', '已回滚'))::numeric / nullif(count(*), 0) * 100, 1) AS completion_rate
|
||||
FROM analytics.store_task
|
||||
GROUP BY store_code
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
b.store_code, b.store_name, b.received, b.bill_count, b.avg_bill_value,
|
||||
b.risk_level,
|
||||
COALESCE(b.theoretical_margin_pct, 0) AS avg_margin,
|
||||
COALESCE(c.over_cost_rate, 0) AS over_cost_rate,
|
||||
COALESCE(m.repeat_rate_pct, 0) AS repeat_rate_pct,
|
||||
COALESCE(b.member_bill_share_pct, 0) AS member_share_pct,
|
||||
COALESCE(t.completion_rate, 0) AS task_completion_rate,
|
||||
LEAST(100, GREATEST(0,
|
||||
25 * CASE WHEN b.avg_daily_received > 0 THEN LEAST(1.0, b.avg_daily_received / mx.max_avg) ELSE 0 END
|
||||
+ 20 * GREATEST(0, LEAST(1.0, COALESCE(b.theoretical_margin_pct, 0) / 70)) * GREATEST(0, 1 - COALESCE(c.over_cost_rate, 0) / 100)
|
||||
+ CASE b.risk_level WHEN '绿色' THEN 20 WHEN '黄色' THEN 12 WHEN '红色' THEN 4 ELSE 10 END
|
||||
+ 10 * GREATEST(0, LEAST(1.0, COALESCE(m.repeat_rate_pct, 0) / 80))
|
||||
+ 10 * COALESCE(t.completion_rate, 0) / 100
|
||||
+ 10 * GREATEST(0, LEAST(1.0, COALESCE(b.member_bill_share_pct, 0) / 60))
|
||||
))::numeric(5,1) AS health_score
|
||||
FROM base b
|
||||
CROSS JOIN max_rev mx
|
||||
LEFT JOIN cost c ON b.store_code = c.store_code
|
||||
LEFT JOIN member m ON b.store_code = m.store_code
|
||||
LEFT JOIN task t ON b.store_code = t.store_code
|
||||
)
|
||||
SELECT store_code, store_name, received, bill_count, avg_bill_value,
|
||||
risk_level, avg_margin, over_cost_rate, repeat_rate_pct,
|
||||
member_share_pct, task_completion_rate, health_score,
|
||||
CASE WHEN health_score >= 75 THEN '健康'
|
||||
WHEN health_score >= 55 THEN '亚健康'
|
||||
ELSE '需干预'
|
||||
END AS health_status
|
||||
FROM scored
|
||||
ORDER BY health_score DESC
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ P0: 自动化阈值预警 ============
|
||||
|
||||
router.get('/alerts', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const alerts: any[] = []
|
||||
|
||||
// 1. 营收异动预警:日营收连续低于月均70%
|
||||
const revenueAlerts = await query(`
|
||||
WITH daily_stats AS (
|
||||
SELECT closed_at::date AS business_date,
|
||||
count(*) AS bill_count,
|
||||
sum(received_total) AS received
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact)
|
||||
AND closed_at IS NOT NULL
|
||||
GROUP BY closed_at::date
|
||||
ORDER BY business_date
|
||||
),
|
||||
monthly_avg AS (
|
||||
SELECT avg(received) AS avg_received
|
||||
FROM daily_stats
|
||||
)
|
||||
SELECT d.business_date::text, d.received, m.avg_received,
|
||||
round(d.received / nullif(m.avg_received, 0) * 100, 1) AS ratio
|
||||
FROM daily_stats d, monthly_avg m
|
||||
WHERE d.received < m.avg_received * 0.7
|
||||
ORDER BY d.business_date DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
revenueAlerts.rows.forEach((r: any) => {
|
||||
alerts.push({
|
||||
type: 'revenue',
|
||||
level: 'red',
|
||||
title: `营收异动 ${r.business_date}`,
|
||||
detail: `日营收 ¥${Number(r.received).toFixed(0)} 仅为月均的 ${r.ratio}%`,
|
||||
store: '全局',
|
||||
date: r.business_date,
|
||||
})
|
||||
})
|
||||
|
||||
// 2. 成本异动预警:成本差异>30%的菜品
|
||||
const costAlerts = await query(`
|
||||
SELECT dish_name, dish_code, category_level1,
|
||||
round(cost_variance_amount::numeric, 2) AS variance,
|
||||
round((cost_variance_amount / nullif(theoretical_cost, 0) * 100)::numeric, 2) AS variance_pct,
|
||||
round(sales_amount::numeric, 2) AS sales_amount
|
||||
FROM public.dish_cost_analysis_summary
|
||||
WHERE cost_variance_amount > 0
|
||||
AND theoretical_cost > 0
|
||||
AND (cost_variance_amount / theoretical_cost) > 0.3
|
||||
ORDER BY variance_pct DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
costAlerts.rows.forEach((r: any) => {
|
||||
alerts.push({
|
||||
type: 'cost',
|
||||
level: 'red',
|
||||
title: `成本异动 ${r.dish_name}`,
|
||||
detail: `成本差异率 ${r.variance_pct}%,差异金额 ¥${r.variance}`,
|
||||
store: '全局',
|
||||
date: null,
|
||||
})
|
||||
})
|
||||
|
||||
// 3. 人力异动预警:考勤异常员工
|
||||
const hrAlerts = await query(`
|
||||
SELECT s.org_level5 AS store_name, count(*) AS alert_count,
|
||||
count(*) FILTER (WHERE s.absent_days > 0) AS absent_count,
|
||||
count(*) FILTER (WHERE s.late_deduction > 0 OR s.no_punch_deduction > 0) AS punch_issue_count
|
||||
FROM salary_detail_records s
|
||||
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
|
||||
AND (s.absent_days > 0 OR s.late_deduction > 0 OR s.no_punch_deduction > 0)
|
||||
GROUP BY s.org_level5
|
||||
HAVING count(*) > 3
|
||||
ORDER BY alert_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
hrAlerts.rows.forEach((r: any) => {
|
||||
alerts.push({
|
||||
type: 'hr',
|
||||
level: r.alert_count > 5 ? 'red' : 'orange',
|
||||
title: `考勤异常 ${r.store_name}`,
|
||||
detail: `异常员工 ${r.alert_count} 人(旷工 ${r.absent_count},打卡问题 ${r.punch_issue_count})`,
|
||||
store: r.store_name,
|
||||
date: null,
|
||||
})
|
||||
})
|
||||
|
||||
// 4. 平台依赖预警:外卖佣金占比>40%
|
||||
const platformAlerts = await query(`
|
||||
SELECT p.store_code, p.store_name,
|
||||
round(p.meituan_cost_rate_pct::numeric, 1) AS meituan_rate,
|
||||
round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
|
||||
round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
|
||||
round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
|
||||
FROM analytics.v_store_platform_economics p
|
||||
JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
|
||||
WHERE (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
|
||||
ORDER BY platform_share DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
platformAlerts.rows.forEach((r: any) => {
|
||||
alerts.push({
|
||||
type: 'platform',
|
||||
level: 'orange',
|
||||
title: `平台依赖 ${r.store_name}`,
|
||||
detail: `外卖占比 ${r.platform_share}%,美团 ${r.meituan_rate}%/淘宝 ${r.taobao_rate}%/京东 ${r.jd_rate}%`,
|
||||
store: r.store_name,
|
||||
date: null,
|
||||
})
|
||||
})
|
||||
|
||||
// 5. 任务逾期预警
|
||||
const taskAlerts = await query(`
|
||||
SELECT store_code, store_name, priority, problem_indicator, deadline::text AS deadline,
|
||||
current_date - deadline::date AS overdue_days
|
||||
FROM analytics.store_task
|
||||
WHERE status NOT IN ('已验收', '已回滚')
|
||||
AND deadline < current_date
|
||||
ORDER BY overdue_days DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
taskAlerts.rows.forEach((r: any) => {
|
||||
alerts.push({
|
||||
type: 'task',
|
||||
level: r.overdue_days > 14 ? 'red' : 'orange',
|
||||
title: `任务逾期 ${r.store_name}`,
|
||||
detail: `${r.priority} - ${r.problem_indicator},逾期 ${r.overdue_days} 天`,
|
||||
store: r.store_name,
|
||||
date: r.deadline,
|
||||
})
|
||||
})
|
||||
|
||||
// 按级别排序
|
||||
const levelOrder: Record<string, number> = { red: 0, orange: 1, yellow: 2 }
|
||||
alerts.sort((a, b) => (levelOrder[a.level] || 3) - (levelOrder[b.level] || 3))
|
||||
|
||||
sendSuccess(res, {
|
||||
total: alerts.length,
|
||||
red: alerts.filter(a => a.level === 'red').length,
|
||||
orange: alerts.filter(a => a.level === 'orange').length,
|
||||
alerts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ P1: 跨模块关联分析 ============
|
||||
|
||||
router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
// 客流-人力匹配度:每门店每小时"每人在岗产出账单数"
|
||||
// mv_store_hourly_staffing 可能不存在,容错处理
|
||||
let staffingRows: any[] = []
|
||||
try {
|
||||
const staffingEfficiency = await query(`
|
||||
WITH hourly_bills AS (
|
||||
SELECT store_name, hour, sum(bills) AS bills
|
||||
FROM mv_bill_hourly
|
||||
GROUP BY store_name, hour
|
||||
),
|
||||
hourly_staff AS (
|
||||
SELECT store_name, hour, total_staff
|
||||
FROM mv_store_hourly_staffing
|
||||
WHERE total_staff > 0
|
||||
)
|
||||
SELECT
|
||||
COALESCE(b.store_name, s.store_name) AS store_name,
|
||||
COALESCE(b.hour, s.hour) AS hour,
|
||||
COALESCE(b.bills, 0) AS bills,
|
||||
COALESCE(s.total_staff, 0) AS staff,
|
||||
round(COALESCE(b.bills, 0)::numeric / nullif(COALESCE(s.total_staff, 0), 0), 1) AS bills_per_staff,
|
||||
CASE
|
||||
WHEN COALESCE(s.total_staff, 0) = 0 THEN '无在岗数据'
|
||||
WHEN COALESCE(b.bills, 0) > 0 AND COALESCE(b.bills, 0)::numeric / COALESCE(s.total_staff, 0) > 15 THEN '高峰人手不足'
|
||||
WHEN COALESCE(b.bills, 0) = 0 AND COALESCE(s.total_staff, 0) > 3 THEN '低谷人员冗余'
|
||||
ELSE '配置合理'
|
||||
END AS match_status
|
||||
FROM hourly_bills b
|
||||
FULL OUTER JOIN hourly_staff s ON b.store_name = s.store_name AND b.hour = s.hour
|
||||
WHERE COALESCE(b.bills, 0) > 0 OR COALESCE(s.total_staff, 0) > 0
|
||||
ORDER BY COALESCE(b.store_name, s.store_name), COALESCE(b.hour, s.hour)
|
||||
`)
|
||||
staffingRows = staffingEfficiency.rows
|
||||
} catch {
|
||||
staffingRows = []
|
||||
}
|
||||
|
||||
// 门店维度汇总:成本-风险关联(用mv_store_risk_rating替代v_store_scorecard)
|
||||
const costRisk = await query(`
|
||||
SELECT
|
||||
r.store_code, r.store_name, r.risk_level,
|
||||
r.received, r.avg_daily_received,
|
||||
COALESCE(r.theoretical_margin_pct, 0) AS avg_margin,
|
||||
COALESCE(c.over_cost_rate, 0) AS over_cost_rate,
|
||||
CASE
|
||||
WHEN r.risk_level = '红色' AND COALESCE(c.over_cost_rate, 0) > 30 THEN '成本失控+高风险'
|
||||
WHEN r.risk_level = '红色' THEN '高风险'
|
||||
WHEN r.risk_level = '黄色' AND COALESCE(c.over_cost_rate, 0) > 20 THEN '成本偏高+中风险'
|
||||
WHEN COALESCE(c.over_cost_rate, 0) > 30 THEN '成本失控'
|
||||
ELSE '正常'
|
||||
END AS correlation_status
|
||||
FROM analytics.mv_store_risk_rating r
|
||||
LEFT JOIN (
|
||||
SELECT store_code,
|
||||
round(sum(CASE WHEN variance_to_theoretical_pct > 0 THEN 1 ELSE 0 END)::numeric / nullif(count(*), 0) * 100, 1) AS over_cost_rate
|
||||
FROM analytics.v_store_theoretical_actual_cost_april
|
||||
GROUP BY store_code
|
||||
) c ON r.store_code = c.store_code
|
||||
WHERE r.received IS NOT NULL
|
||||
ORDER BY
|
||||
CASE WHEN r.risk_level = '红色' AND COALESCE(c.over_cost_rate, 0) > 30 THEN 0
|
||||
WHEN r.risk_level = '红色' THEN 1
|
||||
WHEN r.risk_level = '黄色' AND COALESCE(c.over_cost_rate, 0) > 20 THEN 2
|
||||
ELSE 3 END,
|
||||
r.received DESC NULLS LAST
|
||||
`)
|
||||
|
||||
// 会员-平台-营收三角(用mv_store_risk_rating替代v_store_scorecard)
|
||||
const channelRisk = await query(`
|
||||
SELECT
|
||||
r.store_code, r.store_name, r.received,
|
||||
COALESCE(p.platform_share, 0) AS platform_share,
|
||||
COALESCE(r.member_bill_share_pct, 0) AS member_share_pct,
|
||||
COALESCE(m.repeat_rate_pct, 0) AS repeat_rate_pct,
|
||||
CASE
|
||||
WHEN COALESCE(p.platform_share, 0) > 40 AND COALESCE(r.member_bill_share_pct, 0) < 20 THEN '渠道依赖预警'
|
||||
WHEN COALESCE(p.platform_share, 0) > 30 AND COALESCE(r.member_bill_share_pct, 0) < 30 THEN '渠道风险'
|
||||
WHEN COALESCE(r.member_bill_share_pct, 0) > 50 THEN '会员驱动型'
|
||||
ELSE '均衡型'
|
||||
END AS channel_status
|
||||
FROM analytics.mv_store_risk_rating r
|
||||
LEFT JOIN (
|
||||
SELECT pe.store_code,
|
||||
round((COALESCE(pe.meituan_received, 0) + COALESCE(pe.taobao_received, 0) + COALESCE(pe.jd_received, 0)) / nullif(r2.received, 0) * 100, 1) AS platform_share
|
||||
FROM analytics.v_store_platform_economics pe
|
||||
JOIN analytics.mv_store_risk_rating r2 ON pe.store_code = r2.store_code
|
||||
) p ON r.store_code = p.store_code
|
||||
LEFT JOIN analytics.v_store_repeat_summary_monthly m ON r.store_code = m.store_code
|
||||
WHERE r.received IS NOT NULL
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE(p.platform_share, 0) > 40 AND COALESCE(r.member_bill_share_pct, 0) < 20 THEN 0 ELSE 1 END,
|
||||
r.received DESC
|
||||
`)
|
||||
|
||||
// 考勤-营收关联
|
||||
const hrRevenue = await query(`
|
||||
WITH hr AS (
|
||||
SELECT s.org_level5 AS store_name,
|
||||
count(*) AS emp_count,
|
||||
round(avg(s.actual_attend)::numeric, 1) AS avg_attend,
|
||||
round(sum(s.absent_days)::numeric, 0) AS total_absent,
|
||||
round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attend_rate
|
||||
FROM salary_detail_records s
|
||||
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
|
||||
GROUP BY s.org_level5
|
||||
),
|
||||
rev AS (
|
||||
SELECT store_name, received, bill_count, avg_bill_value
|
||||
FROM analytics.v_store_scorecard
|
||||
WHERE received IS NOT NULL
|
||||
),
|
||||
mapping AS (
|
||||
SELECT salary_name, bill_name FROM public.store_name_mapping
|
||||
)
|
||||
SELECT
|
||||
COALESCE(r.store_name, h.store_name) AS store_name,
|
||||
COALESCE(r.received, 0) AS received,
|
||||
COALESCE(r.bill_count, 0) AS bill_count,
|
||||
COALESCE(h.emp_count, 0) AS emp_count,
|
||||
COALESCE(h.avg_attend, 0) AS avg_attend,
|
||||
COALESCE(h.total_absent, 0) AS total_absent,
|
||||
COALESCE(h.low_attend_rate, 0) AS low_attend_rate,
|
||||
round(COALESCE(r.received, 0) / nullif(COALESCE(h.emp_count, 0), 0), 0) AS revenue_per_emp,
|
||||
CASE
|
||||
WHEN COALESCE(h.low_attend_rate, 0) > 30 AND COALESCE(r.received, 0) > 0 THEN '出勤低+有营收'
|
||||
WHEN COALESCE(h.low_attend_rate, 0) > 30 THEN '出勤低+营收低'
|
||||
WHEN COALESCE(h.total_absent, 0) > 10 THEN '旷工严重'
|
||||
ELSE '正常'
|
||||
END AS hr_status
|
||||
FROM hr h
|
||||
LEFT JOIN mapping mp ON mp.salary_name = h.store_name
|
||||
LEFT JOIN rev r ON r.store_name = mp.bill_name
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE(h.low_attend_rate, 0) > 30 THEN 0 ELSE 1 END,
|
||||
COALESCE(h.total_absent, 0) DESC
|
||||
`)
|
||||
|
||||
sendSuccess(res, {
|
||||
staffing_efficiency: staffingRows,
|
||||
cost_risk: costRisk.rows,
|
||||
channel_risk: channelRisk.rows,
|
||||
hr_revenue: hrRevenue.rows,
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ P2: 趋势预测 ============
|
||||
|
||||
router.get('/forecast', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
// 客流预测:基于历史4周小时数据,按工作日/周末+小时维度计算P85
|
||||
const trafficForecast = await query(`
|
||||
WITH daily_hourly AS (
|
||||
SELECT extract(hour FROM c175::timestamp)::int AS hour,
|
||||
CASE WHEN extract(dow FROM c175::timestamp)::int IN (0, 6) THEN '周末' ELSE '工作日' END AS day_type,
|
||||
DATE(c175::timestamp) AS bill_date,
|
||||
count(*) AS bills
|
||||
FROM bill_records
|
||||
WHERE c175 IS NOT NULL AND c175 != ''
|
||||
AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
|
||||
GROUP BY hour, day_type, bill_date
|
||||
),
|
||||
hourly_stats AS (
|
||||
SELECT hour, day_type,
|
||||
round(avg(bills)::numeric, 0)::int AS avg_bills,
|
||||
round(percentile_cont(0.85) WITHIN GROUP (ORDER BY bills)::numeric, 0)::int AS p85_bills,
|
||||
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY bills)::numeric, 0)::int AS p50_bills,
|
||||
round((max(bills) - min(bills))::numeric / nullif(avg(bills), 0), 2)::float AS volatility
|
||||
FROM daily_hourly
|
||||
GROUP BY hour, day_type
|
||||
)
|
||||
SELECT hour, day_type, avg_bills, p50_bills, p85_bills, volatility,
|
||||
CASE
|
||||
WHEN volatility > 1.5 THEN '波动大'
|
||||
WHEN volatility > 0.8 THEN '中等波动'
|
||||
ELSE '稳定'
|
||||
END AS stability
|
||||
FROM hourly_stats
|
||||
ORDER BY day_type, hour
|
||||
`)
|
||||
|
||||
// 成本趋势:菜品成本差异恶化TOP
|
||||
const costTrend = await query(`
|
||||
SELECT dish_name, dish_code, category_level1,
|
||||
round(sales_amount::numeric, 2) AS sales_amount,
|
||||
round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
|
||||
round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
|
||||
round(cost_variance_amount::numeric, 2) AS variance,
|
||||
CASE
|
||||
WHEN actual_margin_rate_pct < 0 THEN '数据异常'
|
||||
WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.3 THEN '恶化'
|
||||
WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.1 THEN '关注'
|
||||
ELSE '正常'
|
||||
END AS trend_status
|
||||
FROM public.dish_cost_analysis_summary
|
||||
WHERE actual_margin_rate_pct IS NOT NULL
|
||||
ORDER BY variance DESC
|
||||
LIMIT 20
|
||||
`)
|
||||
|
||||
// 人员流失预警
|
||||
const turnoverAlert = await query(`
|
||||
SELECT org_level5 AS store_name,
|
||||
count(*) AS total_emp,
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 1) AS turnover_rate
|
||||
FROM salary_detail_records
|
||||
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
|
||||
GROUP BY org_level5
|
||||
HAVING count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') > 0
|
||||
ORDER BY turnover_rate DESC
|
||||
`)
|
||||
|
||||
sendSuccess(res, {
|
||||
traffic_forecast: trafficForecast.rows,
|
||||
cost_trend: costTrend.rows,
|
||||
turnover_alert: turnoverAlert.rows,
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user