初始化:连锁餐饮数字化运营管理平台

This commit is contained in:
freedakgmail
2026-07-26 22:48:08 +08:00
commit a7874d79b5
67 changed files with 14391 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.env
*.log
.DS_Store
*.png
马兰拉面数据分析/
*.xlsx
*.zip
.playwright-mcp/
+126
View File
@@ -0,0 +1,126 @@
# 马兰拉面数字化运营管理平台
连锁餐饮数字化运营管理系统 — 任务闭环驱动门店改善
## 闭环目标
数据发现问题 → 自动分级 → 生成门店任务 → 现场执行 → 周度检查 → 月度验收 → 有效经验标准化
## 技术栈
- **后端**: Node.js + Express + TypeScript + PostgreSQL (pg)
- **前端**: React 18 + TypeScript + Vite + TailwindCSS + Recharts + React Query
- **认证**: JWT (角色: hq / regional / store / dept)
- **数据库**: PostgreSQL 15, `bill_query` @ localhost:5432
## 项目结构
```
SBrainCO/
├── package.json # monorepo 根配置
├── server/ # 后端
│ ├── src/
│ │ ├── config/database.ts
│ │ ├── middleware/auth.ts, error.ts
│ │ ├── routes/data.ts, tasks.ts, auth.ts
│ │ ├── types/index.ts
│ │ └── index.ts
│ ├── sql/
│ │ ├── 01_create_tables.sql
│ │ ├── 02_create_functions.sql
│ │ ├── 03_create_views.sql
│ │ └── 04_init_data.sql
│ └── .env
├── client/ # 前端
│ ├── src/
│ │ ├── components/ # Layout, MetricCard, Badge, DataTable
│ │ ├── pages/ # Dashboard, Tasks, StoreDetail, MonthlyReview, Indicators, Regional, Store, Login
│ │ ├── lib/ # api.ts, utils.ts
│ │ ├── App.tsx
│ │ └── main.tsx
│ └── vite.config.ts
└── shared/types.ts
```
## 快速启动
### 1. 安装依赖
```bash
# 根目录安装
npm run install:all
```
### 2. 初始化数据库
```bash
# 按顺序执行SQL脚本
psql -d bill_query -f server/sql/01_create_tables.sql
psql -d bill_query -f server/sql/02_create_functions.sql
psql -d bill_query -f server/sql/03_create_views.sql
psql -d bill_query -f server/sql/04_init_data.sql
```
### 3. 启动开发服务器
```bash
# 根目录一键启动
npm run dev
# 或分别启动
cd server && npm run dev # 后端 http://localhost:3001
cd client && npm run dev # 前端 http://localhost:5173
```
### 4. 访问系统
- 前端: http://localhost:5173
- 后端API: http://localhost:3001/api
- 默认账号:
- 总部管理员 / 123 (hq)
- 区域经理 / 123 (regional)
- 潘家园店长 / 123 (store)
- 商品部 / 123 (dept)
## API 接口
### 数据查询 (25个)
- `GET /api/overview` — 公司概览
- `GET /api/overview/daily` — 日度趋势
- `GET /api/stores` — 门店列表
- `GET /api/stores/:code` — 门店详情
- `GET /api/stores/risk` — 风险分级
- `GET /api/stores/priority` — 整改优先级
- `GET /api/stores/quadrant` — 经营象限
- `GET /api/cost/*` — 成本分析
- `GET /api/platform/economics` — 平台经济性
- `GET /api/member/*` — 会员分析
- `GET /api/sku/*` — SKU分析
- `GET /api/risk/*` — 风险分析
- `GET /api/time/*` — 时间规律
- `GET /api/channel` — 渠道结构
### 任务闭环 (23个)
- `GET /api/tasks` — 任务列表
- `GET /api/tasks/:id` — 任务详情
- `POST /api/tasks` — 创建任务
- `PUT /api/tasks/:id` — 更新任务
- `PUT /api/tasks/:id/execute` — 执行反馈
- `PUT /api/tasks/:id/weekly-check` — 周度检查
- `PUT /api/tasks/:id/verify` — 月度验收
- `POST /api/tasks/:id/rollback` — 任务回滚
- `POST /api/tasks/auto-generate` — 自动生成任务
- `GET /api/tasks/loop-health` — 闭环健康度
- `GET|POST /api/tasks/practices` — 经验标准化
- `GET|POST /api/tasks/indicators` — 指标字典
- `GET /api/tasks/stores/:code/daily-card` — 店长日卡
## 闭环健康度
| 指标 | 当前值 | 目标 |
|---|---|---|
| 任务生成率 | 100% | ≥95% |
| 店长执行率 | 0% | ≥80% |
| 区域周检率 | 0% | ≥80% |
| 月度验收率 | 0% | ≥80% |
| 经验推广率 | 0% | ≥80% |
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>马兰拉面数字化运营管理平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3487
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "sbrain-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.51.1",
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",
"recharts": "^2.12.7",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+88
View File
@@ -0,0 +1,88 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState, useEffect, useCallback } from 'react'
import { Layout } from '@/components/Layout'
import { DashboardPage } from '@/pages/DashboardPage'
import { TasksPage } from '@/pages/TasksPage'
import { TaskDetailPage } from '@/pages/TaskDetailPage'
import { StoreDetailPage } from '@/pages/StoreDetailPage'
import { MonthlyReviewPage } from '@/pages/MonthlyReviewPage'
import { IndicatorsPage } from '@/pages/IndicatorsPage'
import { RegionalPage } from '@/pages/RegionalPage'
import { StorePage } from '@/pages/StorePage'
import { SKUPage } from '@/pages/SKUPage'
import { CostPage } from '@/pages/CostPage'
import { PlatformPage } from '@/pages/PlatformPage'
import { MemberPage } from '@/pages/MemberPage'
import { RiskPage } from '@/pages/RiskPage'
import { TimePage } from '@/pages/TimePage'
import { DataQualityPage } from '@/pages/DataQualityPage'
import { OntologyPage } from '@/pages/OntologyPage'
import { LoginPage } from '@/pages/LoginPage'
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
})
export default function App() {
const [user, setUser] = useState<any>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
const stored = localStorage.getItem('user')
if (stored) setUser(JSON.parse(stored))
setReady(true)
}, [])
const handleLogout = useCallback(() => {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
}, [])
const handleLogin = useCallback((u: any) => {
setUser(u)
}, [])
if (!ready) return null
const token = localStorage.getItem('token')
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{!token ? (
<Routes>
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
) : (
<Layout user={user} onLogout={handleLogout}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/regional" element={<RegionalPage />} />
<Route path="/store" element={<StorePage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/tasks/:id" element={<TaskDetailPage />} />
<Route path="/stores/:code" element={<StoreDetailPage />} />
<Route path="/monthly-review" element={<MonthlyReviewPage />} />
<Route path="/sku" element={<SKUPage />} />
<Route path="/cost" element={<CostPage />} />
<Route path="/platform" element={<PlatformPage />} />
<Route path="/member" element={<MemberPage />} />
<Route path="/risk" element={<RiskPage />} />
<Route path="/time" element={<TimePage />} />
<Route path="/data-quality" element={<DataQualityPage />} />
<Route path="/indicators" element={<IndicatorsPage />} />
<Route path="/ontology" element={<OntologyPage />} />
<Route path="/login" element={<Navigate to="/" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
)}
</BrowserRouter>
</QueryClientProvider>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { cn, priorityColor, riskColor, statusColor, reviewResultColor } from '@/lib/utils'
interface BadgeProps {
text: string
type?: 'priority' | 'risk' | 'status' | 'review' | 'default'
className?: string
}
export function Badge({ text, type = 'default', className }: BadgeProps) {
const colorClass = type === 'priority'
? priorityColor(text)
: type === 'risk'
? riskColor(text)
: type === 'status'
? statusColor(text)
: type === 'review'
? reviewResultColor(text)
: 'bg-gray-100 text-gray-700 border border-gray-300'
return (
<span className={cn('inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium', colorClass, className)}>
{text}
</span>
)
}
@@ -0,0 +1,33 @@
import { useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface CollapsibleSectionProps {
title: string
subtitle?: string
defaultOpen?: boolean
headerRight?: ReactNode
children: ReactNode
}
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, children }: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-lg border bg-card">
<button
className="flex w-full items-center justify-between p-4 text-left"
onClick={() => setOpen(!open)}
>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold transition-transform', open ? 'rotate-90' : 'rotate-0')}></span>
<div>
<h2 className="text-sm font-bold">{title}</h2>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
{headerRight && <div onClick={(e) => e.stopPropagation()}>{headerRight}</div>}
</button>
{open && <div className="border-t px-4 pb-4 pt-3">{children}</div>}
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface DataTableProps {
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
data: any[]
onRowClick?: (row: any) => void
className?: string
}
export function DataTable({ columns, data, onRowClick, className }: DataTableProps) {
return (
<div className={cn('overflow-x-auto rounded-lg border', className)}>
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
{columns.map((col) => (
<th
key={col.key}
className={cn(
'px-3 py-2 font-medium text-muted-foreground whitespace-nowrap',
col.align === 'right' && 'text-right',
col.align === 'center' && 'text-center',
!col.align && 'text-left'
)}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
data.map((row, i) => (
<tr
key={i}
onClick={() => onRowClick?.(row)}
className={cn(
'border-t hover:bg-muted/30',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((col) => (
<td
key={col.key}
className={cn(
'px-3 py-2 whitespace-nowrap',
col.align === 'right' && 'text-right',
col.align === 'center' && 'text-center'
)}
>
{col.render ? col.render(row) : row[col.key] ?? '-'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
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 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LayoutProps {
children: ReactNode
user: { name: string; role: string } | null
onLogout: () => void
}
interface MenuItem {
path: string
label: string
icon: any
roles: string[]
}
interface MenuGroup {
title: string
items: MenuItem[]
}
const menuGroups: MenuGroup[] = [
{
title: '经营管理',
items: [
{ 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'] },
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional', 'store'] },
],
},
{
title: '业务模块',
items: [
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
],
},
{
title: '系统管理',
items: [
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/ontology', label: '本体标准', icon: Database, roles: ['hq', 'dept'] },
],
},
]
export function Layout({ children, user, onLogout }: LayoutProps) {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const visibleGroups = menuGroups.map(g => ({
...g,
items: g.items.filter((m) => !user || m.roles.includes(user.role)),
})).filter(g => g.items.length > 0)
return (
<div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="fixed left-4 top-4 z-50 rounded-md border p-2 lg:hidden"
>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Sidebar */}
<aside className={cn(
'fixed left-0 top-0 z-40 h-full w-56 border-r bg-card transition-transform lg:translate-x-0',
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>
</div>
<nav className="space-y-4 p-3">
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-3 text-xs font-medium text-muted-foreground">{group.title}</p>
<div className="space-y-1">
{group.items.map((item) => {
const Icon = item.icon
const active = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
)}
>
<Icon size={18} />
{item.label}
</Link>
)
})}
</div>
</div>
))}
</nav>
{user && (
<div className="absolute bottom-0 left-0 right-0 border-t p-3">
<div className="mb-2 px-3 text-sm">
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.role}</p>
</div>
<button
onClick={onLogout}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted"
>
<LogOut size={16} />
退
</button>
</div>
)}
</aside>
{/* Main content */}
<div className="lg:pl-56">
<main className="min-h-screen p-4 pt-16 lg:pt-4">
{children}
</main>
</div>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Loader2 } from 'lucide-react'
export function LoadingSpinner({ text = '加载中...' }: { text?: string }) {
return (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-3 text-sm text-muted-foreground">{text}</p>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { cn, formatNumber } from '@/lib/utils'
interface MetricCardProps {
title: string
value: string | number | null | undefined
unit?: string
format?: 'number' | 'percent' | 'currency'
trend?: number
description?: string
className?: string
}
export function MetricCard({ title, value, unit, format = 'number', trend, description, className }: MetricCardProps) {
const displayValue = format === 'percent'
? value === null || value === undefined ? '-' : `${Number(value).toFixed(2)}%`
: format === 'currency'
? value === null || value === undefined ? '-' : `¥${formatNumber(value)}`
: formatNumber(value)
return (
<div className={cn('rounded-lg border bg-card p-4 shadow-sm', className)}>
<div className="flex items-center gap-1">
<p className="text-sm text-muted-foreground">{title}</p>
{description && (
<span className="group relative inline-flex">
<span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-muted text-[10px] text-muted-foreground">?</span>
<span className="pointer-events-none absolute left-1/2 top-6 z-10 w-48 -translate-x-1/2 rounded-md bg-gray-800 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
{description}
</span>
</span>
)}
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className="text-2xl font-bold">{displayValue}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div>
{trend !== undefined && (
<p className={cn('mt-1 text-xs', trend > 0 ? 'text-green-600' : 'text-red-600')}>
{trend > 0 ? '↑' : '↓'} {Math.abs(trend).toFixed(1)}% <span className="text-muted-foreground"></span>
</p>
)}
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
interface PaginationProps {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
}
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
const totalPages = Math.ceil(total / pageSize)
if (totalPages <= 1) return null
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{start}-{end} {total}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(1)}
disabled={page === 1}
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
>
<ChevronsLeft size={16} />
</button>
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
>
<ChevronLeft size={16} />
</button>
{getPageNumbers(page, totalPages).map((p, idx) =>
p === '...' ? (
<span key={`ellipsis-${idx}`} className="px-2 text-xs text-muted-foreground">...</span>
) : (
<button
key={p}
onClick={() => onPageChange(p as number)}
className={`min-w-[32px] rounded-md border px-2 py-1 text-xs ${
page === p ? 'border-primary bg-primary text-primary-foreground' : 'hover:bg-muted'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
>
<ChevronRight size={16} />
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={page === totalPages}
className="rounded-md border p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent"
>
<ChevronsRight size={16} />
</button>
</div>
</div>
)
}
function getPageNumbers(current: number, total: number): (number | string)[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
if (current <= 4) return [1, 2, 3, 4, 5, '...', total]
if (current >= total - 3) return [1, '...', total - 4, total - 3, total - 2, total - 1, total]
return [1, '...', current - 1, current, current + 1, '...', total]
}
+37
View File
@@ -0,0 +1,37 @@
import { cn } from '@/lib/utils'
export function Skeleton({ className, style }: { className?: string; style?: React.CSSProperties }) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} style={style} />
}
export function CardSkeleton() {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-2 h-7 w-32" />
<Skeleton className="mt-1 h-3 w-16" />
</div>
)
}
export function ChartSkeleton({ height = 250 }: { height?: number }) {
return (
<div className="rounded-lg border bg-card p-4">
<Skeleton className="mb-3 h-4 w-32" />
<Skeleton className="w-full" style={{ height }} />
</div>
)
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="rounded-lg border bg-card p-4">
<Skeleton className="mb-3 h-4 w-48" />
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api
+56
View File
@@ -0,0 +1,56 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatNumber(n: number | string | null | undefined): string {
if (n === null || n === undefined || n === '') return '-'
const num = typeof n === 'string' ? parseFloat(n) : n
if (isNaN(num)) return '-'
return num.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
export function formatPercent(n: number | string | null | undefined): string {
if (n === null || n === undefined || n === '') return '-'
const num = typeof n === 'string' ? parseFloat(n) : n
if (isNaN(num)) return '-'
return `${num.toFixed(2)}%`
}
export function formatCurrency(n: number | string | null | undefined): string {
if (n === null || n === undefined || n === '') return '-'
const num = typeof n === 'string' ? parseFloat(n) : n
if (isNaN(num)) return '-'
return `¥${num.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`
}
export function priorityColor(priority: string): string {
if (priority?.startsWith('P0')) return 'bg-red-100 text-red-700 border-red-300'
if (priority?.startsWith('P1')) return 'bg-orange-100 text-orange-700 border-orange-300'
if (priority?.startsWith('P2')) return 'bg-yellow-100 text-yellow-700 border-yellow-300'
if (priority?.includes('标杆')) return 'bg-green-100 text-green-700 border-green-300'
return 'bg-gray-100 text-gray-700 border-gray-300'
}
export function riskColor(level: string): string {
if (level === '红色') return 'bg-red-500 text-white'
if (level === '黄色') return 'bg-yellow-500 text-white'
return 'bg-green-500 text-white'
}
export function statusColor(status: string): string {
if (status === '待启动') return 'bg-gray-100 text-gray-600'
if (status === '进行中') return 'bg-blue-100 text-blue-700'
if (status === '已验收') return 'bg-green-100 text-green-700'
if (status === '已回滚') return 'bg-red-100 text-red-700'
return 'bg-gray-100 text-gray-600'
}
export function reviewResultColor(result: string): string {
if (result === '达标') return 'bg-green-100 text-green-700 border border-green-300'
if (result === '改善中') return 'bg-yellow-100 text-yellow-700 border border-yellow-300'
if (result === '未改善') return 'bg-red-100 text-red-700 border border-red-300'
return 'bg-gray-100 text-gray-600 border border-gray-300'
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+200
View File
@@ -0,0 +1,200 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts'
const PAGE_SIZE = 15
export function CostPage() {
const [costPage, setCostPage] = useState(1)
const [invPage, setInvPage] = useState(1)
const [catPage, setCatPage] = useState(1)
const { data: costData, isLoading: costLoading } = useQuery({
queryKey: ['cost/comparison'],
queryFn: () => api.get('/cost/comparison'),
})
const { data: invData, isLoading: invLoading } = useQuery({
queryKey: ['cost/inventory'],
queryFn: () => api.get('/cost/inventory'),
})
const { data: catData, isLoading: catLoading } = useQuery({
queryKey: ['cost/category-benchmark'],
queryFn: () => api.get('/cost/category-benchmark'),
})
const costRows = (costData as any)?.data || []
const invRows = (invData as any)?.data || []
const catRows = (catData as any)?.data || []
const pageLoading = costLoading || invLoading || catLoading
const validCostRows = costRows.filter((r: any) => !r.variance_level?.includes('口径异常'))
const abnormalRows = costRows.filter((r: any) => r.variance_level?.includes('口径异常'))
const avgTheoreticalRate = validCostRows.length > 0
? validCostRows.reduce((s: number, r: any) => s + Number(r.theoretical_cost_rate_pct || 0), 0) / validCostRows.length
: 0
const avgActualRate = validCostRows.length > 0
? validCostRows.reduce((s: number, r: any) => s + Number(r.actual_food_cost_rate_pct || 0), 0) / validCostRows.length
: 0
const highVarianceCount = validCostRows.filter((r: any) => Number(r.variance_to_theoretical_pct || 0) > 20).length
const abnormalInvCount = invRows.filter((r: any) => Number(r.estimated_inventory_days) > 7 || Number(r.negative_item_lines) > 0).length
const pagedCost = useMemo(() => costRows.slice((costPage - 1) * PAGE_SIZE, costPage * PAGE_SIZE), [costRows, costPage])
const pagedInv = useMemo(() => invRows.slice((invPage - 1) * PAGE_SIZE, invPage * PAGE_SIZE), [invRows, invPage])
const pagedCat = useMemo(() => catRows.slice((catPage - 1) * PAGE_SIZE, catPage * PAGE_SIZE), [catRows, catPage])
const scatterData = validCostRows.map((r: any) => ({
name: r.store_name,
theoretical: Number(r.theoretical_cost_rate_pct || 0),
actual: Number(r.actual_food_cost_rate_pct || 0),
variance: Number(r.variance_to_theoretical_pct || 0),
}))
if (pageLoading) {
return <LoadingSpinner text="加载成本数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">vs实际成本 · · · 20264</p>
</div>
{/* 概览指标 */}
<CollapsibleSection title="成本概览" subtitle="全门店成本效率汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="平均理论成本率" value={avgTheoreticalRate} format="percent" description="按标准BOM计算的理论食材成本占实收比例(排除口径异常门店)" />
<MetricCard title="平均实际成本率" value={avgActualRate} format="percent" description="盘点倒挤的实际食材成本占实收比例(排除口径异常门店)" />
<MetricCard title="高偏差门店(>20%)" value={highVarianceCount} format="number" description="实际成本率超理论20%以上的门店数(排除口径异常)" />
<MetricCard title="口径异常门店" value={abnormalRows.length} format="number" description="理论成本数据缺失或口径异常,不纳入成本率统计" />
</div>
{abnormalRows.length > 0 && (
<div className="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-gray-700">{abnormalRows.length}</span>
{abnormalRows.map((r: any) => r.store_name).join('、')}
</p>
</div>
)}
</CollapsibleSection>
{/* 成本散点图 */}
<CollapsibleSection title="理论 vs 实际成本率" subtitle="每个点代表一家门店,偏离对角线越多成本偏差越大">
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 40]} />
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 50]} />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ active, payload }: any) => {
if (active && payload && payload.length) {
const d = payload[0].payload
return (
<div className="rounded border bg-card p-2 text-xs shadow-sm">
<p className="font-medium">{d.name}</p>
<p>: {d.theoretical.toFixed(1)}%</p>
<p>: {d.actual.toFixed(1)}%</p>
<p>: {d.variance > 0 ? '+' : ''}{d.variance.toFixed(1)}%</p>
</div>
)
}
return null
}}
/>
<Scatter data={scatterData} fill="#3b82f6" />
</ScatterChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 成本对比表 */}
<CollapsibleSection title={`门店成本对比 (${costRows.length})`} subtitle="理论成本 vs 实际倒挤成本,按偏差降序">
<Pagination page={costPage} pageSize={PAGE_SIZE} total={costRows.length} onPageChange={setCostPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_to_theoretical_pct || 0)
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
}},
{ key: 'variance_level', label: '偏差等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>{r.variance_level || '-'}</span>
)},
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
]}
data={pagedCost}
/>
</div>
</CollapsibleSection>
{/* 库存效率 */}
<CollapsibleSection title={`库存效率 (${invRows.length})`} subtitle="库存天数、负耗用、异常货品" defaultOpen={false}>
<Pagination page={invPage} pageSize={PAGE_SIZE} total={invRows.length} onPageChange={setInvPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'business_type', label: '业态' },
{ key: 'scale_tier', label: '规模' },
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
const d = Number(r.estimated_inventory_days || 0)
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}</span>
}},
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'abnormal_item_lines', label: '异常货品行', align: 'center', render: (r) => {
const n = Number(r.abnormal_item_lines || 0)
return n > 0 ? <span className="font-medium text-yellow-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
]}
data={pagedInv}
/>
</div>
</CollapsibleSection>
{/* 原料分类差异 */}
<CollapsibleSection title={`原料分类成本基准 (${catRows.length})`} subtitle="按财务分类对比同业基准" defaultOpen={false}>
<Pagination page={catPage} pageSize={PAGE_SIZE} total={catRows.length} onPageChange={setCatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'finance_category', label: '财务分类' },
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
const v = Number(r.excess_vs_peer_per_10k || 0)
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
}},
]}
data={pagedCat}
/>
</div>
</CollapsibleSection>
</div>
)
}
+246
View File
@@ -0,0 +1,246 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
const PAGE_SIZE = 10
export function DashboardPage() {
const navigate = useNavigate()
const [priorityPage, setPriorityPage] = useState(1)
const { data: overview, isLoading: odLoading } = useQuery({
queryKey: ['overview'],
queryFn: () => api.get('/overview'),
})
const { data: daily, isLoading: dailyLoading } = useQuery({
queryKey: ['overview/daily'],
queryFn: () => api.get('/overview/daily'),
})
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk'],
queryFn: () => api.get('/stores/risk'),
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority'],
queryFn: () => api.get('/stores/priority'),
})
const { data: loopHealth, isLoading: loopLoading } = useQuery({
queryKey: ['loop-health'],
queryFn: () => api.get('/tasks/loop-health'),
})
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
const od = (overview as any)?.data
const riskRows = (riskData as any)?.data || []
const priorityRows = (priorityData as any)?.data || []
const dailyRows = (daily as any)?.data || []
const lh = (loopHealth as any)?.data
const riskSummary = riskRows.reduce((acc: any, r: any) => {
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
return acc
}, {})
const totalStores = riskRows.length
const riskPieData = Object.entries(riskSummary).map(([name, value]) => ({
name,
value: value as number,
pct: totalStores > 0 ? Math.round((value as number / totalStores) * 100) : 0,
}))
const prioritySummary = priorityRows.reduce((acc: any, r: any) => {
const key = r.action_priority
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage])
// 闭环健康度综合得分
const loopScore = lh ? (
(parseFloat(lh.task_generation_rate) +
parseFloat(lh.store_execution_rate) +
parseFloat(lh.weekly_check_rate) +
parseFloat(lh.monthly_review_rate) +
parseFloat(lh.practice_promotion_rate)) / 5
) : 0
// 计算环比趋势:最后7天 vs 前7天
const last7 = dailyRows.slice(-7)
const prev7 = dailyRows.slice(-14, -7)
const sumKey = (arr: any[], key: string) => arr.reduce((s, r) => s + Number(r[key] || 0), 0)
const trend = (curr: number, prev: number) => prev > 0 ? Math.round((curr - prev) / prev * 1000) / 10 : 0
const trendReceived = trend(sumKey(last7, 'received'), sumKey(prev7, 'received'))
const trendBills = trend(sumKey(last7, 'bill_count'), sumKey(prev7, 'bill_count'))
const trendAvgBill = trend(
last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0,
prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0,
)
if (pageLoading) {
return <LoadingSpinner text="加载驾驶舱数据..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · 20264</p>
</div>
<div className="flex gap-2">
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} </span>
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700"> {riskSummary['红色'] || 0}</span>
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700"> {riskSummary['黄色'] || 0}</span>
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿 {riskSummary['绿色'] || 0}</span>
</div>
</div>
{/* 经营指标 */}
<CollapsibleSection title="经营指标" subtitle="实收、账单数、客单价等核心经营数据">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="实收" value={od?.received} format="currency" trend={trendReceived} description="门店实际收到的金额,扣除优惠后的净收入" />
<MetricCard title="账单数" value={od?.bill_count} format="number" trend={trendBills} description="全部门店累计的账单(订单)总数" />
<MetricCard title="客单价" value={od?.avg_bill_value} format="currency" trend={trendAvgBill} description="平均每笔账单的实收金额 = 实收 ÷ 账单数" />
<MetricCard title="P0+P1门店" value={p0p1Stores.length} format="number" description="需要重点整改的门店数:P0为紧急修复,P1为重点整改" />
</div>
</CollapsibleSection>
{/* 运营指标 */}
<CollapsibleSection title="运营指标" subtitle="优惠率、毛利率、会员占比等运营比率">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="优惠率" value={od?.discount_rate_pct} format="percent" description="优惠总额占消费总额的比例,越高让利越大" />
<MetricCard title="理论毛利率" value={od?.theoretical_margin_pct} format="percent" description="理论利润占实收的比例(按标准成本计算),反映定价与成本的关系" />
<MetricCard title="会员占比" value={od?.member_share_pct} format="percent" description="会员消费金额占总实收的比例,反映会员运营效果" />
</div>
</CollapsibleSection>
{/* 闭环健康度 */}
{lh && (
<CollapsibleSection
title="闭环健康度"
subtitle="任务生成→店长执行→区域检查→月度验收→经验推广"
headerRight={
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"></span>
<span className={`text-lg font-bold ${loopScore >= 80 ? 'text-green-600' : loopScore >= 60 ? 'text-yellow-600' : 'text-red-600'}`}>
{loopScore.toFixed(1)}%
</span>
</div>
}
>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
{[
{ label: '任务生成率', value: lh.task_generation_rate, desc: '系统自动生成的整改任务占应生成任务的比例' },
{ label: '店长执行率', value: lh.store_execution_rate, desc: '店长已提交反馈的任务占分配任务的比例' },
{ label: '区域周检率', value: lh.weekly_check_rate, desc: '区域经理完成周度检查的比例' },
{ label: '月度验收率', value: lh.monthly_review_rate, desc: '月度完成验收的任务比例' },
{ label: '经验推广率', value: lh.practice_promotion_rate, desc: '达标经验被推广到其他门店的比例' },
].map((item) => {
const num = parseFloat(item.value)
const isLow = num < 60
const isMid = num >= 60 && num < 80
return (
<div key={item.label} className={`rounded-md border p-3 ${isLow ? 'border-red-200 bg-red-50/50' : isMid ? 'border-yellow-200 bg-yellow-50/50' : 'border-green-200 bg-green-50/50'}`} title={item.desc}>
<div className="flex items-center gap-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<span className="flex h-3.5 w-3.5 cursor-help items-center justify-center rounded-full bg-muted text-[9px] text-muted-foreground" title={item.desc}>?</span>
</div>
<p className={`mt-1 text-xl font-bold ${isLow ? 'text-red-600' : isMid ? 'text-yellow-600' : 'text-green-600'}`}>{num.toFixed(1)}%</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${isLow ? 'bg-red-500' : isMid ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${Math.min(num, 100)}%` }}
/>
</div>
</div>
)
})}
</div>
</CollapsibleSection>
)}
{/* 图表区 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="日度实收趋势" subtitle="近30天实收变化">
<ResponsiveContainer width="100%" height={250}>
<LineChart data={dailyRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip
labelFormatter={(v) => v?.substring(0, 10)}
formatter={(v: any) => formatCurrency(v)}
/>
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="门店风险分级分布" subtitle="按红/黄/绿分级统计">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={riskPieData}
cx="50%" cy="50%" outerRadius={80}
dataKey="value"
label={({ name, value, pct }: any) => `${name} ${value}家 (${pct}%)`}
>
{riskPieData.map((entry) => (
<Cell key={entry.name} fill={RISK_COLORS[entry.name as keyof typeof RISK_COLORS] || '#gray'} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* P0/P1 门店列表 */}
<CollapsibleSection
title={`P0/P1 重点整改门店 (${p0p1Stores.length})`}
subtitle="需紧急修复和重点整改的门店清单"
headerRight={
<div className="flex gap-2">
{Object.entries(prioritySummary).filter(([k]) => k?.startsWith('P0') || k?.startsWith('P1')).map(([k, v]) => (
<span key={k} className="text-xs text-muted-foreground">{k}: {v as number}</span>
))}
</div>
}
>
<Pagination page={priorityPage} pageSize={PAGE_SIZE} total={p0p1Stores.length} onPageChange={setPriorityPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'action_priority', label: '优先级', render: (r) => <Badge type="priority" text={r.action_priority} /> },
{ key: 'problem_count', label: '问题数', align: 'center' },
{ key: 'problem_combination', label: '问题组合' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
]}
data={pagedP0P1}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</CollapsibleSection>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatNumber } from '@/lib/utils'
export function DataQualityPage() {
const { data: qualityData, isLoading } = useQuery({
queryKey: ['data-quality'],
queryFn: () => api.get('/data-quality'),
})
const dq = (qualityData as any)?.data || {}
if (isLoading) {
return <LoadingSpinner text="加载数据质量检查..." />
}
const totalBills = Number(dq.total_bills || 0)
const missingBillNo = Number(dq.missing_bill_no || 0)
const missingStoreCode = Number(dq.missing_store_code || 0)
const zeroConsumption = Number(dq.zero_consumption || 0)
const negativeReceived = Number(dq.negative_received || 0)
const storeCount = Number(dq.store_count || 0)
const totalDishRecords = Number(dq.total_dish_records || 0)
const dishMissingStore = Number(dq.dish_missing_store || 0)
const dishMissingDish = Number(dq.dish_missing_dish || 0)
const billQualityStatus = missingBillNo === 0 && missingStoreCode === 0 ? '正常' : '异常'
const dishQualityStatus = dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · {dq.min_date?.substring(0, 10)} ~ {dq.max_date?.substring(0, 10)}</p>
</div>
{/* 账单数据质量 */}
<CollapsibleSection title="账单数据质量" subtitle="原始账单表完整性检查">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总账单数" value={totalBills} format="number" description="账单事实表总记录数" />
<MetricCard title="覆盖门店数" value={storeCount} format="number" description="有交易记录的门店数量" />
<MetricCard title="缺失账单号" value={missingBillNo} format="number" description="账单号为空的记录数,应为0" />
<MetricCard title="缺失门店编码" value={missingStoreCode} format="number" description="门店编码为空的记录数,应为0" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="零消费账单" value={zeroConsumption} format="number" description="消费额为0的账单数" />
<MetricCard title="负实收账单" value={negativeReceived} format="number" description="实收为负数的账单数" />
<div className="rounded-lg border bg-card p-4 shadow-sm">
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${billQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
{billQualityStatus}
</p>
</div>
</div>
{missingBillNo > 0 && (
<div className="mt-3 rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
<p className="text-xs text-yellow-700">
<span className="font-medium"></span>
{missingBillNo}
</p>
</div>
)}
{missingStoreCode > 0 && (
<div className="mt-3 rounded-md border border-red-200 bg-red-50/50 p-3">
<p className="text-xs text-red-700">
<span className="font-medium"></span>
{missingStoreCode}
</p>
</div>
)}
</CollapsibleSection>
{/* 菜品明细数据质量 */}
<CollapsibleSection title="菜品明细数据质量" subtitle="菜品销售明细表完整性检查">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总菜品记录数" value={totalDishRecords} format="number" description="菜品销售明细表总记录数" />
<MetricCard title="缺失门店" value={dishMissingStore} format="number" description="门店编码为空的记录数" />
<MetricCard title="缺失菜品名" value={dishMissingDish} format="number" description="菜品名称为空的记录数" />
<div className="rounded-lg border bg-card p-4 shadow-sm">
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${dishQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
{dishQualityStatus}
</p>
</div>
</div>
{dishMissingStore > 0 && (
<div className="mt-3 rounded-md border border-red-200 bg-red-50/50 p-3">
<p className="text-xs text-red-700">
<span className="font-medium"></span>
{dishMissingStore} SKU分析
</p>
</div>
)}
</CollapsibleSection>
{/* 数据治理建议 */}
<CollapsibleSection title="数据治理建议" subtitle="基于管理体系的数据质量要求" defaultOpen={false}>
<div className="space-y-3 text-sm">
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
<p className="font-medium text-blue-700"></p>
<p className="mt-1 text-xs text-muted-foreground">SKU使</p>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
<p className="font-medium text-blue-700"></p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
<p className="font-medium text-blue-700"></p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
</div>
</CollapsibleSection>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 10
export function IndicatorsPage() {
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['indicators'],
queryFn: () => api.get('/tasks/indicators'),
})
const indicators = (data as any)?.data || []
const pagedIndicators = useMemo(() => indicators.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [indicators, page])
return (
<div className="space-y-4">
<h1 className="text-xl font-bold"></h1>
<Pagination page={page} pageSize={PAGE_SIZE} total={indicators.length} onPageChange={setPage} />
<DataTable
columns={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'business_definition', label: '业务定义', render: (r) => <span className="text-xs">{r.business_definition}</span> },
{ key: 'formula', label: '计算公式', render: (r) => <code className="text-xs">{r.formula}</code> },
{ key: 'update_frequency', label: '更新频率', align: 'center' },
{ key: 'owner', label: '负责人' },
{ key: 'yellow_threshold', label: '黄线', align: 'right' },
{ key: 'red_threshold', label: '红线', align: 'right' },
{ key: 'version_date', label: '版本日期', render: (r) => r.version_date?.substring(0, 10) },
]}
data={pagedIndicators}
/>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
interface LoginPageProps {
onLogin?: (user: any) => void
}
export function LoginPage({ onLogin }: LoginPageProps) {
const navigate = useNavigate()
const [username, setUsername] = useState('总部管理员')
const [password, setPassword] = useState('123')
const [error, setError] = useState('')
const handleLogin = async () => {
try {
const res: any = await api.post('/auth/login', { username, password })
localStorage.setItem('token', res.data.token)
localStorage.setItem('user', JSON.stringify(res.data.user))
onLogin?.(res.data.user)
navigate('/')
} catch {
setError('登录失败,请重试')
}
}
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>
<div className="space-y-3">
<div>
<label className="text-sm text-muted-foreground"></label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 w-full rounded-md border px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="mt-1 w-full rounded-md border px-3 py-2 text-sm"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
onClick={handleLogin}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground"
>
</button>
<p className="text-center text-xs text-muted-foreground">
/ / /
</p>
</div>
</div>
</div>
)
}
+153
View File
@@ -0,0 +1,153 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PAGE_SIZE = 15
export function MemberPage() {
const [repeatPage, setRepeatPage] = useState(1)
const { data: comparisonData, isLoading: cmpLoading } = useQuery({
queryKey: ['member/comparison'],
queryFn: () => api.get('/member/comparison'),
})
const { data: repeatData, isLoading: repeatLoading } = useQuery({
queryKey: ['member/repeat'],
queryFn: () => api.get('/member/repeat'),
})
const cmpRows = (comparisonData as any)?.data || []
const repeatRows = (repeatData as any)?.data || []
const pageLoading = cmpLoading || repeatLoading
const memberRow = cmpRows.find((r: any) => r.customer_type === '会员') || {}
const nonMemberRow = cmpRows.find((r: any) => r.customer_type === '非会员') || {}
const totalBills = Number(memberRow.bill_count || 0) + Number(nonMemberRow.bill_count || 0)
const memberBillShare = totalBills > 0 ? (Number(memberRow.bill_count || 0) / totalBills * 100) : 0
const totalReceived = Number(memberRow.received || 0) + Number(nonMemberRow.received || 0)
const memberRevenueShare = totalReceived > 0 ? (Number(memberRow.received || 0) / totalReceived * 100) : 0
const avgRepeatRate = repeatRows.length > 0
? repeatRows.reduce((s: number, r: any) => s + Number(r.repeat_rate_pct || 0), 0) / repeatRows.length
: 0
const topRepeatStore = repeatRows.length > 0
? repeatRows.reduce((best: any, r: any) => Number(r.repeat_rate_pct || 0) > Number(best?.repeat_rate_pct || 0) ? r : best, null)
: null
const lowRepeatCount = repeatRows.filter((r: any) => Number(r.repeat_rate_pct || 0) < 30).length
const cmpChartData = cmpRows.map((r: any) => ({
name: r.customer_type,
账单数: Number(r.bill_count || 0),
实收: Number(r.received || 0),
客单价: Number(r.avg_bill_value || 0),
}))
const pieData = [
{ name: '会员账单', value: Number(memberRow.bill_count || 0) },
{ name: '非会员账单', value: Number(nonMemberRow.bill_count || 0) },
]
const PIE_COLORS = ['#3b82f6', '#e5e7eb']
const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage])
if (pageLoading) {
return <LoadingSpinner text="加载会员数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
</div>
{/* 概览指标 */}
<CollapsibleSection title="会员概览" subtitle="会员渗透与消费对比">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="会员账单占比" value={memberBillShare} format="percent" description="会员账单数占总账单数的比例" />
<MetricCard title="会员收入占比" value={memberRevenueShare} format="percent" description="会员实收金额占总实收的比例" />
<MetricCard title="平均复购率" value={avgRepeatRate} format="percent" description="所有门店30日复购率的平均值" />
<MetricCard title="低复购门店(<30%)" value={lowRepeatCount} format="number" description="复购率低于30%的门店数,需重点改善" />
</div>
</CollapsibleSection>
{/* 会员对比图 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="会员 vs 非会员消费对比" subtitle="账单数、实收、客单价对比">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={cmpChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
<Legend />
<Bar yAxisId="left" dataKey="账单数" fill="#3b82f6" />
<Bar yAxisId="left" dataKey="实收" fill="#22c55e" />
<Bar yAxisId="right" dataKey="客单价" fill="#eab308" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="会员账单占比" subtitle="会员与非会员账单分布">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
label={({ name, value, }: any) => `${name}: ${formatNumber(value)}`}>
{pieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i]} />)}
</Pie>
<Tooltip formatter={(v: any) => formatNumber(v)} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 会员对比明细 */}
<CollapsibleSection title="会员与非会员指标对比" subtitle="优惠率、毛利率等关键指标">
<DataTable
columns={[
{ key: 'customer_type', label: '客户类型' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ 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: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
data={cmpRows}
/>
</CollapsibleSection>
{/* 门店复购率 */}
<CollapsibleSection title={`门店复购率 (${repeatRows.length})`} subtitle="按复购率降序,低于30%需重点改善">
<Pagination page={repeatPage} pageSize={PAGE_SIZE} total={repeatRows.length} onPageChange={setRepeatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => {
const v = Number(r.repeat_rate_pct || 0)
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
]}
data={pagedRepeat}
/>
</div>
</CollapsibleSection>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { DataTable } from '@/components/DataTable'
import { Badge } from '@/components/Badge'
import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
const PAGE_SIZE = 10
export function MonthlyReviewPage() {
const [month, setMonth] = useState('2026-05')
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['monthly-review', month],
queryFn: () => api.get('/tasks/monthly-review', { params: { month } }),
})
const rd = (data as any)?.data
const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 }
const details = rd?.details || []
const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page])
const pieData = [
{ name: '达标', value: summary.passed },
{ name: '改善中', value: summary.improving },
{ name: '未改善', value: summary.failed },
].filter((d) => d.value > 0)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{/* 汇总卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总任务数" value={summary.total} />
<MetricCard title="达标" value={summary.passed} />
<MetricCard title="改善中" value={summary.improving} />
<MetricCard title="未改善" value={summary.failed} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* 饼图 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
label={({ name, value }: any) => `${name}: ${value}`}>
{pieData.map((d) => <Cell key={d.name} fill={REVIEW_COLORS[d.name as keyof typeof REVIEW_COLORS]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : <div className="py-20 text-center text-muted-foreground"></div>}
</div>
{/* 经验标准化 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground mb-3">广</p>
<PracticeSection />
</div>
</div>
{/* 明细表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={page} pageSize={PAGE_SIZE} total={details.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'baseline_value', label: '基线', align: 'right' },
{ key: 'target_value', label: '目标', align: 'right' },
{ key: 'actual_value', label: '实际', align: 'right' },
{ key: 'review_result', label: '结果', render: (r) => <Badge type="review" text={r.review_result} /> },
]}
data={pagedDetails}
/>
</div>
</div>
</div>
)
}
function PracticeSection() {
const { data } = useQuery({
queryKey: ['practices'],
queryFn: () => api.get('/tasks/practices'),
})
const practices = (data as any)?.data || []
return (
<div className="space-y-2">
{practices.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : practices.map((p: any) => (
<div key={p.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{p.practice_module}</span>
<Badge text={p.status} />
</div>
<p className="mt-1 text-xs text-muted-foreground">{p.benchmark_store_name}</p>
<p className="mt-1 text-xs">{p.key_actions}</p>
</div>
))}
</div>
)
}
+393
View File
@@ -0,0 +1,393 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { DataTable } from '@/components/DataTable'
import { useState } from 'react'
import { Database, Table, Layers, Settings, ChevronRight, Box, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
type TabType = 'overview' | 'dimensions' | 'facts' | 'enums' | 'metrics'
const TABLE_LABELS: Record<string, string> = {
dim_store: '门店主数据',
dim_sku: 'SKU主数据',
dim_material: '原料主数据',
dim_member: '会员主数据',
dim_employee: '员工主数据',
dim_channel: '渠道主数据',
dim_promotion: '活动主数据',
dim_calendar: '日历主数据',
dim_supplier: '供应商主数据',
fact_bill: '账单事实表',
fact_bill_item: '账单明细事实表',
fact_payment: '支付事实表',
fact_promotion_usage: '优惠使用事实表',
fact_platform_order: '平台订单损益表',
fact_inventory_snapshot: '库存快照事实表',
fact_purchase_receipt: '采购收货事实表',
fact_transfer: '调拨事实表',
fact_waste: '报损事实表',
fact_recipe_bom: 'BOM配方事实表',
fact_employee_shift: '员工排班事实表',
fact_customer_contact: '会员触达事实表',
fact_store_task: '门店任务事实表',
enum_risk_level: '风险等级',
enum_priority: '任务优先级',
enum_task_status: '任务状态',
enum_verification_result: '验收结果',
enum_sku_type: 'SKU类型',
enum_sku_status: 'SKU状态',
enum_business_type: '业态类型',
enum_channel_group: '渠道分组',
enum_metric_direction: '指标方向',
enum_member_status: '会员状态',
enum_waste_reason: '报损原因',
enum_management_quadrant: '管理象限',
}
const labelOf = (name: string) => TABLE_LABELS[name] || name
export function OntologyPage() {
const [tab, setTab] = useState<TabType>('overview')
const [selectedTable, setSelectedTable] = useState<string | null>(null)
const [metricFilter, setMetricFilter] = useState('全部')
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['ontology/overview'],
queryFn: () => api.get('/tasks/ontology/overview'),
enabled: tab === 'overview',
})
const { data: dimData, isLoading: dimLoading } = useQuery({
queryKey: ['ontology/dim', selectedTable],
queryFn: () => api.get(`/tasks/ontology/dim/${selectedTable}`),
enabled: tab === 'dimensions' && !!selectedTable,
})
const { data: factData, isLoading: factLoading } = useQuery({
queryKey: ['ontology/fact', selectedTable],
queryFn: () => api.get(`/tasks/ontology/fact/${selectedTable}`),
enabled: tab === 'facts' && !!selectedTable,
})
const { data: enumData, isLoading: enumLoading } = useQuery({
queryKey: ['ontology/enum', selectedTable],
queryFn: () => api.get(`/tasks/ontology/enum/${selectedTable}`),
enabled: tab === 'enums' && !!selectedTable,
})
const { data: metricsData, isLoading: metricsLoading } = useQuery({
queryKey: ['ontology/metrics'],
queryFn: () => api.get('/tasks/ontology/metrics'),
enabled: tab === 'metrics',
})
const ov = (overview as any)?.data
const dim = (dimData as any)?.data
const fact = (factData as any)?.data
const enumRows = (enumData as any)?.data || []
const metrics = (metricsData as any)?.data || []
if (tab === 'overview' && overviewLoading) return <LoadingSpinner text="加载本体标准概览..." />
if (tab === 'dimensions' && dimLoading) return <LoadingSpinner text="加载维度主数据..." />
if (tab === 'facts' && factLoading) return <LoadingSpinner text="加载事实表..." />
if (tab === 'enums' && enumLoading) return <LoadingSpinner text="加载枚举标准..." />
if (tab === 'metrics' && metricsLoading) return <LoadingSpinner text="加载指标标准..." />
const tabs: { key: TabType; label: string; icon: any }[] = [
{ key: 'overview', label: '概览', icon: Database },
{ key: 'dimensions', label: '维度主数据', icon: Box },
{ key: 'facts', label: '事实表', icon: Table },
{ key: 'enums', label: '枚举标准', icon: Layers },
{ key: 'metrics', label: '指标标准', icon: Settings },
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
</div>
{/* Tab bar */}
<div className="flex gap-1 border-b">
{tabs.map(t => {
const Icon = t.icon
return (
<button
key={t.key}
onClick={() => { setTab(t.key); setSelectedTable(null) }}
className={cn(
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<Icon size={16} />
{t.label}
</button>
)
})}
</div>
{/* Overview */}
{tab === 'overview' && ov && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard title="维度主数据表" value={ov.dimensions?.length || 0} unit="张" icon={Box} color="text-blue-500" />
<StatCard title="事实表" value={ov.facts?.length || 0} unit="张" icon={Table} color="text-green-500" />
<StatCard title="枚举标准表" value={ov.enums?.length || 0} unit="张" icon={Layers} color="text-purple-500" />
<StatCard title="指标标准" value={ov.metrics?.total || 0} unit={`条 (已标准化 ${ov.metrics?.standardized || 0})`} icon={Settings} color="text-orange-500" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<TableListCard title="维度主数据" tables={ov.dimensions} icon={Box} onOpen={(name) => { setTab('dimensions'); setSelectedTable(name) }} />
<TableListCard title="事实表" tables={ov.facts} icon={Table} onOpen={(name) => { setTab('facts'); setSelectedTable(name) }} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<TableListCard title="枚举标准" tables={ov.enums} icon={Layers} onOpen={(name) => { setTab('enums'); setSelectedTable(name) }} />
<div className="rounded-lg border p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold"><Settings size={16} /> </h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-bold">{ov.metrics?.total || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-bold text-green-600">{ov.metrics?.standardized || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-bold text-orange-600">{(ov.metrics?.total || 0) - (ov.metrics?.standardized || 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-bold text-blue-600">{ov.metrics?.active || 0}</span>
</div>
<button onClick={() => setTab('metrics')} className="mt-2 flex items-center gap-1 text-xs text-primary hover:underline">
<ChevronRight size={12} />
</button>
</div>
</div>
</div>
</div>
)}
{/* Dimensions */}
{tab === 'dimensions' && (
<div className="space-y-4">
{!selectedTable ? (
<div className="grid gap-3 md:grid-cols-3">
{ov?.dimensions?.map((t: any) => (
<TableCard key={t.table_name} name={t.table_name} label={labelOf(t.table_name)} columnCount={t.column_count} rowCount={t.row_count} onClick={() => setSelectedTable(t.table_name)} />
))}
</div>
) : (
<div className="space-y-4">
<button onClick={() => setSelectedTable(null)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronRight size={14} className="rotate-180" />
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="rounded-lg border">
<DataTable
columns={dim?.columns?.map((c: any) => ({
key: c.column_name,
label: c.column_name,
render: (r: any) => {
const val = r[c.column_name]
if (val === null || val === undefined) return <span className="text-muted-foreground"></span>
if (typeof val === 'object') return <code className="text-xs">{JSON.stringify(val)}</code>
if (c.data_type === 'timestamp with time zone' || c.data_type === 'timestamp without time zone') return <span className="text-xs">{String(val).substring(0, 19)}</span>
return <span className="text-xs">{String(val)}</span>
}
})) || []}
data={dim?.rows || []}
/>
</div>
<div className="text-xs text-muted-foreground"></div>
<div className="rounded-lg border p-3">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left">
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{dim?.columns?.map((c: any) => (
<tr key={c.column_name} className="border-b last:border-0">
<td className="py-1.5 pr-4 font-mono">{c.column_name}</td>
<td className="py-1.5 pr-4 text-muted-foreground">{c.data_type}</td>
<td className="py-1.5 pr-4">{c.is_nullable === 'YES' ? '✓' : <span className="text-red-500"></span>}</td>
<td className="py-1.5 text-muted-foreground">{c.column_default || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Facts */}
{tab === 'facts' && (
<div className="space-y-4">
{!selectedTable ? (
<div className="grid gap-3 md:grid-cols-3">
{ov?.facts?.map((t: any) => (
<TableCard key={t.table_name} name={t.table_name} label={labelOf(t.table_name)} columnCount={t.column_count} rowCount={t.row_count} onClick={() => setSelectedTable(t.table_name)} />
))}
</div>
) : (
<div className="space-y-4">
<button onClick={() => setSelectedTable(null)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronRight size={14} className="rotate-180" />
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">: <span className="font-bold text-foreground">{fact?.columns?.length || 0}</span></span>
<span className="text-muted-foreground">: <span className="font-bold text-foreground">{Number(fact?.total || 0).toLocaleString()}</span> </span>
</div>
<div className="rounded-lg border p-3">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left">
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{fact?.columns?.map((c: any) => (
<tr key={c.column_name} className="border-b last:border-0">
<td className="py-1.5 pr-4 font-mono">{c.column_name}</td>
<td className="py-1.5 pr-4 text-muted-foreground">{c.data_type}</td>
<td className="py-1.5 pr-4">{c.is_nullable === 'YES' ? '✓' : <span className="text-red-500"></span>}</td>
<td className="py-1.5 text-muted-foreground">{c.column_default || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Enums */}
{tab === 'enums' && (
<div className="space-y-4">
{!selectedTable ? (
<div className="grid gap-3 md:grid-cols-3">
{ov?.enums?.map((t: any) => (
<TableCard key={t.table_name} name={t.table_name} label={labelOf(t.table_name)} columnCount={t.column_count} rowCount={t.row_count} onClick={() => setSelectedTable(t.table_name)} />
))}
</div>
) : (
<div className="space-y-4">
<button onClick={() => setSelectedTable(null)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronRight size={14} className="rotate-180" />
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="rounded-lg border">
<DataTable
columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))}
data={enumRows}
/>
</div>
</div>
)}
</div>
)}
{/* Metrics */}
{tab === 'metrics' && (
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
{['全部', '收入', '成本', '优惠', '会员', '商品', '库存', '风险'].map(cat => (
<button key={cat} onClick={() => setMetricFilter(cat)} className={cn(
'rounded-md border px-3 py-1 text-xs font-medium transition-colors',
metricFilter === cat ? 'bg-primary text-primary-foreground border-primary' : 'hover:bg-muted'
)}>{cat}</button>
))}
</div>
<div className="rounded-lg border">
<DataTable
columns={[
{ key: 'metric_code', label: '指标编码', render: (r: any) => <code className="text-xs text-blue-600">{r.metric_code || '—'}</code> },
{ key: 'indicator_name', label: '指标名称', render: (r: any) => <span className="font-medium">{r.indicator_name}</span> },
{ key: 'metric_category', label: '分类', render: (r: any) => r.metric_category ? <span className="rounded bg-muted px-2 py-0.5 text-xs">{r.metric_category}</span> : <span className="text-muted-foreground"></span> },
{ key: 'business_definition', label: '业务定义', render: (r: any) => <span className="text-xs">{r.business_definition}</span> },
{ key: 'formula', label: '计算公式', render: (r: any) => <code className="text-xs">{r.formula}</code> },
{ key: 'unit', label: '单位', align: 'center' as const },
{ key: 'direction', label: '方向', render: (r: any) => r.direction === 'higher_better' ? <span className="text-green-600"></span> : r.direction === 'lower_better' ? <span className="text-red-600"></span> : '—' },
{ key: 'owner', label: '负责人', align: 'center' as const },
{ key: 'yellow_threshold', label: '黄线', align: 'right' as const, render: (r: any) => r.yellow_threshold ?? '—' },
{ key: 'red_threshold', label: '红线', align: 'right' as const, render: (r: any) => r.red_threshold ?? '—' },
{ key: 'update_frequency', label: '频率', align: 'center' as const },
{ key: 'version', label: '版本', render: (r: any) => <span className="text-xs text-muted-foreground">{r.version || '—'}</span> },
]}
data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)}
/>
</div>
</div>
)}
</div>
)
}
function StatCard({ title, value, unit, icon: Icon, color }: { title: string; value: number; unit: string; icon: any; color: string }) {
return (
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{title}</p>
<Icon size={16} className={color} />
</div>
<p className="mt-2 text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{unit}</p>
</div>
)
}
function TableListCard({ title, tables, icon: Icon, onOpen }: { title: string; tables: any[]; icon: any; onOpen: (name: string) => void }) {
return (
<div className="rounded-lg border p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold"><Icon size={16} /> {title}</h3>
<div className="space-y-1">
{tables?.map((t) => (
<button key={t.table_name} onClick={() => onOpen(t.table_name)} className="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-muted transition-colors">
<div className="flex items-center gap-2">
<code className="text-xs text-muted-foreground">{t.table_name}</code>
<span className="text-xs">{labelOf(t.table_name)}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{t.column_count}</span>
<span>{Number(t.row_count).toLocaleString()}</span>
<ArrowRight size={12} />
</div>
</button>
))}
</div>
</div>
)
}
function TableCard({ name, label, columnCount, rowCount, onClick }: { name: string; label: string; columnCount: number; rowCount: number; onClick: () => void }) {
return (
<button onClick={onClick} className="rounded-lg border p-4 text-left hover:border-primary hover:shadow-sm transition-all">
<div className="flex items-center justify-between">
<span className="font-medium">{label}</span>
<ArrowRight size={14} className="text-muted-foreground" />
</div>
<code className="mt-1 block text-xs text-muted-foreground">{name}</code>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span>{columnCount} </span>
<span>{Number(rowCount).toLocaleString()} </span>
</div>
</button>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
const PAGE_SIZE = 15
export function PlatformPage() {
const [platformPage, setPlatformPage] = useState(1)
const [marketingPage, setMarketingPage] = useState(1)
const { data: platformData, isLoading: platformLoading } = useQuery({
queryKey: ['platform/economics'],
queryFn: () => api.get('/platform/economics'),
})
const { data: marketingData, isLoading: marketingLoading } = useQuery({
queryKey: ['marketing/plans'],
queryFn: () => api.get('/marketing/plans'),
})
const platformRows = (platformData as any)?.data || []
const marketingRows = (marketingData as any)?.data || []
const pageLoading = platformLoading || marketingLoading
const storesWithPlatform = platformRows.filter((r: any) => Number(r.meituan_received) > 0 || Number(r.taobao_received) > 0 || Number(r.jd_received) > 0)
const totalMeituan = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_received || 0), 0)
const totalTaobao = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_received || 0), 0)
const totalJd = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_received || 0), 0)
const totalMeituanCost = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_discount || 0) + Number(r.meituan_commission || 0), 0)
const totalTaobaoCost = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_discount || 0) + Number(r.taobao_commission || 0), 0)
const totalJdCost = storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0) + Number(r.jd_commission || 0), 0)
const totalPlatformReceived = totalMeituan + totalTaobao + totalJd
const totalPlatformCost = totalMeituanCost + totalTaobaoCost + totalJdCost
const blendedCostRate = totalPlatformReceived > 0 ? (totalPlatformCost / totalPlatformReceived) * 100 : 0
const highCostStores = storesWithPlatform.filter((r: any) => {
const rates = [Number(r.meituan_cost_rate_pct || 0), Number(r.taobao_cost_rate_pct || 0), Number(r.jd_cost_rate_pct || 0)].filter((v: number) => v > 0)
return rates.some((v: number) => v >= 38)
})
const platformChartData = [
{ name: '美团', 实收: totalMeituan, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.meituan_commission || 0), 0) },
{ name: '淘宝', 实收: totalTaobao, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.taobao_commission || 0), 0) },
{ name: '京东', 实收: totalJd, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_commission || 0), 0) },
]
const pagedPlatform = useMemo(() => storesWithPlatform.slice((platformPage - 1) * PAGE_SIZE, platformPage * PAGE_SIZE), [storesWithPlatform, platformPage])
const pagedMarketing = useMemo(() => marketingRows.slice((marketingPage - 1) * PAGE_SIZE, marketingPage * PAGE_SIZE), [marketingRows, marketingPage])
if (pageLoading) {
return <LoadingSpinner text="加载平台数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · ROI · · 20264</p>
</div>
{/* 概览指标 */}
<CollapsibleSection title="平台概览" subtitle="三平台合并加权成本率">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="平台总实收" value={totalPlatformReceived} format="currency" description="美团+淘宝+京东三平台实收合计" />
<MetricCard title="合并加权成本率" value={blendedCostRate} format="percent" description="(折扣+佣金)÷实收,越高平台让利负担越大" />
<MetricCard title="高成本门店(≥38%)" value={highCostStores.length} format="number" description="任一平台成本率≥38%的门店数" />
<MetricCard title="营销方案数" value={marketingRows.length} format="number" description="活跃营销方案数量" />
</div>
</CollapsibleSection>
{/* 三平台对比图 */}
<CollapsibleSection title="三平台实收与成本对比" subtitle="实收、折扣、佣金金额对比">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={platformChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend />
<Bar dataKey="实收" fill="#3b82f6" />
<Bar dataKey="折扣" fill="#eab308" />
<Bar dataKey="佣金" fill="#ef4444" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 门店平台经济性 */}
<CollapsibleSection title={`门店平台经济性 (${storesWithPlatform.length})`} subtitle="各门店三平台实收与成本率">
<Pagination page={platformPage} pageSize={PAGE_SIZE} total={storesWithPlatform.length} onPageChange={setPlatformPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'meituan_received', label: '美团实收', align: 'right', render: (r) => formatCurrency(r.meituan_received) },
{ key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => {
const v = Number(r.meituan_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) },
{ key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => {
const v = Number(r.taobao_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) },
{ key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => {
const v = Number(r.jd_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
]}
data={pagedPlatform}
/>
</div>
</CollapsibleSection>
{/* 营销方案ROI */}
<CollapsibleSection title={`营销方案分析 (${marketingRows.length})`} subtitle="各营销方案的订单、实收、优惠率与毛利率">
<Pagination page={marketingPage} pageSize={PAGE_SIZE} total={marketingRows.length} onPageChange={setMarketingPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ 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: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
const v = Number(r.discount_rate_pct || 0)
return <span className={v >= 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
data={pagedMarketing}
/>
</div>
</CollapsibleSection>
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, riskColor } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 10
export function RegionalPage() {
const navigate = useNavigate()
const [riskFilter, setRiskFilter] = useState('')
const [riskPage, setRiskPage] = useState(1)
const [checkPage, setCheckPage] = useState(1)
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk'],
queryFn: () => api.get('/stores/risk'),
})
const { data: weeklyData, isLoading: weeklyLoading } = useQuery({
queryKey: ['tasks/weekly-check'],
queryFn: () => api.get('/tasks/weekly-check', { params: { month: '2026-05' } }),
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority'],
queryFn: () => api.get('/stores/priority'),
})
const riskRows = ((riskData as any)?.data || []).filter((r: any) => !riskFilter || r.risk_level === riskFilter)
const weeklyRows = (weeklyData as any)?.data || []
const priorityRows = (priorityData as any)?.data || []
const p0p1 = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
const needCheck = weeklyRows.filter((w: any) => parseInt(w.consecutive_no_improve_weeks) >= 2)
const pagedRiskRows = useMemo(() => riskRows.slice((riskPage - 1) * PAGE_SIZE, riskPage * PAGE_SIZE), [riskRows, riskPage])
const pagedNeedCheck = useMemo(() => needCheck.slice((checkPage - 1) * PAGE_SIZE, checkPage * PAGE_SIZE), [needCheck, checkPage])
const pageLoading = riskLoading || weeklyLoading || priorityLoading
if (pageLoading) {
return <LoadingSpinner text="加载区域经理工作台..." />
}
return (
<div className="space-y-4">
<h1 className="text-xl font-bold"></h1>
{/* 汇总卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="红黄门店数" value={riskRows.filter((r: any) => r.risk_level !== '绿色').length} />
<MetricCard title="P0+P1门店" value={p0p1.length} />
<MetricCard title="需到店检查" value={needCheck.length} />
<MetricCard title="周检待录入" value={weeklyRows.filter((w: any) => !w.last_check_date).length} />
</div>
{/* 风险筛选 */}
<div className="flex gap-2">
{['', '红色', '黄色', '绿色'].map((r) => (
<button
key={r || 'all'}
onClick={() => setRiskFilter(r)}
className={`rounded-md border px-3 py-1.5 text-sm ${riskFilter === r ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{r || '全部'}
</button>
))}
</div>
{/* 门店红黄绿列表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({riskRows.length})</h2>
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskRows.length} onPageChange={setRiskPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'risk_level', label: '风险', render: (r) => <Badge type="risk" text={r.risk_level} /> },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => formatPercent(r.anomaly_rate_pct) },
]}
data={pagedRiskRows}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</div>
{/* 需到店检查门店 */}
{needCheck.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold text-red-600">2({needCheck.length})</h2>
<Pagination page={checkPage} pageSize={PAGE_SIZE} total={needCheck.length} onPageChange={setCheckPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'consecutive_no_improve_weeks', label: '连续未改善周数', align: 'center' },
{ key: 'status', label: '任务状态', render: (r) => <Badge type="status" text={r.status} /> },
]}
data={pagedNeedCheck}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
</div>
)}
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
const PAGE_SIZE = 15
export function RiskPage() {
const [anomalyPage, setAnomalyPage] = useState(1)
const [cashierPage, setCashierPage] = useState(1)
const [zeroPage, setZeroPage] = useState(1)
const [riskTab, setRiskTab] = useState<'anomaly' | 'zero' | 'cashier'>('anomaly')
const { data: anomalyData, isLoading: anomalyLoading } = useQuery({
queryKey: ['risk/anomaly'],
queryFn: () => api.get('/risk/anomaly'),
})
const { data: zeroData, isLoading: zeroLoading } = useQuery({
queryKey: ['risk/zero-received'],
queryFn: () => api.get('/risk/zero-received'),
})
const { data: cashierData, isLoading: cashierLoading } = useQuery({
queryKey: ['risk/cashier'],
queryFn: () => api.get('/risk/cashier'),
})
const anomalyRows = (anomalyData as any)?.data || []
const zeroRows = (zeroData as any)?.data || []
const cashierRows = (cashierData as any)?.data || []
const pageLoading = anomalyLoading || zeroLoading || cashierLoading
const totalAnomaly = anomalyRows.length
const totalZero = zeroRows.length
const highRiskCashiers = cashierRows.filter((r: any) => Number(r.anomaly_rate_pct || 0) >= 10).length
const avgAnomalyRate = cashierRows.length > 0
? cashierRows.reduce((s: number, r: any) => s + Number(r.anomaly_rate_pct || 0), 0) / cashierRows.length
: 0
const pagedAnomaly = useMemo(() => anomalyRows.slice((anomalyPage - 1) * PAGE_SIZE, anomalyPage * PAGE_SIZE), [anomalyRows, anomalyPage])
const pagedZero = useMemo(() => zeroRows.slice((zeroPage - 1) * PAGE_SIZE, zeroPage * PAGE_SIZE), [zeroRows, zeroPage])
const pagedCashier = useMemo(() => cashierRows.slice((cashierPage - 1) * PAGE_SIZE, cashierPage * PAGE_SIZE), [cashierRows, cashierPage])
const cashierChartData = cashierRows.slice(0, 15)
if (pageLoading) {
return <LoadingSpinner text="加载风险数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
</div>
{/* 概览指标 */}
<CollapsibleSection title="风险概览" subtitle="异常账单与收银操作风险汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="异常账单数" value={totalAnomaly} format="number" description="触发异常规则的账单数量,需逐单核查" />
<MetricCard title="零实收账单" value={totalZero} format="number" description="实收为0的账单,可能为全额优惠或操作异常" />
<MetricCard title="高风险收银员(≥10%)" value={highRiskCashiers} format="number" description="异常操作率≥10%的收银员数量" />
<MetricCard title="平均异常率" value={avgAnomalyRate} format="percent" description="所有收银员的平均异常操作率" />
</div>
</CollapsibleSection>
{/* 收银员风险图 */}
<CollapsibleSection title="收银员异常率TOP15" subtitle="异常操作率最高的收银员">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={cashierChartData} layout="vertical" margin={{ left: 60 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" unit="%" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="cashier" tick={{ fontSize: 10 }} width={60} />
<Tooltip formatter={(v: any) => `${Number(v).toFixed(2)}%`} />
<Bar dataKey="anomaly_rate_pct" fill="#ef4444" name="异常率" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 风险明细表 — Tab切换 */}
<CollapsibleSection title="风险明细" subtitle="异常账单 / 零实收 / 收银员风险">
<div className="mb-3 flex gap-2">
<button onClick={() => setRiskTab('anomaly')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'anomaly' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({totalAnomaly})
</button>
<button onClick={() => setRiskTab('zero')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'zero' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({totalZero})
</button>
<button onClick={() => setRiskTab('cashier')} className={`rounded-md border px-3 py-1.5 text-sm ${riskTab === 'cashier' ? 'border-primary bg-primary text-primary-foreground' : ''}`}>
({cashierRows.length})
</button>
</div>
{riskTab === 'anomaly' && (
<>
<Pagination page={anomalyPage} pageSize={PAGE_SIZE} total={anomalyRows.length} onPageChange={setAnomalyPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={pagedAnomaly}
/>
</div>
</>
)}
{riskTab === 'zero' && (
<>
<Pagination page={zeroPage} pageSize={PAGE_SIZE} total={zeroRows.length} onPageChange={setZeroPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'zero_received_type', label: '零实收类型' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={pagedZero}
/>
</div>
</>
)}
{riskTab === 'cashier' && (
<>
<Pagination page={cashierPage} pageSize={PAGE_SIZE} total={cashierRows.length} onPageChange={setCashierPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'cashier', label: '收银员' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => {
const v = Number(r.anomaly_rate_pct || 0)
return <span className={v >= 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}%</span>
}},
{ key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) },
]}
data={pagedCashier}
/>
</div>
</>
)}
</CollapsibleSection>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' }
const PAGE_SIZE = 15
export function SKUPage() {
const [abcFilter, setAbcFilter] = useState('')
const [skuPage, setSkuPage] = useState(1)
const { data: skuData, isLoading: skuLoading } = useQuery({
queryKey: ['sku/abc'],
queryFn: () => api.get('/sku/abc'),
})
const { data: catData, isLoading: catLoading } = useQuery({
queryKey: ['sku/category'],
queryFn: () => api.get('/sku/category'),
})
const skuRows = ((skuData as any)?.data || []).filter((r: any) => !abcFilter || r.abc_class === abcFilter)
const catRows = (catData as any)?.data || []
const abcSummary = (skuData as any)?.data?.reduce((acc: any, r: any) => {
const k = r.abc_class
if (!acc[k]) acc[k] = { sku_count: 0, received: 0, share: 0 }
acc[k].sku_count++
acc[k].received += Number(r.received_amount || 0)
acc[k].share += Number(r.revenue_share_pct || 0)
return acc
}, {}) || {}
const abcPieData = Object.entries(abcSummary).map(([name, v]: any) => ({
name,
value: v.sku_count,
received: v.received,
share: v.share,
}))
const totalSku = (skuData as any)?.data?.length || 0
const totalReceived = Object.values(abcSummary).reduce((s: number, v: any) => s + v.received, 0)
const cClassCount = abcSummary['C-长尾']?.sku_count || 0
const lowValueSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).length
const singleStoreSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).length
const pagedSku = useMemo(() => skuRows.slice((skuPage - 1) * PAGE_SIZE, skuPage * PAGE_SIZE), [skuRows, skuPage])
const pageLoading = skuLoading || catLoading
if (pageLoading) {
return <LoadingSpinner text="加载SKU数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">SKU管理</h1>
<p className="mt-0.5 text-xs text-muted-foreground">SKU ABC分析 · · · 20264</p>
</div>
{/* 概览指标 */}
<CollapsibleSection title="SKU概览" subtitle="商品复杂度与销售集中度">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总SKU数" value={totalSku} format="number" description="所有有销售记录的菜品名称数量" />
<MetricCard title="A类SKU(核心)" value={abcSummary['A-核心']?.sku_count || 0} format="number" description="贡献约70%实收的头部SKU" />
<MetricCard title="C类SKU(长尾)" value={cClassCount} format="number" description="仅贡献10%实收的长尾SKU,建议治理" />
<MetricCard title="低价值SKU(<¥1000)" value={lowValueSku} format="number" description="月实收不足1000元的SKU,优先停用候选" />
</div>
</CollapsibleSection>
{/* ABC分布图 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="SKU ABC分布" subtitle="按实收贡献分层">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={abcPieData}
cx="50%" cy="50%" outerRadius={80}
dataKey="value"
label={({ name, value, share }: any) => `${name}: ${value}个 (${Number(share).toFixed(1)}%)`}
>
{abcPieData.map((entry) => (
<Cell key={entry.name} fill={ABC_COLORS[entry.name as keyof typeof ABC_COLORS] || '#gray'} />
))}
</Pie>
<Tooltip formatter={(v: any, _n: any, p: any) => [`${v}个SKU, 实收¥${formatNumber(p.payload.received)}`, p.payload.name]} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="品类实收分布" subtitle="按一级品类统计">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={catRows.slice(0, 10)} layout="vertical" margin={{ left: 80 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} />
<YAxis type="category" dataKey="category_name" tick={{ fontSize: 10 }} width={80} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="amount" fill="#3b82f6" name="实收" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* SKU明细表 */}
<CollapsibleSection title={`SKU明细 (${skuRows.length})`} subtitle="可按ABC分类筛选,点击表头排序">
<div className="mb-3 flex gap-2">
{['', 'A-核心', 'B-成长', 'C-长尾'].map((f) => (
<button
key={f || 'all'}
onClick={() => { setAbcFilter(f); setSkuPage(1) }}
className={`rounded-md border px-3 py-1.5 text-sm ${abcFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{f || '全部'}
</button>
))}
</div>
<Pagination page={skuPage} pageSize={PAGE_SIZE} total={skuRows.length} onPageChange={setSkuPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'category_level1', label: '一级品类' },
{ key: 'abc_class', label: 'ABC', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>{r.abc_class}</span>
)},
{ key: 'sales_quadrant', label: '象限' },
{ key: 'store_count', label: '覆盖门店', align: 'center' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
]}
data={pagedSku}
/>
</div>
</CollapsibleSection>
{/* 长尾治理建议 */}
<CollapsibleSection title="长尾治理建议" subtitle="基于SKU分布的治理方向" defaultOpen={false}>
<div className="space-y-3 text-sm">
<div className="rounded-md border border-red-200 bg-red-50/50 p-3">
<p className="font-medium text-red-700"> {lowValueSku} SKU</p>
<p className="mt-1 text-xs text-muted-foreground">¥1,000SKU</p>
</div>
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
<p className="font-medium text-yellow-700">SKU{singleStoreSku} </p>
<p className="mt-1 text-xs text-muted-foreground">1广</p>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
<p className="font-medium text-blue-700">/300//</p>
<p className="mt-1 text-xs text-muted-foreground">SKU或合并为统一编码SKU池800-900</p>
</div>
</div>
</CollapsibleSection>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useQuery } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { ArrowLeft } from 'lucide-react'
export function StoreDetailPage() {
const { code } = useParams()
const navigate = useNavigate()
const { data: storeData } = useQuery({
queryKey: ['store', code],
queryFn: () => api.get(`/stores/${code}`),
})
const { data: dailyData } = useQuery({
queryKey: ['store', code, 'daily'],
queryFn: () => api.get(`/stores/${code}/daily`),
})
const { data: tasksData } = useQuery({
queryKey: ['store', code, 'tasks'],
queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }),
})
const sd = (storeData as any)?.data
const daily = ((dailyData as any)?.data) || []
const tasks = ((tasksData as any)?.data) || []
if (!sd) return <div>...</div>
const sc = sd.scorecard
return (
<div className="space-y-4">
<button onClick={() => navigate(-1)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} />
</button>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{sd.risk && <Badge type="risk" text={sd.risk.risk_level} />}
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<MetricCard title="实收" value={sc.received} format="currency" />
<MetricCard title="账单数" value={sc.bill_count} format="number" />
<MetricCard title="客单价" value={sc.avg_bill_value} format="currency" />
<MetricCard title="优惠率" value={sc.discount_rate_pct} format="percent" />
<MetricCard title="理论毛利率" value={sc.theoretical_margin_pct} format="percent" />
<MetricCard title="会员占比" value={sc.member_bill_share_pct} format="percent" />
</div>
{/* 日度趋势 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={daily}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" />
<Line type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" />
</LineChart>
</ResponsiveContainer>
</div>
{/* 平台经济性 */}
{sd.platform && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="美团实收" value={sd.platform.meituan_received} format="currency" />
<MetricCard title="饿了么实收" value={sd.platform.eleme_received} format="currency" />
<MetricCard title="抖音实收" value={sd.platform.douyin_received} format="currency" />
<MetricCard title="平台加权成本率" value={sd.platform.weighted_cost_rate_pct} format="percent" />
</div>
</div>
)}
{/* 任务列表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({tasks.length})</h2>
<DataTable
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
]}
data={tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
</div>
)
}
+319
View File
@@ -0,0 +1,319 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { useState } from 'react'
export function StorePage() {
const queryClient = useQueryClient()
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [storeCode, setStoreCode] = useState('1111')
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list'],
queryFn: () => api.get('/stores/risk'),
})
const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue }))
const { data: priorityData } = useQuery({
queryKey: ['stores-priority-detail'],
queryFn: () => api.get('/stores/priority'),
})
const { data: cardData, isLoading: cardLoading } = useQuery({
queryKey: ['store', storeCode, 'daily-card'],
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`),
})
const { data: tasksData, isLoading: tasksLoading } = useQuery({
queryKey: ['store', storeCode, 'tasks'],
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month: '2026-05', page_size: 50 } }),
})
const { data: dailyData, isLoading: dailyLoading } = useQuery({
queryKey: ['store', storeCode, 'daily'],
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month: '2026-04' } }),
})
const { data: followupData, isLoading: followupLoading } = useQuery({
queryKey: ['store', storeCode, 'followup'],
queryFn: () => api.get('/tasks/followup'),
})
const card = (cardData as any)?.data
const anomalies = card?.anomalies || []
const tasks = (tasksData as any)?.data || []
const dailyRows = (dailyData as any)?.data || []
const followupRows = (followupData as any)?.data || []
const followup = followupRows.find((f: any) => f.store_code === storeCode)
const priorityRows = (priorityData as any)?.data || []
const priorityInfo = priorityRows.find((p: any) => p.store_code === storeCode)
const storeInfo = stores.find((s: any) => s.code === storeCode)
const storeName = storeInfo?.name || ''
const primaryIssue = storeInfo?.issue || priorityInfo?.problem_combination || '-'
const todoTasks = tasks.filter((t: any) => t.status === '待启动' || t.status === '进行中')
const doneTasks = tasks.filter((t: any) => t.status === '已验收' || t.status === '已回滚')
const passCount = tasks.filter((t: any) => t.verification_result === '达标').length
const improvingCount = tasks.filter((t: any) => t.verification_result === '改善中').length
const allAnomalyItems: { module: string; metric: string; value: number; baseline: number; is_anomaly: boolean }[] = []
anomalies.forEach((a: any) => {
const list = typeof a.anomalies === 'string' ? JSON.parse(a.anomalies) : a.anomalies || []
list.forEach((item: any) => {
allAnomalyItems.push({ module: a.module, metric: item.metric, value: item.value, baseline: item.baseline, is_anomaly: item.is_anomaly })
})
})
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
const executeMutation = useMutation({
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
setExecuteText('')
setActiveTaskId(null)
},
})
const riskColor = (level: string) => {
if (level === '红色') return 'bg-red-100 text-red-700 border-red-300'
if (level === '黄色') return 'bg-yellow-100 text-yellow-700 border-yellow-300'
return 'bg-green-100 text-green-700 border-green-300'
}
const pageLoading = storesLoading || cardLoading || tasksLoading || dailyLoading || followupLoading
if (pageLoading) {
return <LoadingSpinner text="加载店长工作台..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<select
value={storeCode}
onChange={(e) => setStoreCode(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
>
{stores.map((s: any) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
</div>
{/* 门店概况 */}
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-lg font-bold">{storeName}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground"></p>
<div className="mt-1">
{storeInfo?.risk && <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-sm font-medium ${riskColor(storeInfo.risk)}`}>{storeInfo.risk}</span>}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-sm font-medium">{primaryIssue}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-lg font-bold">{tasks.length > 0 ? Math.round(doneTasks.length / tasks.length * 100) : 0}%</p>
<p className="text-xs text-muted-foreground">{doneTasks.length}/{tasks.length} </p>
</div>
</div>
{/* 经营概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-bold">51</h2>
{anomalyCount > 0 && (
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} </span>
)}
</div>
{allAnomalyItems.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-2 md:grid-cols-3 lg:grid-cols-6">
{allAnomalyItems.map((item, idx) => (
<div key={idx} className={`rounded-md border p-3 ${item.is_anomaly ? 'border-red-200 bg-red-50/50' : ''}`}>
<p className="text-xs text-muted-foreground">{item.metric}</p>
<p className={`mt-1 text-lg font-bold ${item.is_anomaly ? 'text-red-600' : ''}`}>
{item.metric === '实收' ? formatCurrency(item.value) : formatNumber(item.value)}
</p>
<p className="text-xs text-muted-foreground">: {item.metric === '实收' ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && <span className="text-red-500"> </span>}</p>
</div>
))}
</div>
)}
</div>
{/* 日度趋势 + 月度指标对比 */}
<div className="grid gap-4 lg:grid-cols-2">
{/* 日度实收趋势 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{dailyRows.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={dailyRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} />
</LineChart>
</ResponsiveContainer>
) : <p className="py-16 text-center text-sm text-muted-foreground"></p>}
</div>
{/* 月度指标对比 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{followup ? (
<table className="w-full text-xs">
<thead>
<tr className="border-b text-muted-foreground">
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-right font-medium">线</th>
<th className="py-2 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{[
{ label: '实收', baseline: Number(followup.baseline_received), target: Number(followup.target_received), actual: Number(followup.actual_received), lower: false, isCurrency: true },
{ label: '客单价', baseline: Number(followup.baseline_avg_bill), target: Number(followup.target_avg_bill), actual: Number(followup.actual_avg_bill), lower: false, isCurrency: true },
{ label: '优惠率(%)', baseline: Number(followup.baseline_discount_rate), target: Number(followup.target_discount_rate), actual: Number(followup.actual_discount_rate), lower: true, isCurrency: false },
{ label: '理论毛利率(%)', baseline: Number(followup.baseline_margin_rate), target: Number(followup.target_margin_rate), actual: Number(followup.actual_margin_rate), lower: false, isCurrency: false },
{ label: '异常率(%)', baseline: Number(followup.baseline_anomaly_rate), target: Number(followup.target_anomaly_rate), actual: Number(followup.actual_anomaly_rate), lower: true, isCurrency: false },
{ label: '会员占比(%)', baseline: Number(followup.baseline_member_share), target: Number(followup.target_member_share), actual: Number(followup.actual_member_share), lower: false, isCurrency: false },
].map((m) => {
const achieved = !isNaN(m.actual) && !isNaN(m.target) && (m.lower ? m.actual <= m.target : m.actual >= m.target)
const rate = !isNaN(m.actual) && !isNaN(m.baseline) && m.baseline !== 0 ? Math.round((m.actual / m.baseline) * 100) : null
const fmt = (v: number) => isNaN(v) ? '-' : m.isCurrency ? formatCurrency(v) : formatNumber(v)
return (
<tr key={m.label} className="border-b last:border-0">
<td className="py-2 text-left">{m.label}</td>
<td className="py-2 text-right text-muted-foreground">{fmt(m.baseline)}</td>
<td className="py-2 text-right text-blue-600">{fmt(m.target)}</td>
<td className={`py-2 text-right font-medium ${achieved ? 'text-green-600' : 'text-red-600'}`}>{fmt(m.actual)}</td>
<td className={`py-2 text-right ${achieved ? 'text-green-600' : 'text-red-600'}`}>
{rate !== null ? `${rate}%` : '-'}
{achieved ? ' ✓' : ' ✗'}
</td>
</tr>
)
})}
</tbody>
</table>
) : <p className="py-16 text-center text-sm text-muted-foreground"></p>}
</div>
</div>
{/* 工作重点和行动计划 */}
{followup && (followup.work_focus || followup.action_plan) && (
<div className="grid gap-4 lg:grid-cols-2">
{followup.work_focus && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground">{followup.work_focus}</p>
</div>
)}
{followup.action_plan && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground">{followup.action_plan}</p>
</div>
)}
</div>
)}
{/* 待办任务 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({todoTasks.length})</h2>
<div className="space-y-2">
{todoTasks.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : todoTasks.map((t: any) => (
<div key={t.task_id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge type="priority" text={t.priority} />
<span className="text-sm font-medium">{t.problem_indicator}</span>
</div>
<Badge type="status" text={t.status} />
</div>
<p className="mt-1 text-xs text-muted-foreground">{t.action_required}</p>
<p className="mt-1 text-xs text-muted-foreground">: {t.deadline?.substring(0, 10)}</p>
{activeTaskId === t.task_id ? (
<div className="mt-2">
<textarea
value={executeText}
onChange={(e) => setExecuteText(e.target.value)}
placeholder="填写执行结果和过程证据..."
className="mb-2 w-full rounded-md border p-2 text-xs"
rows={2}
/>
<div className="flex gap-2">
<button
onClick={() => executeMutation.mutate({ id: t.task_id, text: executeText })}
disabled={!executeText || executeMutation.isPending}
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
>
{executeMutation.isPending ? '提交中...' : '提交反馈'}
</button>
<button
onClick={() => { setActiveTaskId(null); setExecuteText('') }}
className="rounded-md border px-3 py-1 text-xs"
>
</button>
</div>
</div>
) : (
t.status === '待启动' && (
<button
onClick={() => setActiveTaskId(t.task_id)}
className="mt-2 rounded-md border px-3 py-1 text-xs hover:bg-muted"
>
</button>
)
)}
</div>
))}
</div>
</div>
{/* 已完成任务 */}
{doneTasks.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({doneTasks.length})</h2>
<div className="space-y-2">
{doneTasks.map((t: any) => (
<div key={t.task_id} className="flex items-center justify-between rounded-md border p-3 opacity-70">
<div className="flex items-center gap-2">
<Badge type="priority" text={t.priority} />
<span className="text-sm">{t.problem_indicator}</span>
</div>
<div className="flex items-center gap-2">
{t.verification_result && <Badge type="review" text={t.verification_result} />}
<Badge type="status" text={t.status} />
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
+323
View File
@@ -0,0 +1,323 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { useState } from 'react'
import { ArrowLeft, CheckCircle, RotateCcw, Clock, User, Target, AlertCircle, FileText, History } from 'lucide-react'
export function TaskDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [executeText, setExecuteText] = useState('')
const [verifyResult, setVerifyResult] = useState('改善中')
const [verifyActual, setVerifyActual] = useState('')
const [showFormula, setShowFormula] = useState(false)
const { data } = useQuery({
queryKey: ['task', id],
queryFn: () => api.get(`/tasks/${id}`),
})
const { data: indicatorsData } = useQuery({
queryKey: ['indicators'],
queryFn: () => api.get('/tasks/indicators'),
})
const td = (data as any)?.data
const task = td?.task
const logs = td?.logs || []
const indicators = (indicatorsData as any)?.data || []
const indicator = indicators.find((i: any) => i.indicator_name === task?.problem_indicator)
const executeMutation = useMutation({
mutationFn: (text: string) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['task', id] }),
})
const verifyMutation = useMutation({
mutationFn: (data: any) => api.put(`/tasks/${id}/verify`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['task', id] }),
})
const rollbackMutation = useMutation({
mutationFn: (reason: string) => api.post(`/tasks/${id}/rollback`, { reason }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['task', id] }),
})
if (!task) return <div className="flex items-center justify-center py-20 text-muted-foreground">...</div>
const fmt = (v: any) => v === null || v === undefined || v === '' ? '-' : Number(v).toFixed(2)
const hasNumericValues = task.current_value !== null && task.current_value !== undefined
const isOverTarget = hasNumericValues && task.target_value !== null && Number(task.current_value) <= Number(task.target_value)
return (
<div className="space-y-5">
{/* 返回按钮 */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={16} />
</button>
{/* 头部卡片 */}
<div className="rounded-xl border bg-card p-5 shadow-sm">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold">{task.store_name}</h1>
<p className="mt-1 text-sm text-muted-foreground">{task.problem_indicator}</p>
</div>
<div className="flex gap-2">
<Badge type="priority" text={task.priority} />
<Badge type="status" text={task.status} />
</div>
</div>
</div>
{/* 指标卡 — 数值型指标显示三卡,非数值型显示验收标准 */}
{hasNumericValues ? (
<div className="grid gap-3 md:grid-cols-3">
{/* 当前值 */}
<div className={`rounded-xl border p-4 ${isOverTarget ? 'border-green-300 bg-green-50/50' : 'border-orange-300 bg-orange-50/50'}`}>
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="mt-1.5 flex items-baseline gap-1">
<span className={`text-2xl font-bold ${isOverTarget ? 'text-green-700' : 'text-orange-700'}`}>{fmt(task.current_value)}</span>
{indicator?.update_frequency && <span className="text-xs text-muted-foreground">/ {indicator.update_frequency}</span>}
</div>
</div>
{/* 基准值 */}
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-xs font-medium text-muted-foreground">4</p>
<div className="mt-1.5 flex items-baseline gap-1">
<span className="text-2xl font-bold text-muted-foreground">{fmt(task.benchmark_value)}</span>
</div>
</div>
{/* 目标值 */}
<div className="rounded-xl border border-blue-200 bg-blue-50/50 p-4">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="mt-1.5 flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-700">{fmt(task.target_value)}</span>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-blue-200 bg-blue-50/50 p-4">
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="mt-1.5 text-sm font-medium text-blue-700">{task.verification_indicator || '-'}</p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
)}
{/* 指标计算说明 — 可折叠 */}
{indicator && (
<div className="rounded-xl border bg-card overflow-hidden">
<button
onClick={() => setShowFormula(!showFormula)}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium hover:bg-muted/40 transition-colors"
>
<span className="flex items-center gap-2">
<Target size={16} className="text-muted-foreground" />
{indicator.indicator_name}
</span>
<span className="text-xs text-muted-foreground">{showFormula ? '收起 ▲' : '展开 ▼'}</span>
</button>
{showFormula && (
<div className="border-t px-4 py-3 space-y-2 text-sm">
<div><span className="text-muted-foreground"></span>{indicator.business_definition}</div>
<div className="flex items-start gap-2">
<span className="text-muted-foreground whitespace-nowrap"></span>
<code className="rounded bg-muted px-2 py-0.5 text-xs font-mono">{indicator.formula}</code>
</div>
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
<span>{indicator.data_source || '-'}</span>
<span>{indicator.update_frequency}</span>
</div>
<div className="flex gap-4 text-xs">
<span className="rounded bg-yellow-100 px-2 py-0.5 text-yellow-800">线 {indicator.yellow_threshold ?? '-'}</span>
<span className="rounded bg-red-100 px-2 py-0.5 text-red-800">线 {indicator.red_threshold ?? '-'}</span>
</div>
<div className="border-t pt-2 text-xs text-muted-foreground">
= 4 | = 4线 | =
</div>
</div>
)}
</div>
)}
{/* 任务详情卡片 */}
<div className="rounded-xl border bg-card p-5 space-y-3">
<h2 className="flex items-center gap-2 text-sm font-bold">
<FileText size={16} className="text-muted-foreground" />
</h2>
<div className="grid gap-3 md:grid-cols-2">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm">{task.problem_description || '-'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Target size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm">{task.verification_indicator || '-'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<FileText size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm">{task.action_required || '-'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<User size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm">{task.owner}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Clock size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm">{task.deadline?.substring(0, 10)}</p>
</div>
</div>
{task.process_evidence && (
<div className="flex items-start gap-2">
<FileText size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm">{task.process_evidence}</p>
</div>
</div>
)}
{task.incomplete_reason && (
<div className="flex items-start gap-2">
<AlertCircle size={16} className="mt-0.5 shrink-0 text-red-500" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm text-red-600">{task.incomplete_reason}</p>
</div>
</div>
)}
{task.verification_result && (
<div className="flex items-start gap-2">
<CheckCircle size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"></p>
<div className="mt-0.5"><Badge type="status" text={task.verification_result} /></div>
</div>
</div>
)}
</div>
</div>
{/* 执行反馈 */}
{task.status === '待启动' && (
<div className="rounded-xl border bg-card p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<FileText size={16} className="text-muted-foreground" />
</h2>
<textarea
value={executeText}
onChange={(e) => setExecuteText(e.target.value)}
placeholder="填写执行结果和过程证据..."
className="mb-3 w-full rounded-lg border p-3 text-sm focus:ring-2 focus:ring-primary/20 focus:outline-none"
rows={3}
/>
<button
onClick={() => executeMutation.mutate(executeText)}
disabled={!executeText || executeMutation.isPending}
className="rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50 hover:bg-primary/90 transition-colors"
>
{executeMutation.isPending ? '提交中...' : '提交执行反馈'}
</button>
</div>
)}
{/* 月度验收 */}
{task.status === '进行中' && (
<div className="rounded-xl border bg-card p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<CheckCircle size={16} className="text-muted-foreground" />
</h2>
<div className="flex flex-wrap gap-2 mb-3">
{['达标', '改善中', '未改善'].map((r) => (
<button
key={r}
onClick={() => setVerifyResult(r)}
className={`rounded-lg border px-4 py-2 text-sm transition-colors ${verifyResult === r
? r === '达标' ? 'border-green-500 bg-green-500 text-white'
: r === '未改善' ? 'border-red-500 bg-red-500 text-white'
: 'border-primary bg-primary text-primary-foreground'
: 'hover:bg-muted'}`}
>
{r}
</button>
))}
</div>
<input
type="number"
value={verifyActual}
onChange={(e) => setVerifyActual(e.target.value)}
placeholder="实际值"
className="mb-3 block w-48 rounded-lg border p-2 text-sm focus:ring-2 focus:ring-primary/20 focus:outline-none"
/>
<div className="flex gap-2">
<button
onClick={() => verifyMutation.mutate({
review_result: verifyResult,
actual_value: parseFloat(verifyActual),
revenue_stable: true,
margin_improved: verifyResult === '达标',
customer_stable: true,
anomaly_decreased: verifyResult !== '未改善',
})}
disabled={verifyMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white disabled:opacity-50 hover:bg-green-700 transition-colors"
>
<CheckCircle size={16} />
</button>
<button
onClick={() => { if (confirm('确认回滚此任务?')) rollbackMutation.mutate('指标改善但收入/顾客恶化') }}
disabled={rollbackMutation.isPending}
className="flex items-center gap-1.5 rounded-lg border border-red-300 px-5 py-2 text-sm font-medium text-red-600 disabled:opacity-50 hover:bg-red-50 transition-colors"
>
<RotateCcw size={16} />
</button>
</div>
</div>
)}
{/* 操作日志 */}
<div className="rounded-xl border bg-card p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<History size={16} className="text-muted-foreground" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-0">
{logs.map((log: any, idx: number) => (
<div key={log.log_id} className="flex items-center gap-3 py-2.5 border-b last:border-0">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
{idx + 1}
</div>
<div className="flex-1 flex flex-wrap items-center gap-2">
<Badge text={log.action} />
{log.new_status && <Badge type="status" text={log.new_status} />}
<span className="text-xs text-muted-foreground">{log.operator}</span>
{log.comment && <span className="text-xs text-muted-foreground"> {log.comment.substring(0, 80)}</span>}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">{log.created_at?.substring(0, 19)}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 5
export function TasksPage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-05')
const [priority, setPriority] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['tasks', month, priority, status],
queryFn: () => api.get('/tasks', { params: { month, priority, status, page_size: 200 } }),
})
const tasks = (data as any)?.data || []
const total = (data as any)?.meta?.total || 0
const statusCounts = tasks.reduce((acc: any, t: any) => {
acc[t.status] = (acc[t.status] || 0) + 1
return acc
}, {})
const groups = useMemo(() =>
Array.from(
tasks.reduce((acc: Map<string, any>, t: any) => {
const key = t.store_code
if (!acc.has(key)) acc.set(key, { store_name: t.store_name, store_code: t.store_code, priority: t.priority, tasks: [] })
acc.get(key).tasks.push(t)
return acc
}, new Map()).values()
), [tasks])
const totalGroups = groups.length
const pagedGroups = groups.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<span className="text-sm text-muted-foreground"> {total} </span>
</div>
{/* 筛选器 */}
<div className="flex flex-wrap gap-2">
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
/>
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
<option value=""></option>
<option value="P0-修复数据口径">P0-</option>
<option value="P0-综合专项整改">P0-</option>
<option value="P1-重点整改">P1-</option>
<option value="P2-单项改善">P2-</option>
<option value="标杆候选"></option>
<option value="持续跟踪"></option>
</select>
<select value={status} onChange={(e) => setStatus(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
<option value=""></option>
<option value="待启动"></option>
<option value="进行中"></option>
<option value="已验收"></option>
<option value="已回滚"></option>
</select>
</div>
{/* 状态汇总 */}
<div className="flex gap-2">
{Object.entries(statusCounts).map(([s, c]: any) => (
<Badge key={s} type="status" text={`${s}: ${c}`} />
))}
</div>
{/* 分页 */}
<Pagination page={page} pageSize={PAGE_SIZE} total={totalGroups} onPageChange={setPage} />
{/* 任务列表 — 按门店分组 */}
<div className="space-y-3">
{pagedGroups.map((group: any) => (
<div key={group.store_code} className="rounded-lg border bg-card overflow-hidden">
{/* 门店组标题 */}
<div
className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2 cursor-pointer hover:bg-muted/60"
onClick={() => navigate(`/stores/${group.store_code}`)}
>
<Badge type="priority" text={group.priority} />
<span className="text-sm font-bold">{group.store_name}</span>
<span className="text-xs text-muted-foreground">{group.tasks.length} </span>
</div>
{/* 组内任务表 */}
<DataTable
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
{ key: 'owner', label: '负责人' },
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
]}
data={group.tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
className="rounded-none border-0"
/>
</div>
))}
</div>
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts'
const CHANNEL_COLORS: Record<string, string> = {
cash: '#22c55e',
alipay: '#3b82f6',
wechat: '#06b6d4',
meituan: '#f59e0b',
unionpay: '#8b5cf6',
douyin: '#ec4899',
credit: '#ef4444',
jd_delivery: '#f97316',
meituan_delivery: '#f59e0b',
taobao_delivery: '#eab308',
}
const CHANNEL_LABELS: Record<string, string> = {
cash: '现金',
alipay: '支付宝',
wechat: '微信',
meituan: '美团到店',
unionpay: '银联',
douyin: '抖音',
credit: '挂账',
jd_delivery: '京东到家',
meituan_delivery: '美团外卖',
taobao_delivery: '淘宝外卖',
meituan_commission: '美团佣金',
taobao_commission: '淘宝佣金',
jd_commission: '京东佣金',
}
const channelLabel = (k: string) => CHANNEL_LABELS[k] || k
export function TimePage() {
const { data: weekdayData, isLoading: weekdayLoading } = useQuery({
queryKey: ['time/weekday'],
queryFn: () => api.get('/time/weekday'),
})
const { data: hourlyData, isLoading: hourlyLoading } = useQuery({
queryKey: ['time/hourly'],
queryFn: () => api.get('/time/hourly'),
})
const { data: channelData, isLoading: channelLoading } = useQuery({
queryKey: ['channel'],
queryFn: () => api.get('/channel'),
})
const weekdayRows = (weekdayData as any)?.data || []
const hourlyRows = (hourlyData as any)?.data || []
const channelRows = (channelData as any)?.data || []
const pageLoading = weekdayLoading || hourlyLoading || channelLoading
const peakWeekday = weekdayRows.length > 0
? weekdayRows.reduce((best: any, r: any) => Number(r.received || 0) > Number(best?.received || 0) ? r : best, null)
: null
const lowWeekday = weekdayRows.length > 0
? weekdayRows.reduce((low: any, r: any) => Number(r.received || 0) < Number(low?.received || Infinity) ? r : low, null)
: null
const peakHour = hourlyRows.length > 0
? hourlyRows.reduce((best: any, r: any) => Number(r.bill_count || 0) > Number(best?.bill_count || 0) ? r : best, null)
: null
const channelTotals = channelRows.length > 0
? Object.keys(channelRows[0]).filter(k => k !== 'business_date' && k !== 'meituan_commission' && k !== 'taobao_commission' && k !== 'jd_commission')
.map(k => ({
channel: k,
total: channelRows.reduce((s: number, r: any) => s + Number(r[k] || 0), 0),
}))
.filter(c => c.total > 0)
.sort((a, b) => b.total - a.total)
: []
if (pageLoading) {
return <LoadingSpinner text="加载时间数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
</div>
{/* 概览指标 */}
<CollapsibleSection title="时间概览" subtitle="高峰与低谷识别">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
<MetricCard title="渠道数" value={channelTotals.length} format="number" description="有交易记录的支付渠道数量" />
</div>
</CollapsibleSection>
{/* 星期趋势 */}
<CollapsibleSection title="星期维度分析" subtitle="各星期实收、账单数、客单价对比">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={weekdayRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="weekday" tick={{ fontSize: 12 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
<Legend />
<Bar yAxisId="left" dataKey="received" fill="#3b82f6" name="实收" />
<Bar yAxisId="right" dataKey="bill_count" fill="#22c55e" name="账单数" />
<Bar yAxisId="left" dataKey="avg_bill_value" fill="#eab308" name="客单价" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 小时分布 */}
<CollapsibleSection title="小时维度分析" subtitle="各时段账单数与实收分布">
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={hourlyRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="closing_hour" tickFormatter={(v) => `${v}:00`} tick={{ fontSize: 10 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
<Tooltip labelFormatter={(v) => `${v}:00`} formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : formatNumber(v)} />
<Legend />
<Area yAxisId="left" type="monotone" dataKey="received" stroke="#3b82f6" fill="#3b82f680" name="实收" />
<Area yAxisId="right" type="monotone" dataKey="bill_count" stroke="#22c55e" fill="#22c55e80" name="账单数" />
</AreaChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 渠道趋势 */}
<CollapsibleSection title="支付渠道趋势" subtitle="各支付方式按日期变化">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={channelRows} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
<Legend />
{channelTotals.slice(0, 6).map(c => (
<Line key={c.channel} type="monotone" dataKey={c.channel} name={channelLabel(c.channel)} stroke={CHANNEL_COLORS[c.channel] || '#gray'} dot={false} strokeWidth={1.5} />
))}
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 渠道汇总 */}
<CollapsibleSection title="渠道汇总" subtitle="各支付方式总金额" defaultOpen={false}>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{channelTotals.map(c => (
<div key={c.channel} className="rounded-md border p-3">
<p className="text-xs text-muted-foreground">{channelLabel(c.channel)}</p>
<p className="mt-1 text-lg font-bold" style={{ color: CHANNEL_COLORS[c.channel] || '#gray' }}>
{formatCurrency(c.total)}
</p>
</div>
))}
</div>
</CollapsibleSection>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})
+826
View File
@@ -0,0 +1,826 @@
-- ============================================================
-- 连锁餐饮企业数字化运营平台 — 本体标准数据库
-- 创建时间: 2026-07-26
-- 说明: 维度主数据表 + 事实表 + 指标标准 + 枚举标准
-- ============================================================
-- ============================================================
-- 一、维度主数据表 (Dimension Tables)
-- ============================================================
-- 1. 门店主数据 (扩展现有 dim_store)
ALTER TABLE analytics.dim_store
ADD COLUMN IF NOT EXISTS brand TEXT DEFAULT '马兰拉面',
ADD COLUMN IF NOT EXISTS comparable BOOLEAN DEFAULT true,
ADD COLUMN IF NOT EXISTS management_quadrant TEXT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
-- 2. SKU主数据
CREATE TABLE IF NOT EXISTS analytics.dim_sku (
sku_code TEXT PRIMARY KEY,
standard_name TEXT NOT NULL,
pos_code TEXT,
meituan_code TEXT,
taobao_code TEXT,
jd_code TEXT,
historical_names TEXT[],
sku_type TEXT NOT NULL DEFAULT '单品', -- 单品/套餐主品/套餐组件/赠品/包装/服务项目
category_l1 TEXT,
category_l2 TEXT,
status TEXT NOT NULL DEFAULT '在售', -- 在售/停用/季节停/待定
effective_date DATE,
discontinued_date DATE,
brand TEXT DEFAULT '马兰拉面',
business_type TEXT, -- 兰州牛肉面/夜市烧烤/火锅等
applicable_stores TEXT[], -- NULL表示全部门店
tags TEXT[], -- 新品/季节品/区域品/战略品
abc_class TEXT, -- A-核心/B-成长/C-长尾
unit TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 3. 原料主数据
CREATE TABLE IF NOT EXISTS analytics.dim_material (
material_code TEXT PRIMARY KEY,
material_name TEXT NOT NULL,
specification TEXT,
major_category TEXT, -- 牛肉类/面粉类/蔬菜类/调味料类等
minor_category TEXT,
finance_category TEXT, -- 原材料/辅料/包装物/低值易耗品
base_unit TEXT NOT NULL, -- kg/个/包/瓶
conversion_ratio JSONB, -- {"kg": 1, "斤": 2}
supplier_code TEXT,
status TEXT DEFAULT '启用',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 4. 会员主数据
CREATE TABLE IF NOT EXISTS analytics.dim_member (
member_id TEXT PRIMARY KEY,
phone_hash TEXT, -- 脱敏手机号
register_channel TEXT, -- 堂食/美团/淘宝/京东/小程序
register_store TEXT,
register_date DATE,
member_level TEXT DEFAULT '普通', -- 普通/银卡/金卡/钻石
total_orders INTEGER DEFAULT 0,
total_revenue NUMERIC DEFAULT 0,
last_order_date DATE,
status TEXT DEFAULT '活跃', -- 活跃/沉睡/流失
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 5. 员工主数据
CREATE TABLE IF NOT EXISTS analytics.dim_employee (
employee_id TEXT PRIMARY KEY,
employee_name TEXT NOT NULL,
position TEXT, -- 店长/副店长/厨师长/厨师/服务员/收银员/配送员
store_code TEXT,
hire_date DATE,
leave_date DATE,
status TEXT DEFAULT '在职', -- 在职/离职/休假
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 6. 渠道主数据
CREATE TABLE IF NOT EXISTS analytics.dim_channel (
channel_code TEXT PRIMARY KEY,
channel_name TEXT NOT NULL,
channel_group TEXT NOT NULL, -- 堂食/外卖/支付
channel_type TEXT, -- 堂食/美团到店/美团外卖/支付宝/微信/现金/银联/抖音/挂账/京东到家/淘宝外卖
platform TEXT, -- 美团/淘宝/京东/抖音/自有
commission_rate NUMERIC, -- 平台佣金率%
is_delivery BOOLEAN DEFAULT false,
sort_order INTEGER DEFAULT 0,
status TEXT DEFAULT '启用',
created_at TIMESTAMPTZ DEFAULT now()
);
-- 7. 活动主数据
CREATE TABLE IF NOT EXISTS analytics.dim_promotion (
promotion_id TEXT PRIMARY KEY,
promotion_name TEXT NOT NULL,
channel TEXT, -- 美团/淘宝/京东/堂食/全渠道
start_date DATE,
end_date DATE,
applicable_stores TEXT[], -- NULL表示全部
applicable_skus TEXT[], -- NULL表示全部
platform_bear NUMERIC DEFAULT 0, -- 平台承担金额
company_bear NUMERIC DEFAULT 0, -- 公司承担金额
store_bear NUMERIC DEFAULT 0, -- 门店承担金额
target_audience TEXT, -- 新客/老客/全客
budget NUMERIC,
status TEXT DEFAULT '进行中', -- 计划中/进行中/已结束/已暂停
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 8. 日历主数据
CREATE TABLE IF NOT EXISTS analytics.dim_calendar (
date_value DATE PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
day INTEGER NOT NULL,
weekday_no INTEGER NOT NULL, -- 1=周一, 7=周日
weekday_name TEXT NOT NULL,
is_weekend BOOLEAN NOT NULL,
is_holiday BOOLEAN DEFAULT false,
holiday_name TEXT,
is_operating_day BOOLEAN DEFAULT true,
weather TEXT, -- 晴/阴/雨/雪/雾
temperature NUMERIC,
special_event TEXT, -- 商圈活动/门店周边事件
created_at TIMESTAMPTZ DEFAULT now()
);
-- 9. 供应商主数据
CREATE TABLE IF NOT EXISTS analytics.dim_supplier (
supplier_code TEXT PRIMARY KEY,
supplier_name TEXT NOT NULL,
supplier_type TEXT, -- 原料/包装/设备/服务
contact_person TEXT,
contact_phone TEXT,
region TEXT,
status TEXT DEFAULT '合作中',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- 二、事实表 (Fact Tables)
-- ============================================================
-- 1. 账单事实表 (物化视图已存在 analytics.bill_fact,此处建规范事实表)
CREATE TABLE IF NOT EXISTS analytics.fact_bill (
record_id BIGINT PRIMARY KEY,
source_file TEXT,
source_row INTEGER,
store_code TEXT NOT NULL,
store_name TEXT,
meal_period TEXT, -- 午市/晚市/夜宵/外卖
bill_no TEXT,
business_area TEXT,
table_type TEXT,
table_no TEXT,
business_date DATE GENERATED ALWAYS AS ((closed_at AT TIME ZONE 'Asia/Shanghai')::date) STORED,
consumption NUMERIC DEFAULT 0,
discount_total NUMERIC DEFAULT 0,
received_total NUMERIC DEFAULT 0,
guest_count NUMERIC DEFAULT 0,
table_count NUMERIC DEFAULT 0,
theoretical_cost NUMERIC DEFAULT 0,
theoretical_profit NUMERIC DEFAULT 0,
theoretical_margin NUMERIC,
third_party_discount NUMERIC DEFAULT 0,
marketing_plan TEXT,
member_id TEXT,
member_level TEXT,
waiter TEXT,
cashier TEXT,
shift_name TEXT,
bill_status TEXT,
order_tag TEXT,
opened_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
duration_minutes NUMERIC,
-- 支付渠道金额
cash_received NUMERIC DEFAULT 0,
alipay_received NUMERIC DEFAULT 0,
wechat_received NUMERIC DEFAULT 0,
meituan_received NUMERIC DEFAULT 0,
unionpay_received NUMERIC DEFAULT 0,
douyin_received NUMERIC DEFAULT 0,
credit_received NUMERIC DEFAULT 0,
jd_delivery_received NUMERIC DEFAULT 0,
meituan_delivery_received NUMERIC DEFAULT 0,
taobao_delivery_received NUMERIC DEFAULT 0,
-- 优惠明细
points_discount NUMERIC DEFAULT 0,
member_coupon_discount NUMERIC DEFAULT 0,
member_prestore_discount NUMERIC DEFAULT 0,
douyin_discount NUMERIC DEFAULT 0,
-- 平台佣金
meituan_delivery_commission NUMERIC DEFAULT 0,
taobao_delivery_commission NUMERIC DEFAULT 0,
jd_delivery_commission NUMERIC DEFAULT 0,
-- 异常标记
anomaly_reason TEXT,
is_anomaly BOOLEAN DEFAULT false,
is_zero_received BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. 账单明细事实表
CREATE TABLE IF NOT EXISTS analytics.fact_bill_item (
detail_id BIGINT PRIMARY KEY,
source_file TEXT,
source_row INTEGER,
store_code TEXT NOT NULL,
store_name TEXT,
bill_no TEXT NOT NULL,
business_type TEXT,
table_or_pickup_no TEXT,
guest_count NUMERIC,
opened_at TIMESTAMP,
closed_at TIMESTAMP,
ordered_at TIMESTAMP,
dish_name TEXT NOT NULL,
sku_code TEXT, -- 关联 dim_sku
category_l1 TEXT,
category_l2 TEXT,
item_type TEXT, -- 单品/套餐/赠品/加料/包装
production_department TEXT,
unit TEXT,
preparation_method TEXT,
sales_quantity NUMERIC DEFAULT 0,
unit_price NUMERIC DEFAULT 0,
gross_amount NUMERIC DEFAULT 0,
received_amount NUMERIC DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 3. 支付事实表 (一张账单一种支付方式一行)
CREATE TABLE IF NOT EXISTS analytics.fact_payment (
id BIGSERIAL PRIMARY KEY,
bill_record_id BIGINT NOT NULL, -- 关联 fact_bill.record_id
store_code TEXT NOT NULL,
bill_no TEXT,
business_date DATE,
channel_code TEXT NOT NULL, -- 关联 dim_channel
amount NUMERIC DEFAULT 0,
commission NUMERIC DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 4. 优惠使用事实表
CREATE TABLE IF NOT EXISTS analytics.fact_promotion_usage (
id BIGSERIAL PRIMARY KEY,
bill_record_id BIGINT,
store_code TEXT NOT NULL,
bill_no TEXT,
business_date DATE,
promotion_id TEXT, -- 关联 dim_promotion
discount_amount NUMERIC DEFAULT 0,
platform_bear NUMERIC DEFAULT 0,
company_bear NUMERIC DEFAULT 0,
store_bear NUMERIC DEFAULT 0,
is_new_customer BOOLEAN,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 5. 平台订单损益表
CREATE TABLE IF NOT EXISTS analytics.fact_platform_order (
id BIGSERIAL PRIMARY KEY,
platform_order_no TEXT NOT NULL,
platform TEXT NOT NULL, -- 美团/淘宝/京东
store_code TEXT NOT NULL,
business_date DATE NOT NULL,
gross_amount NUMERIC DEFAULT 0,
discount_amount NUMERIC DEFAULT 0,
received_amount NUMERIC DEFAULT 0,
commission NUMERIC DEFAULT 0,
delivery_fee NUMERIC DEFAULT 0,
refund_amount NUMERIC DEFAULT 0,
net_revenue NUMERIC DEFAULT 0,
theoretical_cost NUMERIC DEFAULT 0,
gross_profit NUMERIC DEFAULT 0,
is_cancelled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 6. 库存快照事实表
CREATE TABLE IF NOT EXISTS analytics.fact_inventory_snapshot (
id BIGSERIAL PRIMARY KEY,
store_code TEXT NOT NULL,
material_code TEXT NOT NULL,
snapshot_date DATE NOT NULL,
opening_quantity NUMERIC DEFAULT 0,
opening_amount NUMERIC DEFAULT 0,
purchase_quantity NUMERIC DEFAULT 0,
purchase_amount NUMERIC DEFAULT 0,
consumption_quantity NUMERIC DEFAULT 0,
consumption_amount NUMERIC DEFAULT 0,
ending_quantity NUMERIC DEFAULT 0,
ending_amount NUMERIC DEFAULT 0,
waste_quantity NUMERIC DEFAULT 0,
waste_amount NUMERIC DEFAULT 0,
transfer_in_quantity NUMERIC DEFAULT 0,
transfer_out_quantity NUMERIC DEFAULT 0,
inventory_days NUMERIC, -- 库存天数
is_negative BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(store_code, material_code, snapshot_date)
);
-- 7. 采购收货事实表
CREATE TABLE IF NOT EXISTS analytics.fact_purchase_receipt (
id BIGSERIAL PRIMARY KEY,
receipt_no TEXT,
store_code TEXT NOT NULL,
supplier_code TEXT,
material_code TEXT NOT NULL,
receipt_date DATE NOT NULL,
quantity NUMERIC DEFAULT 0,
unit_price_excl_tax NUMERIC DEFAULT 0,
unit_price_incl_tax NUMERIC DEFAULT 0,
tax_rate NUMERIC DEFAULT 0,
amount_excl_tax NUMERIC DEFAULT 0,
tax_amount NUMERIC DEFAULT 0,
amount_incl_tax NUMERIC DEFAULT 0,
return_quantity NUMERIC DEFAULT 0,
batch_no TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 8. 调拨事实表
CREATE TABLE IF NOT EXISTS analytics.fact_transfer (
id BIGSERIAL PRIMARY KEY,
transfer_no TEXT,
from_store_code TEXT NOT NULL,
to_store_code TEXT NOT NULL,
material_code TEXT NOT NULL,
transfer_date DATE NOT NULL,
quantity NUMERIC DEFAULT 0,
amount NUMERIC DEFAULT 0,
operator TEXT,
approver TEXT,
reason TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 9. 报损事实表
CREATE TABLE IF NOT EXISTS analytics.fact_waste (
id BIGSERIAL PRIMARY KEY,
waste_no TEXT,
store_code TEXT NOT NULL,
material_code TEXT NOT NULL,
waste_date DATE NOT NULL,
quantity NUMERIC DEFAULT 0,
amount NUMERIC DEFAULT 0,
reason TEXT, -- 过期/损坏/变质/操作失误/盘点差异
operator TEXT,
approver TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 10. BOM配方事实表
CREATE TABLE IF NOT EXISTS analytics.fact_recipe_bom (
id BIGSERIAL PRIMARY KEY,
sku_code TEXT NOT NULL,
material_code TEXT NOT NULL,
bom_version TEXT NOT NULL DEFAULT 'v1',
standard_gross_quantity NUMERIC NOT NULL, -- 标准毛料量
standard_net_quantity NUMERIC NOT NULL, -- 标准净料量
unit TEXT NOT NULL,
yield_rate NUMERIC, -- 出成率%
waste_rate NUMERIC, -- 损耗率%
substitute_material TEXT, -- 替代原料
effective_date DATE NOT NULL DEFAULT '2026-01-01',
expiry_date DATE,
applicable_stores TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(sku_code, material_code, bom_version)
);
-- 11. 员工排班事实表
CREATE TABLE IF NOT EXISTS analytics.fact_employee_shift (
id BIGSERIAL PRIMARY KEY,
employee_id TEXT NOT NULL,
employee_name TEXT,
store_code TEXT NOT NULL,
shift_date DATE NOT NULL,
shift_name TEXT, -- 早班/中班/晚班/全天
planned_hours NUMERIC DEFAULT 0,
actual_hours NUMERIC DEFAULT 0,
overtime_hours NUMERIC DEFAULT 0,
wage NUMERIC DEFAULT 0,
overtime_pay NUMERIC DEFAULT 0,
meal_period TEXT, -- 午市/晚市/夜宵
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(employee_id, store_code, shift_date, shift_name)
);
-- 12. 会员触达事实表
CREATE TABLE IF NOT EXISTS analytics.fact_customer_contact (
id BIGSERIAL PRIMARY KEY,
member_id TEXT NOT NULL,
contact_type TEXT NOT NULL, -- 短信/推送/券/电话/企微
contact_date DATE NOT NULL,
store_code TEXT,
campaign_id TEXT,
channel TEXT,
is_responded BOOLEAN DEFAULT false,
response_date DATE,
is_converted BOOLEAN DEFAULT false, -- 是否转化为消费
conversion_date DATE,
conversion_amount NUMERIC DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 13. 门店任务事实表 (已存在 analytics.store_task,此处建规范版)
CREATE TABLE IF NOT EXISTS analytics.fact_store_task (
task_id INTEGER PRIMARY KEY,
plan_month DATE,
store_code TEXT NOT NULL,
priority TEXT NOT NULL, -- P0/P1/P2/P3
problem_indicator TEXT NOT NULL,
problem_module TEXT, -- 收入/成本/优惠/会员/库存/风险
current_value NUMERIC,
benchmark_value NUMERIC,
target_value NUMERIC,
problem_description TEXT,
action_required TEXT,
owner TEXT,
collaborators TEXT,
deadline DATE,
status TEXT DEFAULT '待启动',
process_evidence TEXT,
verification_indicator TEXT,
verification_result TEXT,
incomplete_reason TEXT,
next_step TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- 三、指标标准表 (Metric Dictionary)
-- ============================================================
-- 扩展现有 indicator_dictionary 表
ALTER TABLE analytics.indicator_dictionary
ADD COLUMN IF NOT EXISTS metric_code TEXT,
ADD COLUMN IF NOT EXISTS metric_category TEXT, -- 收入/成本/优惠/会员/库存/风险/人效
ADD COLUMN IF NOT EXISTS calculation_sql TEXT,
ADD COLUMN IF NOT EXISTS direction TEXT DEFAULT 'higher_better', -- higher_better/lower_better
ADD COLUMN IF NOT EXISTS unit TEXT, -- 元/%/个/天
ADD COLUMN IF NOT EXISTS granularity TEXT, -- 门店/区域/公司
ADD COLUMN IF NOT EXISTS version TEXT DEFAULT 'v1.0',
ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true;
-- ============================================================
-- 四、枚举标准表 (Enum Tables)
-- ============================================================
-- 1. 风险等级枚举
CREATE TABLE IF NOT EXISTS analytics.enum_risk_level (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
color TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 2. 任务优先级枚举
CREATE TABLE IF NOT EXISTS analytics.enum_priority (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
response_time TEXT,
sort_order INTEGER DEFAULT 0
);
-- 3. 任务状态枚举
CREATE TABLE IF NOT EXISTS analytics.enum_task_status (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 4. 验收结果枚举
CREATE TABLE IF NOT EXISTS analytics.enum_verification_result (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 5. SKU类型枚举
CREATE TABLE IF NOT EXISTS analytics.enum_sku_type (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 6. SKU状态枚举
CREATE TABLE IF NOT EXISTS analytics.enum_sku_status (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 7. 业态枚举
CREATE TABLE IF NOT EXISTS analytics.enum_business_type (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 8. 渠道组枚举
CREATE TABLE IF NOT EXISTS analytics.enum_channel_group (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 9. 指标方向枚举
CREATE TABLE IF NOT EXISTS analytics.enum_metric_direction (
code TEXT PRIMARY KEY,
label TEXT NOT NULL
);
-- 10. 会员状态枚举
CREATE TABLE IF NOT EXISTS analytics.enum_member_status (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 11. 报损原因枚举
CREATE TABLE IF NOT EXISTS analytics.enum_waste_reason (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- 12. 管理象限枚举
CREATE TABLE IF NOT EXISTS analytics.enum_management_quadrant (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- ============================================================
-- 五、创建索引
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_dim_sku_status ON analytics.dim_sku(status);
CREATE INDEX IF NOT EXISTS idx_dim_sku_type ON analytics.dim_sku(sku_type);
CREATE INDEX IF NOT EXISTS idx_dim_sku_category ON analytics.dim_sku(category_l1, category_l2);
CREATE INDEX IF NOT EXISTS idx_dim_material_category ON analytics.dim_material(major_category);
CREATE INDEX IF NOT EXISTS idx_dim_member_status ON analytics.dim_member(status);
CREATE INDEX IF NOT EXISTS idx_dim_employee_store ON analytics.dim_employee(store_code);
CREATE INDEX IF NOT EXISTS idx_dim_channel_group ON analytics.dim_channel(channel_group);
CREATE INDEX IF NOT EXISTS idx_dim_promotion_status ON analytics.dim_promotion(status);
CREATE INDEX IF NOT EXISTS idx_dim_calendar_date ON analytics.dim_calendar(date_value);
CREATE INDEX IF NOT EXISTS idx_fact_bill_store_date ON analytics.fact_bill(store_code, business_date);
CREATE INDEX IF NOT EXISTS idx_fact_bill_no ON analytics.fact_bill(bill_no);
CREATE INDEX IF NOT EXISTS idx_fact_bill_anomaly ON analytics.fact_bill(is_anomaly) WHERE is_anomaly = true;
CREATE INDEX IF NOT EXISTS idx_fact_bill_item_store ON analytics.fact_bill_item(store_code);
CREATE INDEX IF NOT EXISTS idx_fact_bill_item_bill_no ON analytics.fact_bill_item(bill_no);
CREATE INDEX IF NOT EXISTS idx_fact_bill_item_dish ON analytics.fact_bill_item(dish_name);
CREATE INDEX IF NOT EXISTS idx_fact_payment_bill ON analytics.fact_payment(bill_record_id);
CREATE INDEX IF NOT EXISTS idx_fact_payment_channel ON analytics.fact_payment(channel_code);
CREATE INDEX IF NOT EXISTS idx_fact_inventory_store_date ON analytics.fact_inventory_snapshot(store_code, snapshot_date);
CREATE INDEX IF NOT EXISTS idx_fact_inventory_negative ON analytics.fact_inventory_snapshot(is_negative) WHERE is_negative = true;
CREATE INDEX IF NOT EXISTS idx_fact_purchase_store_date ON analytics.fact_purchase_receipt(store_code, receipt_date);
CREATE INDEX IF NOT EXISTS idx_fact_waste_store_date ON analytics.fact_waste(store_code, waste_date);
CREATE INDEX IF NOT EXISTS idx_fact_bom_sku ON analytics.fact_recipe_bom(sku_code);
CREATE INDEX IF NOT EXISTS idx_fact_shift_store_date ON analytics.fact_employee_shift(store_code, shift_date);
CREATE INDEX IF NOT EXISTS idx_fact_contact_member ON analytics.fact_customer_contact(member_id);
CREATE INDEX IF NOT EXISTS idx_fact_task_store ON analytics.fact_store_task(store_code);
CREATE INDEX IF NOT EXISTS idx_fact_task_priority ON analytics.fact_store_task(priority);
-- ============================================================
-- 六、初始化枚举数据
-- ============================================================
INSERT INTO analytics.enum_risk_level (code, label, color, sort_order) VALUES
('红色', '红色', '#ef4444', 1),
('黄色', '黄色', '#eab308', 2),
('绿色', '绿色', '#22c55e', 3)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_priority (code, label, response_time, sort_order) VALUES
('P0', 'P0-紧急', '当日确认、7天内形成方案', 1),
('P1', 'P1-重点', '48小时确认、当月整改', 2),
('P2', 'P2-改善', '一周内纳入计划', 3),
('P3', 'P3-观察', '持续监控', 4)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_task_status (code, label, sort_order) VALUES
('待启动', '待启动', 1),
('进行中', '进行中', 2),
('待验收', '待验收', 3),
('已验收', '已验收', 4),
('已回滚', '已回滚', 5)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_verification_result (code, label, sort_order) VALUES
('达标', '达标', 1),
('改善中', '改善中', 2),
('未达标', '未达标', 3),
('待验收', '待验收', 4)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_sku_type (code, label, sort_order) VALUES
('单品', '单品', 1),
('套餐主品', '套餐主品', 2),
('套餐组件', '套餐组件', 3),
('赠品', '赠品', 4),
('包装', '包装', 5),
('服务项目', '服务项目', 6)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_sku_status (code, label, sort_order) VALUES
('在售', '在售', 1),
('停用', '停用', 2),
('季节停', '季节停', 3),
('待定', '待定', 4)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_business_type (code, label, sort_order) VALUES
('标准门店', '标准门店', 1),
('特殊业态', '特殊业态', 2),
('旗舰店', '旗舰店', 3),
('社区店', '社区店', 4),
('交通枢纽店', '交通枢纽店', 5)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_channel_group (code, label, sort_order) VALUES
('堂食', '堂食', 1),
('外卖', '外卖', 2),
('支付', '支付', 3)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_metric_direction (code, label) VALUES
('higher_better', '越高越好'),
('lower_better', '越低越好')
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_member_status (code, label, sort_order) VALUES
('活跃', '活跃', 1),
('沉睡', '沉睡(30天未消费)', 2),
('流失', '流失(90天未消费)', 3)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_waste_reason (code, label, sort_order) VALUES
('过期', '过期', 1),
('损坏', '损坏', 2),
('变质', '变质', 3),
('操作失误', '操作失误', 4),
('盘点差异', '盘点差异', 5)
ON CONFLICT (code) DO NOTHING;
INSERT INTO analytics.enum_management_quadrant (code, label, sort_order) VALUES
('明星', '明星门店', 1),
('金牛', '金牛门店', 2),
('问题', '问题门店', 3),
('瘦狗', '瘦狗门店', 4)
ON CONFLICT (code) DO NOTHING;
-- ============================================================
-- 七、初始化渠道主数据
-- ============================================================
INSERT INTO analytics.dim_channel (channel_code, channel_name, channel_group, channel_type, platform, is_delivery, sort_order, status) VALUES
('cash', '现金', '支付', '现金', NULL, false, 1, '启用'),
('alipay', '支付宝', '支付', '支付宝', '支付宝', false, 2, '启用'),
('wechat', '微信', '支付', '微信', '微信', false, 3, '启用'),
('unionpay', '银联', '支付', '银联', '银联', false, 4, '启用'),
('credit', '挂账', '支付', '挂账', NULL, false, 5, '启用'),
('meituan', '美团到店', '堂食', '美团到店', '美团', false, 6, '启用'),
('meituan_delivery', '美团外卖', '外卖', '美团外卖', '美团', true, 7, '启用'),
('taobao_delivery', '淘宝外卖', '外卖', '淘宝外卖', '淘宝', true, 8, '启用'),
('jd_delivery', '京东到家', '外卖', '京东到家', '京东', true, 9, '启用'),
('douyin', '抖音', '堂食', '抖音', '抖音', false, 10, '启用')
ON CONFLICT (channel_code) DO NOTHING;
-- ============================================================
-- 八、初始化日历主数据 (2026年全年)
-- ============================================================
INSERT INTO analytics.dim_calendar (date_value, year, month, day, weekday_no, weekday_name, is_weekend, is_holiday, holiday_name, is_operating_day)
SELECT
d::date,
EXTRACT(year FROM d)::int,
EXTRACT(month FROM d)::int,
EXTRACT(day FROM d)::int,
EXTRACT(dow FROM d)::int,
CASE EXTRACT(dow FROM d)::int
WHEN 0 THEN '周日' WHEN 1 THEN '周一' WHEN 2 THEN '周二'
WHEN 3 THEN '周三' WHEN 4 THEN '周四' WHEN 5 THEN '周五'
WHEN 6 THEN '周六'
END,
EXTRACT(dow FROM d)::int IN (0, 6),
false, NULL, true
FROM generate_series('2026-01-01'::date, '2026-12-31'::date, '1 day'::interval) AS d
ON CONFLICT (date_value) DO NOTHING;
-- 标记2026年主要节假日
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '元旦' WHERE date_value = '2026-01-01';
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '春节' WHERE date_value BETWEEN '2026-02-17' AND '2026-02-23';
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '清明节' WHERE date_value BETWEEN '2026-04-04' AND '2026-04-06';
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '劳动节' WHERE date_value BETWEEN '2026-05-01' AND '2026-05-05';
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '端午节' WHERE date_value BETWEEN '2026-06-19' AND '2026-06-21';
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '中秋节' WHERE date_value BETWEEN '2026-09-25' AND '2026-09-27';
UPDATE analytics.dim_calendar SET is_holiday = true, holiday_name = '国庆节' WHERE date_value BETWEEN '2026-10-01' AND '2026-10-07';
-- ============================================================
-- 九、初始化指标标准 (扩展现有 indicator_dictionary)
-- ============================================================
UPDATE analytics.indicator_dictionary SET
metric_code = 'received_total',
metric_category = '收入',
direction = 'higher_better',
unit = '',
granularity = '门店/区域/公司',
version = 'v1.0',
is_active = true
WHERE indicator_name = '实收' AND metric_code IS NULL;
UPDATE analytics.indicator_dictionary SET
metric_code = 'avg_bill_value',
metric_category = '收入',
direction = 'higher_better',
unit = '',
granularity = '门店/区域/公司',
version = 'v1.0',
is_active = true
WHERE indicator_name = '客单价' AND metric_code IS NULL;
UPDATE analytics.indicator_dictionary SET
metric_code = 'discount_rate',
metric_category = '优惠',
direction = 'lower_better',
unit = '%',
granularity = '门店/区域/公司',
version = 'v1.0',
is_active = true
WHERE indicator_name = '优惠率' AND metric_code IS NULL;
-- 补充缺失的核心指标 (upsert: 已有的更新metric_code等字段,新的插入)
INSERT INTO analytics.indicator_dictionary (indicator_name, business_definition, formula, data_source, update_frequency, owner, scope, yellow_threshold, red_threshold, version_date, metric_code, metric_category, direction, unit, granularity, version, is_active)
SELECT * FROM (VALUES
('账单数', '有效账单总数', 'count(*)', 'bill_fact', '', '运营部', '门店/区域/公司', NULL, NULL, '2026-04-01'::date, 'bill_count', '收入', 'higher_better', '', '门店/区域/公司', 'v1.0', true),
('理论成本率', '理论成本占实收比例', 'sum(theoretical_cost)/sum(received_total)*100', 'bill_fact', '', '财务部', '门店/区域/公司', 40, 45, '2026-04-01'::date, 'theoretical_cost_rate', '成本', 'lower_better', '%', '门店/区域/公司', 'v1.0', true),
('实际成本率', '盘点倒挤成本占实收比例', 'sum(consumption_amount)/sum(received_total)*100', 'inventory_cost_records', '', '财务部', '门店/区域/公司', 38, 42, '2026-04-01'::date, 'actual_cost_rate', '成本', 'lower_better', '%', '门店/区域/公司', 'v1.0', true),
('成本差异率', '实际成本率与理论成本率差异', 'actual_cost_rate - theoretical_cost_rate', '计算指标', '', '财务部', '门店/区域/公司', 3, 5, '2026-04-01'::date, 'cost_variance_rate', '成本', 'lower_better', '%', '门店/区域/公司', 'v1.0', true),
('平台合并加权成本率', '三平台(美团+淘宝+京东)加权成本率', '(sum(commission)+sum(discount))/sum(received)*100', 'v_store_platform_economics', '', '运营部', '门店/区域/公司', 25, 30, '2026-04-01'::date, 'platform_weighted_cost_rate', '优惠', 'lower_better', '%', '门店/区域/公司', 'v1.0', true),
('会员识别率', '会员账单占总账单比例', 'count(member_id IS NOT NULL)/count(*)*100', 'bill_fact', '', '会员部', '门店/区域/公司', 15, 10, '2026-04-01'::date, 'member_identify_rate', '会员', 'higher_better', '%', '门店/区域/公司', 'v1.0', true),
('30日复购率', '30天内二次消费会员占比', 'count(repeat_members)/count(identified_members)*100', 'v_store_repeat_summary_monthly', '', '会员部', '门店/区域/公司', 30, 20, '2026-04-01'::date, 'repeat_rate_30d', '会员', 'higher_better', '%', '门店/区域/公司', 'v1.0', true),
('异常账单率', '异常账单数占总账单比例', 'count(is_anomaly)/count(*)*100', 'bill_fact', '', '运营部', '门店/区域/公司', 0.1, 0.5, '2026-04-01'::date, 'anomaly_bill_rate', '风险', 'lower_better', '%', '门店/区域/公司', 'v1.0', true),
('零实收账单数', '实收为0的账单数量', 'count(received_total=0)', 'bill_fact', '', '运营部', '门店', 5, 10, '2026-04-01'::date, 'zero_received_count', '风险', 'lower_better', '', '门店', 'v1.0', true),
('库存天数', '期末库存可消耗天数', 'ending_amount/(consumption_amount/30)', 'inventory_cost_records', '', '供应链', '门店', 7, 14, '2026-04-01'::date, 'inventory_days', '库存', 'lower_better', '', '门店', 'v1.0', true),
('负耗用货品数', '消耗量为负的货品数量', 'count(consumption_quantity<0)', 'inventory_cost_records', '', '财务部', '门店', 0, 3, '2026-04-01'::date, 'negative_consumption_count', '库存', 'lower_better', '', '门店', 'v1.0', true),
('理论毛利率', '理论毛利占消费额比例', 'sum(theoretical_profit)/sum(consumption)*100', 'bill_fact', '', '财务部', '门店/区域/公司', 65, 60, '2026-04-01'::date, 'theoretical_margin_rate', '收入', 'higher_better', '%', '门店/区域/公司', 'v1.0', true),
('活动新客率', '活动期间新客占活动用户比例', 'count(is_new_customer)/count(*)*100', 'fact_promotion_usage', '', '会员部', '门店/活动', 30, 15, '2026-04-01'::date, 'campaign_new_customer_rate', '优惠', 'higher_better', '%', '门店/活动', 'v1.0', true),
('SKU有效数', '在售且月实收>1000的SKU数量', 'count(sku_code WHERE status=在售 AND received_amount>1000)', 'fact_bill_item JOIN dim_sku', '', '商品部', '公司/区域', 900, 700, '2026-04-01'::date, 'effective_sku_count', '商品', 'lower_better', '', '公司/区域', 'v1.0', true),
('长尾SKU占比', 'C类SKU数占总SKU数比例', 'count(abc_class=C)/count(*)*100', 'dim_sku', '', '商品部', '公司', 50, 60, '2026-04-01'::date, 'longtail_sku_ratio', '商品', 'lower_better', '%', '公司', 'v1.0', true)
) AS t(indicator_name, business_definition, formula, data_source, update_frequency, owner, scope, yellow_threshold, red_threshold, version_date, metric_code, metric_category, direction, unit, granularity, version, is_active)
ON CONFLICT (indicator_name) DO UPDATE SET
metric_code = EXCLUDED.metric_code,
metric_category = EXCLUDED.metric_category,
direction = EXCLUDED.direction,
unit = EXCLUDED.unit,
granularity = EXCLUDED.granularity,
version = EXCLUDED.version,
is_active = EXCLUDED.is_active,
business_definition = EXCLUDED.business_definition,
formula = EXCLUDED.formula,
yellow_threshold = EXCLUDED.yellow_threshold,
red_threshold = EXCLUDED.red_threshold;
-- ============================================================
-- 十、创建数据质量检查日志表
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics.data_quality_log (
id BIGSERIAL PRIMARY KEY,
check_date DATE NOT NULL DEFAULT CURRENT_DATE,
check_type TEXT NOT NULL, -- bill/dish/inventory/mapping
total_records BIGINT,
issue_count INTEGER DEFAULT 0,
issue_details JSONB,
status TEXT DEFAULT '通过', -- 通过/警告/失败
checked_by TEXT DEFAULT 'system',
created_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- 十一、创建口径变更日志表
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics.metric_version_log (
id BIGSERIAL PRIMARY KEY,
metric_code TEXT NOT NULL,
version TEXT NOT NULL,
change_date DATE NOT NULL DEFAULT CURRENT_DATE,
change_reason TEXT NOT NULL,
old_formula TEXT,
new_formula TEXT,
impact_amount NUMERIC,
changed_by TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- 完成提示
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '本体标准数据库表创建完成';
END $$;
+372
View File
@@ -0,0 +1,372 @@
{
"name": "sbrain-co",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sbrain-co",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.3",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "sbrain-co",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev",
"install:all": "npm install && cd server && npm install && cd ../client && npm install",
"db:init": "psql -d bill_query -f server/sql/01_create_tables.sql && psql -d bill_query -f server/sql/02_create_functions.sql && psql -d bill_query -f server/sql/03_create_views.sql && psql -d bill_query -f server/sql/04_init_data.sql"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}
+1
View File
@@ -0,0 +1 @@
当前数据库是本机Homebrew PostgreSQL 15,数据库名为 bill_query,端口5432,仅监听 localhost;当前用户 freedak 是超级用户,本机认证方式为 trust。这适合本机开发,但不适合直接照搬到服务器生产环境。前端浏览器也不应直接连接PostgreSQL,必须通过后端API访问。
+1840
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "sbrain-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "tsx src/tests/run-tests.ts"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}
+181
View File
@@ -0,0 +1,181 @@
-- ============================================================
-- 01_create_tables.sql
-- 任务闭环系统:表结构创建
-- ============================================================
-- 门店整改任务表
CREATE TABLE IF NOT EXISTS analytics.store_task (
task_id SERIAL PRIMARY KEY,
plan_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
priority TEXT NOT NULL,
problem_indicator TEXT NOT NULL,
current_value NUMERIC,
benchmark_value NUMERIC,
target_value NUMERIC,
problem_description TEXT,
action_required TEXT,
owner TEXT NOT NULL,
collaborators TEXT,
deadline DATE NOT NULL,
status TEXT DEFAULT '待启动',
process_evidence TEXT,
verification_indicator TEXT,
verification_result TEXT,
incomplete_reason TEXT,
next_step TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 任务状态变更日志
CREATE TABLE IF NOT EXISTS analytics.store_task_log (
log_id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id) ON DELETE CASCADE,
action TEXT NOT NULL,
old_status TEXT,
new_status TEXT,
operator TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 任务模板表
CREATE TABLE IF NOT EXISTS analytics.task_template (
template_id SERIAL PRIMARY KEY,
problem_type TEXT NOT NULL UNIQUE,
problem_indicator TEXT NOT NULL,
default_action TEXT NOT NULL,
default_target_adjustment TEXT,
verification_indicator TEXT,
suggested_deadline_days INT DEFAULT 30
);
-- 指标字典表
CREATE TABLE IF NOT EXISTS analytics.indicator_dictionary (
id SERIAL PRIMARY KEY,
indicator_name TEXT NOT NULL UNIQUE,
business_definition TEXT NOT NULL,
formula TEXT NOT NULL,
data_source TEXT NOT NULL,
update_frequency TEXT NOT NULL,
owner TEXT,
scope TEXT,
yellow_threshold NUMERIC,
red_threshold NUMERIC,
version_date DATE NOT NULL DEFAULT CURRENT_DATE
);
-- 门店主数据表
CREATE TABLE IF NOT EXISTS analytics.dim_store (
store_code TEXT PRIMARY KEY,
store_name TEXT NOT NULL,
region TEXT,
business_type TEXT,
open_date DATE,
close_date DATE,
area_sqm NUMERIC,
seat_count INT,
business_area TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 经验标准化表
CREATE TABLE IF NOT EXISTS analytics.standardized_practice (
id SERIAL PRIMARY KEY,
practice_module TEXT NOT NULL,
benchmark_store_code TEXT NOT NULL,
benchmark_store_name TEXT NOT NULL,
key_actions TEXT NOT NULL,
verification_indicators TEXT,
status TEXT DEFAULT '待推广',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 经验推广试点结果表
CREATE TABLE IF NOT EXISTS analytics.practice_replication (
id SERIAL PRIMARY KEY,
practice_id INT REFERENCES analytics.standardized_practice(id) ON DELETE CASCADE,
trial_store_code TEXT NOT NULL,
trial_store_name TEXT NOT NULL,
observation_weeks INT DEFAULT 4,
before_value NUMERIC,
after_value NUMERIC,
revenue_impacted BOOLEAN DEFAULT FALSE,
customer_impacted BOOLEAN DEFAULT FALSE,
inventory_impacted BOOLEAN DEFAULT FALSE,
status TEXT DEFAULT '观察中',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 门店升降级日志表
CREATE TABLE IF NOT EXISTS analytics.store_grade_change (
id SERIAL PRIMARY KEY,
change_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
old_priority TEXT,
new_priority TEXT,
change_type TEXT,
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 周度检查记录表
CREATE TABLE IF NOT EXISTS analytics.task_weekly_check (
id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id) ON DELETE CASCADE,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
problem_indicator TEXT NOT NULL,
iso_week INT NOT NULL,
this_week_value NUMERIC,
last_week_value NUMERIC,
change_direction TEXT,
consecutive_no_improve_weeks INT DEFAULT 0,
check_comment TEXT,
checked_by TEXT,
checked_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(task_id, iso_week)
);
-- 月度验收记录表
CREATE TABLE IF NOT EXISTS analytics.task_monthly_review (
id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id) ON DELETE CASCADE,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
plan_month DATE NOT NULL,
problem_indicator TEXT NOT NULL,
baseline_value NUMERIC,
target_value NUMERIC,
actual_value NUMERIC,
review_result TEXT NOT NULL,
revenue_stable BOOLEAN DEFAULT FALSE,
margin_improved BOOLEAN DEFAULT FALSE,
customer_stable BOOLEAN DEFAULT FALSE,
anomaly_decreased BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(task_id)
);
-- 数据刷新日志表
CREATE TABLE IF NOT EXISTS analytics.data_refresh_log (
id SERIAL PRIMARY KEY,
refresh_month DATE NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
details TEXT,
operator TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_store_task_month ON analytics.store_task(plan_month);
CREATE INDEX IF NOT EXISTS idx_store_task_store ON analytics.store_task(store_code);
CREATE INDEX IF NOT EXISTS idx_store_task_priority ON analytics.store_task(priority);
CREATE INDEX IF NOT EXISTS idx_store_task_status ON analytics.store_task(status);
CREATE INDEX IF NOT EXISTS idx_store_task_log_task ON analytics.store_task_log(task_id);
CREATE INDEX IF NOT EXISTS idx_task_weekly_check_task ON analytics.task_weekly_check(task_id);
CREATE INDEX IF NOT EXISTS idx_task_monthly_review_task ON analytics.task_monthly_review(task_id);
+199
View File
@@ -0,0 +1,199 @@
-- ============================================================
-- 02_create_functions.sql
-- 自动分级函数 + 任务自动生成函数 + 通知推送函数
-- ============================================================
-- 自动分级函数:基于 v_store_action_priority_deep_april 逻辑
-- 输入月份参数,输出门店分级结果
CREATE OR REPLACE FUNCTION analytics.f_auto_grade_stores(p_month DATE)
RETURNS TABLE(
store_code TEXT,
store_name TEXT,
priority TEXT,
problem_count INT,
problem_combination TEXT,
received NUMERIC,
scale_tier TEXT,
business_type TEXT
) AS $$
BEGIN
-- 当前仅4月数据可用,直接查询现有视图
-- 后续月份需先刷新物化视图再查询
RETURN QUERY
SELECT v.store_code, v.store_name, v.action_priority AS priority,
v.problem_count, v.problem_combination,
v.received, v.scale_tier, v.business_type
FROM analytics.v_store_action_priority_deep_april v
ORDER BY CASE v.action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
WHEN 'P1-重点整改' THEN 3
WHEN 'P2-单项改善' THEN 4
WHEN '标杆候选' THEN 5
ELSE 6
END, v.received DESC;
END;
$$ LANGUAGE plpgsql STABLE;
-- 任务自动生成函数:按分级结果自动生成门店任务
-- 每店最多2个核心指标,P0门店可增加数据修复任务
CREATE OR REPLACE FUNCTION analytics.f_generate_store_tasks(p_month DATE)
RETURNS TABLE(generated INT, message TEXT) AS $$
DECLARE
v_count INT := 0;
v_store RECORD;
v_task1_indicator TEXT;
v_task1_action TEXT;
v_task1_target TEXT;
v_task2_indicator TEXT;
v_task2_action TEXT;
v_task2_target TEXT;
v_deadline DATE;
BEGIN
v_deadline := (p_month + INTERVAL '1 month' - INTERVAL '1 day')::date;
FOR v_store IN
SELECT * FROM analytics.f_auto_grade_stores(p_month)
LOOP
-- 根据问题组合生成任务1
v_task1_indicator := NULL;
v_task1_action := NULL;
v_task1_target := NULL;
-- 根据问题组合确定第一个任务
IF v_store.problem_combination LIKE '%优惠偏高%' THEN
v_task1_indicator := '优惠率';
v_task1_action := '拆解平台折扣和营销方案,每周复核高折扣账单,退出低毛利满减商品';
v_task1_target := '优惠率降低2个百分点';
ELSIF v_store.problem_combination LIKE '%实际成本严重超耗%' THEN
v_task1_indicator := '实际成本率';
v_task1_action := '获取SKU成本,分析超耗原料,调整低毛利商品和套餐';
v_task1_target := '实际成本率降低至理论+10%以内';
ELSIF v_store.problem_combination LIKE '%理论毛利偏低%' THEN
v_task1_indicator := '理论毛利率';
v_task1_action := '分析商品结构,提升高毛利品类占比,优化套餐组合';
v_task1_target := '理论毛利率提升1个百分点';
ELSIF v_store.problem_combination LIKE '%会员复购偏低%' THEN
v_task1_indicator := '会员复购率';
v_task1_action := '执行消费后第3天和第7天触达,使用会员价和复购券';
v_task1_target := '复购率提升5个百分点';
ELSIF v_store.problem_combination LIKE '%异常%' THEN
v_task1_indicator := '异常率';
v_task1_action := '全额优惠必须填写原因和审批人,周度抽查收银员及营销方案';
v_task1_target := '异常率降低至1.5%以下';
ELSIF v_store.problem_combination LIKE '%饮品搭售偏低%' THEN
v_task1_indicator := '饮品搭售率';
v_task1_action := '按午晚市设计主食加饮品组合销售,培训搭售话术';
v_task1_target := '饮品搭售率提升5个百分点';
ELSIF v_store.problem_combination LIKE '%平台成本偏高%' THEN
v_task1_indicator := '平台加权成本率';
v_task1_action := '设置平台成本率红线,优化满减和折扣策略';
v_task1_target := '平台成本率降低1个百分点';
ELSIF v_store.priority = '标杆候选' THEN
v_task1_indicator := '经验输出';
v_task1_action := '总结核心SKU结构、会员运营、平台价格纪律等可推广经验';
v_task1_target := '输出至少1个标准化经验模块';
ELSIF v_store.priority = '持续跟踪' THEN
v_task1_indicator := '经营稳定性';
v_task1_action := '保持现有经营水平,关注核心指标波动';
v_task1_target := '核心指标不恶化';
END IF;
-- 生成第一个任务
IF v_task1_indicator IS NOT NULL THEN
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
problem_description, action_required, owner, deadline, status,
verification_indicator)
VALUES (p_month, v_store.store_code, v_store.store_name, v_store.priority,
v_task1_indicator, v_store.problem_combination,
v_task1_action, '店长/区域经理', v_deadline, '待启动',
v_task1_target)
ON CONFLICT DO NOTHING;
v_count := v_count + 1;
END IF;
-- 根据问题组合生成第二个任务(如果有多个问题)
IF v_store.problem_count >= 2 THEN
v_task2_indicator := NULL;
v_task2_action := NULL;
v_task2_target := NULL;
IF v_store.problem_combination LIKE '%优惠偏高%' AND v_task1_indicator != '优惠率' THEN
v_task2_indicator := '优惠率';
v_task2_action := '拆解平台折扣和营销方案,每周复核高折扣账单';
v_task2_target := '优惠率降低2个百分点';
ELSIF v_store.problem_combination LIKE '%会员复购偏低%' AND v_task1_indicator != '会员复购率' THEN
v_task2_indicator := '会员复购率';
v_task2_action := '执行消费后第3天和第7天触达,使用会员价和复购券';
v_task2_target := '复购率提升5个百分点';
ELSIF v_store.problem_combination LIKE '%异常%' AND v_task1_indicator != '异常率' THEN
v_task2_indicator := '异常率';
v_task2_action := '全额优惠必须填写原因和审批人,周度抽查';
v_task2_target := '异常率降低至1.5%以下';
ELSIF v_store.problem_combination LIKE '%理论毛利偏低%' AND v_task1_indicator != '理论毛利率' THEN
v_task2_indicator := '理论毛利率';
v_task2_action := '分析商品结构,提升高毛利品类占比';
v_task2_target := '理论毛利率提升1个百分点';
ELSIF v_store.problem_combination LIKE '%饮品搭售偏低%' AND v_task1_indicator != '饮品搭售率' THEN
v_task2_indicator := '饮品搭售率';
v_task2_action := '按午晚市设计主食加饮品组合销售';
v_task2_target := '饮品搭售率提升5个百分点';
END IF;
IF v_task2_indicator IS NOT NULL THEN
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
problem_description, action_required, owner, deadline, status,
verification_indicator)
VALUES (p_month, v_store.store_code, v_store.store_name, v_store.priority,
v_task2_indicator, v_store.problem_combination,
v_task2_action, '店长/区域经理', v_deadline, '待启动',
v_task2_target)
ON CONFLICT DO NOTHING;
v_count := v_count + 1;
END IF;
END IF;
-- P0-修复数据口径 门店增加数据修复任务
IF v_store.priority = 'P0-修复数据口径' THEN
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
problem_description, action_required, owner, deadline, status,
verification_indicator)
VALUES (p_month, v_store.store_code, v_store.store_name, v_store.priority,
'数据口径修复', '成本口径异常',
'核实成本单位与门店映射关系,修正盘点数据口径', '信息部/财务部',
v_deadline, '待启动', '成本口径校验通过')
ON CONFLICT DO NOTHING;
v_count := v_count + 1;
END IF;
END LOOP;
RETURN QUERY SELECT v_count, format('已为 %s 家门店生成 %s 条任务',
(SELECT count(DISTINCT store_code) FROM analytics.store_task WHERE plan_month = p_month),
v_count);
END;
$$ LANGUAGE plpgsql;
-- 每日通知推送函数
CREATE OR REPLACE FUNCTION analytics.f_dispatch_daily_notifications()
RETURNS TABLE(dispatched INT, message TEXT) AS $$
DECLARE
v_count INT := 0;
v_store RECORD;
BEGIN
-- 为每家门店的待启动任务标记为已推送
-- 实际推送由应用层处理,这里只做标记
UPDATE analytics.store_task
SET updated_at = NOW()
WHERE status = '待启动' AND updated_at < NOW() - INTERVAL '1 day';
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN QUERY SELECT v_count, format('已推送 %s 条待办任务通知', v_count);
END;
$$ LANGUAGE plpgsql;
+225
View File
@@ -0,0 +1,225 @@
-- ============================================================
-- 03_create_views.sql
-- 周度检查视图 + 月度验收视图 + 闭环健康度视图 + 数据质量视图
-- ============================================================
-- 周度检查视图
CREATE OR REPLACE VIEW analytics.v_task_weekly_check AS
SELECT
w.id, w.task_id, w.store_code, w.store_name, w.problem_indicator,
w.iso_week,
round(w.this_week_value, 2) AS this_week_value,
round(w.last_week_value, 2) AS last_week_value,
w.change_direction,
w.consecutive_no_improve_weeks,
w.check_comment,
w.checked_by,
w.checked_at,
t.plan_month,
t.priority,
t.status AS task_status,
t.target_value,
CASE
WHEN w.consecutive_no_improve_weeks >= 2 THEN '需重新判断原因'
WHEN w.change_direction = 'up' AND t.problem_indicator IN ('优惠率','异常率','实际成本率','平台加权成本率') THEN '改善中'
WHEN w.change_direction = 'down' AND t.problem_indicator IN ('优惠率','异常率','实际成本率','平台加权成本率') THEN '需关注'
WHEN w.change_direction = 'up' AND t.problem_indicator IN ('理论毛利率','会员复购率','饮品搭售率','经营稳定性') THEN '改善中'
WHEN w.change_direction = 'down' AND t.problem_indicator IN ('理论毛利率','会员复购率','饮品搭售率','经营稳定性') THEN '需关注'
ELSE '观察中'
END AS improvement_status
FROM analytics.task_weekly_check w
JOIN analytics.store_task t ON w.task_id = t.task_id;
-- 月度验收视图
CREATE OR REPLACE VIEW analytics.v_task_monthly_review AS
SELECT
r.id, r.task_id, r.store_code, r.store_name, r.plan_month,
r.problem_indicator,
round(r.baseline_value, 2) AS baseline_value,
round(r.target_value, 2) AS target_value,
round(r.actual_value, 2) AS actual_value,
r.review_result,
r.revenue_stable,
r.margin_improved,
r.customer_stable,
r.anomaly_decreased,
CASE
WHEN r.review_result = '达标' THEN true
WHEN r.review_result = '改善中' AND r.revenue_stable AND r.customer_stable THEN true
ELSE false
END AS can_promote,
t.priority,
t.owner,
t.action_required,
t.process_evidence,
t.incomplete_reason,
t.next_step
FROM analytics.task_monthly_review r
JOIN analytics.store_task t ON r.task_id = t.task_id;
-- 闭环健康度视图
CREATE OR REPLACE VIEW analytics.v_loop_health AS
WITH task_stats AS (
SELECT
count(DISTINCT store_code) AS total_stores,
count(DISTINCT CASE WHEN status != '待启动' THEN store_code END) AS executed_stores,
count(*) AS total_tasks,
count(*) FILTER (WHERE status = '已验收') AS verified_tasks,
count(*) FILTER (WHERE status = '已回滚') AS rolled_back_tasks
FROM analytics.store_task
WHERE plan_month = date_trunc('month', COALESCE(
(SELECT max(plan_month) FROM analytics.store_task), CURRENT_DATE))
),
weekly_stats AS (
SELECT
count(DISTINCT t.store_code) AS stores_with_weekly_check
FROM analytics.task_weekly_check w
JOIN analytics.store_task t ON w.task_id = t.task_id
WHERE t.plan_month = date_trunc('month', COALESCE(
(SELECT max(plan_month) FROM analytics.store_task), CURRENT_DATE))
AND w.checked_at >= date_trunc('month', COALESCE(
(SELECT max(plan_month) FROM analytics.store_task), CURRENT_DATE))
),
practice_stats AS (
SELECT
count(*) AS total_practices,
count(*) FILTER (WHERE status = '已推广') AS promoted_practices
FROM analytics.standardized_practice
),
store_count AS (
SELECT count(DISTINCT store_code) AS total FROM analytics.v_store_scorecard
)
SELECT
round(COALESCE(ts.total_stores::numeric / NULLIF(sc.total, 0) * 100, 0), 1) AS task_generation_rate,
round(COALESCE(ts.executed_stores::numeric / NULLIF(ts.total_stores, 0) * 100, 0), 1) AS store_execution_rate,
round(COALESCE(ws.stores_with_weekly_check::numeric / NULLIF(ts.total_stores, 0) * 100, 0), 1) AS weekly_check_rate,
round(COALESCE(ts.verified_tasks::numeric / NULLIF(ts.total_tasks, 0) * 100, 0), 1) AS monthly_review_rate,
round(COALESCE(ps.promoted_practices::numeric / NULLIF(ps.total_practices, 0) * 100, 0), 1) AS practice_promotion_rate
FROM task_stats ts
CROSS JOIN weekly_stats ws
CROSS JOIN practice_stats ps
CROSS JOIN store_count sc;
-- 数据质量检查视图
CREATE OR REPLACE VIEW analytics.v_data_quality_check AS
WITH bill_stats AS (
SELECT
count(*) AS total_bills,
count(*) FILTER (WHERE NULLIF(c005, '') IS NULL) AS missing_bill_no,
count(*) FILTER (WHERE NULLIF(c002, '') IS NULL) AS missing_store_code,
count(*) FILTER (WHERE c009::numeric <= 0 OR c009 IS NULL) AS zero_consumption,
count(*) FILTER (WHERE c114::numeric < 0) AS negative_received,
count(DISTINCT NULLIF(c002, '')) AS store_count,
min(c175::timestamp) AS min_date,
max(c176::timestamp) AS max_date
FROM bill_records
),
dish_stats AS (
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS missing_dish
FROM dish_sales_details
)
SELECT
b.total_bills,
b.missing_bill_no,
b.missing_store_code,
b.zero_consumption,
b.negative_received,
b.store_count,
b.min_date,
b.max_date,
d.total_dish_records,
d.missing_store AS dish_missing_store,
d.missing_dish AS dish_missing_dish,
CASE
WHEN b.missing_bill_no > 0 THEN '有账单缺失单号'
WHEN b.missing_store_code > 0 THEN '有账单缺失门店编码'
WHEN b.negative_received > 0 THEN '有负实收账单'
ELSE '数据完整性正常'
END AS bill_quality_status,
CASE
WHEN d.missing_store > 0 OR d.missing_dish > 0 THEN '菜品明细有缺失字段'
ELSE '菜品明细完整性正常'
END AS dish_quality_status
FROM bill_stats b CROSS JOIN dish_stats d;
-- 店长日卡视图
CREATE OR REPLACE VIEW analytics.v_store_daily_card AS
WITH latest_date AS (
SELECT max(closed_at)::date AS business_date FROM analytics.bill_fact WHERE closed_at IS NOT NULL
),
-- 收入模块
revenue AS (
SELECT bf.store_code, bf.store_name,
'收入' AS module,
jsonb_build_array(
jsonb_build_object('metric', '实收', 'value', round(sum(bf.received_total), 2),
'baseline', round(avg(sw.received), 2), 'is_anomaly',
sum(bf.received_total) < avg(sw.received) * 0.8),
jsonb_build_object('metric', '账单数', 'value', count(*),
'baseline', round(avg(sw.bill_count), 0), 'is_anomaly',
count(*) < avg(sw.bill_count) * 0.8),
jsonb_build_object('metric', '客单价', 'value', round(sum(bf.received_total)/count(*), 2),
'baseline', round(avg(sw.avg_bill), 2), 'is_anomaly',
sum(bf.received_total)/count(*) < avg(sw.avg_bill) * 0.9)
) AS anomalies
FROM analytics.bill_fact bf
CROSS JOIN latest_date ld
LEFT JOIN LATERAL (
SELECT sum(r.received_total) AS received, count(*) AS bill_count,
sum(r.received_total)/count(*) AS avg_bill
FROM analytics.bill_fact r
WHERE r.store_code = bf.store_code
AND r.closed_at::date >= ld.business_date - 28
AND r.closed_at::date < ld.business_date
AND extract(isodow FROM r.closed_at) = extract(isodow FROM ld.business_date)
) sw ON true
WHERE bf.closed_at::date = ld.business_date
GROUP BY bf.store_code, bf.store_name
),
-- 优惠模块
discount AS (
SELECT bf.store_code, bf.store_name,
'优惠' AS module,
jsonb_build_array(
jsonb_build_object('metric', '优惠率', 'value',
round(sum(bf.discount_total)/nullif(sum(bf.consumption), 0) * 100, 2),
'baseline', 20.23, 'is_anomaly',
sum(bf.discount_total)/nullif(sum(bf.consumption), 0) * 100 > 25),
jsonb_build_object('metric', '异常优惠账单', 'value',
count(*) FILTER (WHERE bf.discount_total > bf.consumption AND bf.consumption > 0),
'baseline', 0, 'is_anomaly',
count(*) FILTER (WHERE bf.discount_total > bf.consumption AND bf.consumption > 0) > 5)
) AS anomalies
FROM analytics.bill_fact bf
CROSS JOIN latest_date ld
WHERE bf.closed_at::date = ld.business_date
GROUP BY bf.store_code, bf.store_name
),
-- 风险模块
risk AS (
SELECT bf.store_code, bf.store_name,
'风险' AS module,
jsonb_build_array(
jsonb_build_object('metric', '零实收账单', 'value',
count(*) FILTER (WHERE bf.received_total = 0), 'baseline', 0, 'is_anomaly',
count(*) FILTER (WHERE bf.received_total = 0) > 3),
jsonb_build_object('metric', '撤单/退款', 'value',
count(*) FILTER (WHERE bf.bill_status IN ('撤单', '退款')), 'baseline', 0, 'is_anomaly',
count(*) FILTER (WHERE bf.bill_status IN ('撤单', '退款')) > 2)
) AS anomalies
FROM analytics.bill_fact bf
CROSS JOIN latest_date ld
WHERE bf.closed_at::date = ld.business_date
GROUP BY bf.store_code, bf.store_name
)
SELECT
COALESCE(r.store_code, d.store_code, rk.store_code) AS store_code,
COALESCE(r.store_name, d.store_name, rk.store_name) AS store_name,
COALESCE(r.module, d.module, rk.module) AS module,
COALESCE(r.anomalies, d.anomalies, rk.anomalies) AS anomalies
FROM revenue r
FULL JOIN discount d USING (store_code, store_name)
FULL JOIN risk rk USING (store_code, store_name);
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- 04_init_data.sql
-- 初始化数据:任务模板 + 指标字典 + 门店主数据
-- ============================================================
-- 任务模板
INSERT INTO analytics.task_template (problem_type, problem_indicator, default_action, default_target_adjustment, verification_indicator, suggested_deadline_days) VALUES
('高优惠', '优惠率', '拆解平台折扣和营销方案,每周复核高折扣账单,退出低毛利满减商品', '降低2个百分点', '优惠率达标', 30),
('低毛利', '理论毛利率', '分析商品结构,提升高毛利品类占比,优化套餐组合', '提升1个百分点', '毛利率达标', 30),
('高异常', '异常率', '全额优惠必须填写原因和审批人,周度抽查收银员及营销方案', '降低至1.5%以下', '异常率达标', 30),
('低复购', '会员复购率', '执行消费后第3天和第7天触达,使用会员价和复购券', '提升5个百分点', '复购率达标', 30),
('低客单', '平均客单价', '按午晚市设计主食加小吃、凉菜、饮料的组合销售', '提升1元', '客单价达标', 30),
('标杆输出', '经验输出', '总结核心SKU结构、会员运营、平台价格纪律等可推广经验', '输出至少1个标准化经验模块', '经验文档完成', 30),
('数据修复', '数据口径修复', '核实成本单位与门店映射关系,修正盘点数据口径', '成本口径校验通过', '口径校验通过', 15)
ON CONFLICT (problem_type) DO NOTHING;
-- 指标字典
INSERT INTO analytics.indicator_dictionary (indicator_name, business_definition, formula, data_source, update_frequency, owner, scope, yellow_threshold, red_threshold, version_date) VALUES
('实收', '门店实际收到的金额,扣除优惠后的净收入', 'sum(received_total)', 'bill_fact / c114', '', '财务部', '门店/公司', NULL, NULL, '2026-04-01'),
('客单价', '平均每笔账单的实收金额', 'sum(received_total) / count(*)', 'bill_fact', '', '运营部', '门店/公司', 30, 28, '2026-04-01'),
('优惠率', '优惠总额占消费总额的比例', 'sum(discount_total) / sum(consumption) * 100', 'bill_fact / c068 / c009', '', '运营部', '门店/公司', 22, 25, '2026-04-01'),
('理论毛利率', '理论利润占实收的比例', 'sum(theoretical_profit) / sum(received_total) * 100', 'bill_fact / c181 / c114', '', '财务部', '门店/公司', 70, 68, '2026-04-01'),
('实际成本率', '实际倒挤成本占实收的比例', 'actual_food_cost / received * 100', 'inventory_cost_records + bill_fact', '', '财务部', '门店', 30, 35, '2026-04-01'),
('平台加权成本率', '三平台折扣+佣金占平台实收+折扣+佣金的比例', '(discount + commission) / (received + discount + commission) * 100', 'bill_fact', '', '运营部', '门店/公司', 35, 38, '2026-04-01'),
('复购率', '跨日消费会员占全部识别会员的比例', 'count(DISTINCT member_id FILTER(WHERE cross_day)) / count(DISTINCT member_id) * 100', 'bill_fact', '', '会员部', '门店/公司', 30, 25, '2026-04-01'),
('异常率', '异常账单数占总账单数的比例', 'count(anomaly_bills) / count(all_bills) * 100', 'v_anomaly_bills / bill_fact', '', '运营部', '门店', 1.5, 3, '2026-04-01'),
('库存天数', '库存金额 / 日均成本', 'inventory_amount / (monthly_cost / 30)', 'inventory_cost_records', '', '供应链', '门店/成本单位', 7, 10, '2026-04-01'),
('任务完成率', '已验收任务数占总任务数的比例', 'count(verified_tasks) / count(total_tasks) * 100', 'store_task', '', '运营部', '门店/公司', 80, 60, '2026-04-01')
ON CONFLICT (indicator_name) DO NOTHING;
-- 门店主数据(从现有视图提取)
INSERT INTO analytics.dim_store (store_code, store_name, business_type)
SELECT DISTINCT store_code, store_name,
CASE WHEN store_name ~ '机场|火锅|商城|快手|哈马尔罕' THEN '特殊业态' ELSE '标准门店' END
FROM analytics.v_store_scorecard
ON CONFLICT (store_code) DO UPDATE SET
store_name = EXCLUDED.store_name,
business_type = EXCLUDED.business_type;
+54
View File
@@ -0,0 +1,54 @@
-- 回填 store_task 的 current_value / benchmark_value / target_value
-- 标准7指标从 v_store_monthly_followup 取值
-- 实际成本率/饮品搭售率从 v_store_action_priority_deep_april 取值
UPDATE analytics.store_task t
SET
current_value = src.current_val::numeric,
benchmark_value = src.benchmark_val::numeric,
target_value = src.target_val::numeric
FROM (
SELECT
t.task_id,
CASE t.problem_indicator
WHEN '异常率' THEN f.actual_anomaly_rate::text
WHEN '优惠率' THEN f.actual_discount_rate::text
WHEN '理论毛利率' THEN f.actual_margin_rate::text
WHEN '会员复购率' THEN f.actual_repeat_rate::text
WHEN '平台加权成本率' THEN NULL
WHEN '客单价' THEN f.actual_avg_bill::text
WHEN '实收' THEN f.actual_received::text
WHEN '实际成本率' THEN v.actual_food_cost_rate_pct::text
WHEN '饮品搭售率' THEN v.noodle_drink_attach_pct::text
ELSE NULL
END AS current_val,
CASE t.problem_indicator
WHEN '异常率' THEN f.baseline_anomaly_rate::text
WHEN '优惠率' THEN f.baseline_discount_rate::text
WHEN '理论毛利率' THEN f.baseline_margin_rate::text
WHEN '会员复购率' THEN f.baseline_repeat_rate::text
WHEN '平台加权成本率' THEN f.baseline_meituan_cost_rate::text
WHEN '客单价' THEN f.baseline_avg_bill::text
WHEN '实收' THEN f.baseline_received::text
WHEN '实际成本率' THEN v.theoretical_cost_rate_pct::text
WHEN '饮品搭售率' THEN '15'
ELSE d.yellow_threshold::text
END AS benchmark_val,
CASE t.problem_indicator
WHEN '异常率' THEN f.target_anomaly_rate::text
WHEN '优惠率' THEN f.target_discount_rate::text
WHEN '理论毛利率' THEN f.target_margin_rate::text
WHEN '会员复购率' THEN f.target_repeat_rate::text
WHEN '平台加权成本率' THEN f.target_meituan_cost_rate::text
WHEN '客单价' THEN f.target_avg_bill::text
WHEN '实收' THEN f.target_received::text
WHEN '实际成本率' THEN d.red_threshold::text
WHEN '饮品搭售率' THEN '20'
ELSE d.red_threshold::text
END AS target_val
FROM analytics.store_task t
LEFT JOIN analytics.v_store_monthly_followup f ON f.store_code = t.store_code
LEFT JOIN analytics.v_store_action_priority_deep_april v ON v.store_code = t.store_code
LEFT JOIN analytics.indicator_dictionary d ON d.indicator_name = t.problem_indicator
WHERE t.problem_indicator IN ('异常率','优惠率','理论毛利率','会员复购率','平台加权成本率','实际成本率','饮品搭售率','客单价','实收')
) src
WHERE t.task_id = src.task_id;
+47
View File
@@ -0,0 +1,47 @@
-- 补充回填:v_store_monthly_followup 中值为 NULL 的门店
-- 从 v_store_action_priority_deep_april 和 v_store_risk_rating 补充
UPDATE analytics.store_task t
SET
current_value = src.current_val::numeric,
benchmark_value = src.benchmark_val::numeric,
target_value = src.target_val::numeric
FROM (
SELECT
t.task_id,
CASE t.problem_indicator
WHEN '异常率' THEN COALESCE(f.actual_anomaly_rate, r.anomaly_rate_pct)::text
WHEN '优惠率' THEN COALESCE(f.actual_discount_rate, v.discount_rate_pct)::text
WHEN '理论毛利率' THEN COALESCE(f.actual_margin_rate, v.theoretical_margin_pct)::text
WHEN '会员复购率' THEN COALESCE(f.actual_repeat_rate, v.repeat_rate_pct)::text
WHEN '实际成本率' THEN v.actual_food_cost_rate_pct::text
WHEN '饮品搭售率' THEN v.noodle_drink_attach_pct::text
ELSE NULL
END AS current_val,
CASE t.problem_indicator
WHEN '异常率' THEN COALESCE(f.baseline_anomaly_rate, d.yellow_threshold)::text
WHEN '优惠率' THEN COALESCE(f.baseline_discount_rate, d.yellow_threshold)::text
WHEN '理论毛利率' THEN COALESCE(f.baseline_margin_rate, d.yellow_threshold)::text
WHEN '会员复购率' THEN COALESCE(f.baseline_repeat_rate, d.yellow_threshold)::text
WHEN '实际成本率' THEN v.theoretical_cost_rate_pct::text
WHEN '饮品搭售率' THEN '15'
ELSE d.yellow_threshold::text
END AS benchmark_val,
CASE t.problem_indicator
WHEN '异常率' THEN COALESCE(f.target_anomaly_rate, d.red_threshold)::text
WHEN '优惠率' THEN COALESCE(f.target_discount_rate, d.red_threshold)::text
WHEN '理论毛利率' THEN COALESCE(f.target_margin_rate, d.red_threshold)::text
WHEN '会员复购率' THEN COALESCE(f.target_repeat_rate, d.red_threshold)::text
WHEN '实际成本率' THEN d.red_threshold::text
WHEN '饮品搭售率' THEN '20'
ELSE d.red_threshold::text
END AS target_val
FROM analytics.store_task t
LEFT JOIN analytics.v_store_monthly_followup f ON f.store_code = t.store_code
LEFT JOIN analytics.v_store_action_priority_deep_april v ON v.store_code = t.store_code
LEFT JOIN analytics.v_store_risk_rating r ON r.store_code = t.store_code
LEFT JOIN analytics.indicator_dictionary d ON d.indicator_name = t.problem_indicator
WHERE t.current_value IS NULL
AND t.problem_indicator IN ('异常率','优惠率','理论毛利率','会员复购率','实际成本率','饮品搭售率')
) src
WHERE t.task_id = src.task_id
AND src.current_val IS NOT NULL;
+78
View File
@@ -0,0 +1,78 @@
-- 模拟闭环流程:
-- 1. 将部分任务推进到"进行中"(模拟店长已提交执行反馈)
-- 2. 对部分"进行中"任务执行月度验收
-- 3. 生成 task_monthly_review 数据
-- Step 1: 将前60条任务推进到"进行中"(模拟执行反馈)
UPDATE analytics.store_task
SET status = '进行中', process_evidence = '已按行动要求执行,提交过程证据', updated_at = NOW()
WHERE task_id IN (
SELECT task_id FROM analytics.store_task WHERE status = '待启动' ORDER BY task_id LIMIT 60
);
-- 记录执行日志
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
SELECT task_id, '执行反馈', '进行中', '店长', '已按行动要求执行'
FROM analytics.store_task WHERE status = '进行中';
-- Step 2: 对"进行中"的任务执行月度验收
-- 达标:current_value 已优于 target_value
INSERT INTO analytics.task_monthly_review
(task_id, store_code, store_name, plan_month, problem_indicator,
baseline_value, target_value, actual_value, review_result,
revenue_stable, margin_improved, customer_stable, anomaly_decreased)
SELECT
t.task_id, t.store_code, t.store_name, t.plan_month, t.problem_indicator,
t.benchmark_value, t.target_value,
t.current_value,
CASE
WHEN t.current_value IS NOT NULL AND t.target_value IS NOT NULL AND t.current_value <= t.target_value THEN '达标'
WHEN t.current_value IS NOT NULL AND t.benchmark_value IS NOT NULL AND t.current_value < t.benchmark_value THEN '改善中'
ELSE '未改善'
END,
true,
CASE
WHEN t.current_value IS NOT NULL AND t.target_value IS NOT NULL AND t.current_value <= t.target_value THEN true
ELSE false
END,
true,
CASE
WHEN t.current_value IS NOT NULL AND t.benchmark_value IS NOT NULL AND t.current_value < t.benchmark_value THEN true
ELSE false
END
FROM analytics.store_task t
WHERE t.status = '进行中';
-- Step 3: 更新任务状态为"已验收"
UPDATE analytics.store_task t
SET status = '已验收',
verification_result = r.review_result,
updated_at = NOW()
FROM analytics.task_monthly_review r
WHERE t.task_id = r.task_id;
-- 记录验收日志
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
SELECT task_id, '月度验收', '已验收', '区域经理', review_result
FROM analytics.task_monthly_review;
-- Step 4: 创建1条标准化经验(达标门店)
INSERT INTO analytics.best_practice
(source_store_code, source_store_name, practice_title, practice_category, description, indicators, status)
SELECT
t.store_code, t.store_name,
t.problem_indicator || '改善经验',
CASE t.problem_indicator
WHEN '异常率' THEN '运营规范'
WHEN '优惠率' THEN '价格管理'
WHEN '理论毛利率' THEN '商品结构'
WHEN '会员复购率' THEN '会员运营'
ELSE '综合管理'
END,
'通过' || t.action_required || '实现' || t.problem_indicator || '' || t.benchmark_value || '改善至' || t.current_value,
t.problem_indicator,
'待推广'
FROM analytics.task_monthly_review r
JOIN analytics.store_task t ON t.task_id = r.task_id
WHERE r.review_result = '达标'
LIMIT 3;
+31
View File
@@ -0,0 +1,31 @@
-- 修正 review_result:区分指标方向(越高越好 vs 越低越好)
UPDATE analytics.task_monthly_review r
SET review_result = CASE
-- 越高越好的指标
WHEN r.problem_indicator IN ('理论毛利率','会员复购率','饮品搭售率','实收','客单价') THEN
CASE
WHEN r.actual_value >= r.target_value THEN '达标'
WHEN r.actual_value > r.baseline_value THEN '改善中'
ELSE '未改善'
END
-- 越低越好的指标
ELSE
CASE
WHEN r.actual_value <= r.target_value THEN '达标'
WHEN r.actual_value < r.baseline_value THEN '改善中'
ELSE '未改善'
END
END
WHERE r.actual_value IS NOT NULL;
-- 同步更新 store_task
UPDATE analytics.store_task t
SET verification_result = r.review_result
FROM analytics.task_monthly_review r
WHERE t.task_id = r.task_id;
-- 更新日志
UPDATE analytics.store_task_log
SET comment = r.review_result
FROM analytics.task_monthly_review r
WHERE store_task_log.task_id = r.task_id AND action = '月度验收';
+48
View File
@@ -0,0 +1,48 @@
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'bill_query',
user: process.env.DB_USER || 'freedak',
password: process.env.DB_PASSWORD || '',
max: parseInt(process.env.DB_POOL_MAX || '10'),
})
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err)
})
export interface QueryResult<T = any> {
rows: T[]
rowCount: number | null
}
export async function query<T = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
const start = Date.now()
const res = await pool.query(text, params)
const duration = Date.now() - start
if (duration > 500) {
console.warn(`Slow query (${duration}ms):`, text.substring(0, 100))
}
return res
}
export async function withTransaction<T>(callback: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
await client.query('BEGIN')
const result = await callback(client)
await client.query('COMMIT')
return result
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
export default pool
+42
View File
@@ -0,0 +1,42 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import { authMiddleware, AuthRequest } from './middleware/auth.js'
import { errorHandler, notFoundHandler } from './middleware/error.js'
import authRoutes from './routes/auth.js'
import dataRoutes from './routes/data.js'
import taskRoutes from './routes/tasks.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3001')
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true,
}))
app.use(express.json())
app.get('/api/health', (req, res) => {
res.json({ success: true, data: { status: 'ok', time: new Date().toISOString() } })
})
app.use('/api/auth', authRoutes)
app.use((req, res, next) => {
if (req.path === '/api/health' || req.path.startsWith('/api/auth')) {
return next()
}
authMiddleware(req as AuthRequest, res, next)
})
app.use('/api', dataRoutes)
app.use('/api/tasks', taskRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
export default app
+63
View File
@@ -0,0 +1,63 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import type { AuthUser, ApiResponse } from '../types/index.js'
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret'
export interface AuthRequest extends Request {
user?: AuthUser
}
export function generateToken(user: AuthUser): string {
return jwt.sign(user, JWT_SECRET, { expiresIn: '7d' })
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
const response: ApiResponse = { success: false, data: null, error: 'No token provided' }
return res.status(401).json(response)
}
const token = authHeader.substring(7)
try {
const decoded = jwt.verify(token, JWT_SECRET) as AuthUser
req.user = decoded
next()
} catch {
const response: ApiResponse = { success: false, data: null, error: 'Invalid or expired token' }
return res.status(401).json(response)
}
}
export function requireRole(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
const response: ApiResponse = { success: false, data: null, error: 'Not authenticated' }
return res.status(401).json(response)
}
if (!roles.includes(req.user.role)) {
const response: ApiResponse = { success: false, data: null, error: 'Insufficient permissions' }
return res.status(403).json(response)
}
next()
}
}
export function requireStoreAccess(req: AuthRequest, res: Response, next: NextFunction) {
if (!req.user) {
const response: ApiResponse = { success: false, data: null, error: 'Not authenticated' }
return res.status(401).json(response)
}
if (req.user.role === 'hq' || req.user.role === 'dept') {
return next()
}
if (req.user.role === 'store') {
const storeCode = req.params.code || req.params.storeCode || req.body.store_code
if (storeCode && storeCode !== req.user.storeCode) {
const response: ApiResponse = { success: false, data: null, error: 'Access denied to this store' }
return res.status(403).json(response)
}
}
next()
}
+43
View File
@@ -0,0 +1,43 @@
import { Request, Response, NextFunction } from 'express'
import type { ApiResponse } from '../types/index.js'
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
console.error('Error:', err.message)
const response: ApiResponse = {
success: false,
data: null,
error: err.message || 'Internal server error',
}
res.status(500).json(response)
}
export function notFoundHandler(req: Request, res: Response) {
const response: ApiResponse = {
success: false,
data: null,
error: `Route not found: ${req.method} ${req.path}`,
}
res.status(404).json(response)
}
export function sendSuccess<T>(res: Response, data: T, meta?: any) {
const response: ApiResponse<T> = { success: true, data, meta }
res.json(response)
}
export function sendError(res: Response, error: string, status = 400) {
const response: ApiResponse = { success: false, data: null, error }
res.status(status).json(response)
}
export function parseMonth(req: Request): string {
const month = (req.query.month as string) || '2026-04'
return month.length === 7 ? `${month}-01` : month
}
export function parsePagination(req: Request) {
const page = parseInt((req.query.page as string) || '1')
const pageSize = parseInt((req.query.page_size as string) || '50')
const offset = (page - 1) * pageSize
return { page, pageSize, offset }
}
+36
View File
@@ -0,0 +1,36 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import { generateToken } from '../middleware/auth.js'
import type { AuthUser } from '../types/index.js'
const router = Router()
const mockUsers: AuthUser[] = [
{ id: '1', role: 'hq', name: '总部管理员' },
{ id: '2', role: 'regional', name: '区域经理', region: '北京' },
{ id: '3', role: 'store', name: '潘家园店长', storeCode: '0026' },
{ id: '4', role: 'dept', name: '商品部', dept: 'product' },
]
router.post('/login', async (req, res) => {
const { username, password } = req.body
if (!username || !password) {
return sendError(res, 'Username and password required')
}
const user = mockUsers.find((u) => u.name === username || u.id === username)
if (!user) {
return sendError(res, 'Invalid credentials', 401)
}
const token = generateToken(user)
sendSuccess(res, { token, user })
})
router.get('/me', async (req: any, res) => {
if (!req.user) {
return sendError(res, 'Not authenticated', 401)
}
sendSuccess(res, req.user)
})
export default router
+376
View File
@@ -0,0 +1,376 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const sql = `
SELECT count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / nullif(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct,
count(*) FILTER (WHERE member_id IS NOT NULL) AS member_bills,
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_share_pct
FROM analytics.bill_fact
WHERE closed_at >= $1::date
AND closed_at < ($1::date + interval '1 month')
AND closed_at IS NOT NULL
`
const result = await query(sql, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/overview/daily', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const sql = `
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.bill_fact
WHERE closed_at >= $1::date
AND closed_at < ($1::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
`
const result = await query(sql, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const riskLevel = req.query.risk_level as string
const quadrant = req.query.quadrant as string
let sql = `SELECT * FROM analytics.v_store_scorecard`
const params: any[] = []
const conditions: string[] = []
if (riskLevel) {
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.v_store_risk_rating r ON s.store_code = r.store_code
WHERE r.risk_level = $1`
params.push(riskLevel)
}
if (quadrant) {
if (params.length > 0) {
conditions.push(`b.management_quadrant = $${params.length + 1}`)
sql = sql.replace('FROM analytics.v_store_scorecard s', 'FROM analytics.v_store_scorecard s JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code')
} else {
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code
WHERE b.management_quadrant = $1`
params.push(quadrant)
}
}
sql += ` ORDER BY received DESC NULLS LAST`
const countResult = await query(`SELECT count(*) AS total FROM (${sql}) t`, params)
sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
params.push(pageSize, offset)
const result = await query(sql, params)
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/risk', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_risk_rating ORDER BY risk_level, received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/priority', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT store_code, store_name, action_priority, problem_count, problem_combination,
received, scale_tier, business_type
FROM analytics.cache_store_priority
ORDER BY CASE action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
WHEN 'P1-重点整改' THEN 3
WHEN 'P2-单项改善' THEN 4
WHEN '标杆候选' THEN 5
ELSE 6
END, received DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/quadrant', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_benchmark ORDER BY management_quadrant, avg_daily_received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const scorecard = await query(`SELECT * FROM analytics.v_store_scorecard WHERE store_code = $1`, [code])
const risk = await query(`SELECT * FROM analytics.v_store_risk_rating WHERE store_code = $1`, [code])
const platform = await query(`SELECT * FROM analytics.v_store_platform_economics WHERE store_code = $1`, [code])
const benchmark = await query(`SELECT * FROM analytics.v_store_benchmark WHERE store_code = $1`, [code])
const action = await query(`SELECT * FROM analytics.v_store_action_list WHERE store_code = $1`, [code])
const execution = await query(`SELECT * FROM analytics.v_store_execution_priority WHERE store_code = $1`, [code])
if (scorecard.rows.length === 0) {
return sendError(res, 'Store not found', 404)
}
sendSuccess(res, {
scorecard: scorecard.rows[0],
risk: risk.rows[0],
platform: platform.rows[0],
benchmark: benchmark.rows[0],
action: action.rows[0],
execution: execution.rows[0],
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.bill_fact
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
`, [code, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_theoretical_actual_cost_april ORDER BY variance_to_theoretical_pct DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_category_cost_benchmark_april ORDER BY store_code`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/inventory', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_inventory_efficiency_april ORDER BY estimated_inventory_days DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/platform/economics', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_platform_economics ORDER BY meituan_received DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_member_comparison`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/repeat', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly ORDER BY repeat_rate_pct DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/abc', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_dish_sku_abc_april ORDER BY cumulative_revenue_share`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/category', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.category_summary ORDER BY amount DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/attach', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.dish_pair_summary_april ORDER BY pair_count DESC LIMIT 50`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/anomaly', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills`)
const result = await query(`SELECT * FROM analytics.v_anomaly_bills ORDER BY closed_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/zero-received', async (req: AuthRequest, res) => {
try {
const storeCode = req.query.store_code as string
let sql = `SELECT * FROM analytics.v_zero_received_detail`
const params: any[] = []
if (storeCode) {
sql += ` WHERE store_code = $1`
params.push(storeCode)
}
sql += ` ORDER BY closed_at DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_cashier_risk ORDER BY anomaly_rate_pct DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/benchmark/composite', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_benchmark_composite ORDER BY benchmark_score DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/channel', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const billStats = await query(`
SELECT
count(*) AS total_bills,
count(*) FILTER (WHERE bill_no IS NULL OR bill_no = '') AS missing_bill_no,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store_code,
count(*) FILTER (WHERE consumption = 0 OR consumption IS NULL) AS zero_consumption,
count(*) FILTER (WHERE received_total < 0) AS negative_received,
count(DISTINCT store_code) AS store_count,
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.bill_fact
`)
const dishStats = await query(`
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS dish_missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS dish_missing_dish
FROM public.dish_sales_details
`)
const result = { ...billStats.rows[0], ...dishStats.rows[0] }
sendSuccess(res, result)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+571
View File
@@ -0,0 +1,571 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 固定路径路由(必须在 /:id 之前定义)
// ============================================================
router.get('/', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const priority = req.query.priority as string
const status = req.query.status as string
const storeCode = req.query.store_code as string
const conditions: string[] = [`plan_month = $1`]
const params: any[] = [month]
let paramIdx = 2
if (priority) {
conditions.push(`priority = $${paramIdx++}`)
params.push(priority)
}
if (status) {
conditions.push(`status = $${paramIdx++}`)
params.push(status)
}
if (storeCode) {
conditions.push(`store_code = $${paramIdx++}`)
params.push(storeCode)
}
const where = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total FROM analytics.store_task WHERE ${where}`, params)
const result = await query(
`SELECT * FROM analytics.store_task WHERE ${where} ORDER BY
CASE priority WHEN 'P0-修复数据口径' THEN 1 WHEN 'P0-综合专项整改' THEN 2 WHEN 'P1' THEN 3 WHEN 'P2' THEN 4 ELSE 5 END,
deadline ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
[...params, pageSize, offset]
)
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/', async (req: AuthRequest, res) => {
try {
const b = req.body
const result = await withTransaction(async (client) => {
const taskResult = await client.query(`
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
current_value, benchmark_value, target_value, problem_description,
action_required, owner, collaborators, deadline,
verification_indicator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING task_id
`, [b.plan_month, b.store_code, b.store_name, b.priority, b.problem_indicator,
b.current_value, b.benchmark_value, b.target_value, b.problem_description,
b.action_required, b.owner, b.collaborators, b.deadline, b.verification_indicator])
const taskId = taskResult.rows[0].task_id
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '创建', '待启动', $2, $3)
`, [taskId, req.user?.name || 'system', b.problem_description])
return taskId
})
sendSuccess(res, { task_id: result })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/auto-generate', async (req: AuthRequest, res) => {
try {
const month = req.body.month ? (req.body.month.length === 7 ? `${req.body.month}-01` : req.body.month) : '2026-05-01'
const result = await query(`SELECT * FROM analytics.f_generate_store_tasks($1)`, [month])
sendSuccess(res, result.rows[0] || { generated: 0, message: 'No tasks generated' })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/weekly-check', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.v_task_weekly_check WHERE plan_month = $1 ORDER BY consecutive_no_improve_weeks DESC, store_code`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/monthly-review', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.v_task_monthly_review WHERE plan_month = $1 ORDER BY store_code`, [month])
const summary = {
total: result.rows.length,
passed: result.rows.filter((r: any) => r.review_result === '达标').length,
improving: result.rows.filter((r: any) => r.review_result === '改善中').length,
failed: result.rows.filter((r: any) => r.review_result === '未改善').length,
}
sendSuccess(res, { summary, details: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/followup', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_monthly_followup ORDER BY priority, store_code`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/grade-change', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.store_grade_change ORDER BY change_month DESC, store_code`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/practices', async (req: AuthRequest, res) => {
try {
const mod = req.query.module as string
const status = req.query.status as string
let sql = `SELECT * FROM analytics.standardized_practice`
const params: any[] = []
const conditions: string[] = []
if (mod) {
conditions.push(`practice_module = $${params.length + 1}`)
params.push(mod)
}
if (status) {
conditions.push(`status = $${params.length + 1}`)
params.push(status)
}
if (conditions.length > 0) {
sql += ` WHERE ` + conditions.join(' AND ')
}
sql += ` ORDER BY created_at DESC`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/practices', async (req: AuthRequest, res) => {
try {
const b = req.body
const result = await query(`
INSERT INTO analytics.standardized_practice
(practice_module, benchmark_store_code, benchmark_store_name, key_actions, verification_indicators)
VALUES ($1, $2, $3, $4, $5) RETURNING id
`, [b.practice_module, b.benchmark_store_code, b.benchmark_store_name, b.key_actions, b.verification_indicators])
sendSuccess(res, { id: result.rows[0].id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/practices/:id/replication-result', async (req: AuthRequest, res) => {
try {
const practiceId = parseInt(req.params.id)
const result = await query(`SELECT * FROM analytics.practice_replication WHERE practice_id = $1 ORDER BY id`, [practiceId])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/practices/:id/replicate', async (req: AuthRequest, res) => {
try {
const practiceId = parseInt(req.params.id)
const b = req.body
const result = await query(`
INSERT INTO analytics.practice_replication
(practice_id, trial_store_code, trial_store_name, observation_weeks, before_value, status)
VALUES ($1, $2, $3, $4, $5, '观察中') RETURNING id
`, [practiceId, b.trial_store_code, b.trial_store_name, b.observation_weeks || 4, b.before_value])
sendSuccess(res, { id: result.rows[0].id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/practices/:id/promote', async (req: AuthRequest, res) => {
try {
const practiceId = parseInt(req.params.id)
await query(`UPDATE analytics.standardized_practice SET status = '已推广' WHERE id = $1`, [practiceId])
sendSuccess(res, { id: practiceId, status: '已推广' })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/indicators', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.indicator_dictionary ORDER BY id`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/indicators', async (req: AuthRequest, res) => {
try {
const b = req.body
const result = await query(`
INSERT INTO analytics.indicator_dictionary
(indicator_name, business_definition, formula, data_source, update_frequency,
owner, scope, yellow_threshold, red_threshold, version_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (indicator_name) DO UPDATE SET
business_definition = EXCLUDED.business_definition,
formula = EXCLUDED.formula,
data_source = EXCLUDED.data_source,
update_frequency = EXCLUDED.update_frequency,
owner = EXCLUDED.owner,
scope = EXCLUDED.scope,
yellow_threshold = EXCLUDED.yellow_threshold,
red_threshold = EXCLUDED.red_threshold,
version_date = EXCLUDED.version_date
RETURNING id
`, [b.indicator_name, b.business_definition, b.formula, b.data_source,
b.update_frequency, b.owner, b.scope, b.yellow_threshold, b.red_threshold,
b.version_date || new Date().toISOString().substring(0, 10)])
sendSuccess(res, { id: result.rows[0].id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/loop-health', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_loop_health`)
sendSuccess(res, result.rows[0] || {
task_generation_rate: 0,
store_execution_rate: 0,
weekly_check_rate: 0,
monthly_review_rate: 0,
practice_promotion_rate: 0,
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/notifications/dispatch', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.f_dispatch_daily_notifications()`)
sendSuccess(res, result.rows[0] || { dispatched: 0 })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const result = await query(`SELECT * FROM analytics.v_store_daily_card WHERE store_code = $1 ORDER BY module`, [code])
const tasks = await query(`
SELECT t.* FROM analytics.store_task t
WHERE t.store_code = $1 AND t.status IN ('待启动', '进行中')
ORDER BY t.priority LIMIT 3
`, [code])
sendSuccess(res, { anomalies: result.rows, todos: tasks.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 动态路径路由 /:id(必须在所有固定路径之后)
// ============================================================
router.get('/:id', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
if (isNaN(id)) {
return sendError(res, 'Invalid task id', 400)
}
const result = await query(`SELECT * FROM analytics.store_task WHERE task_id = $1`, [id])
if (result.rows.length === 0) {
return sendError(res, 'Task not found', 404)
}
const logs = await query(`SELECT * FROM analytics.store_task_log WHERE task_id = $1 ORDER BY created_at`, [id])
sendSuccess(res, { task: result.rows[0], logs: logs.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
const fields = ['status', 'process_evidence', 'verification_result', 'incomplete_reason', 'next_step', 'owner', 'collaborators', 'deadline', 'action_required']
const updates: string[] = []
const params: any[] = []
let idx = 1
for (const f of fields) {
if (b[f] !== undefined) {
updates.push(`${f} = $${idx++}`)
params.push(b[f])
}
}
if (updates.length === 0) {
return sendError(res, 'No fields to update')
}
updates.push(`updated_at = NOW()`)
params.push(id)
await query(`UPDATE analytics.store_task SET ${updates.join(', ')} WHERE task_id = $${idx}`, params)
await query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '更新', $2, $3, $4)
`, [id, b.status || null, req.user?.name || 'system', b.next_step || null])
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id/execute', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task
SET status = '进行中', process_evidence = $1, action_required = $2,
incomplete_reason = $3, next_step = $4, updated_at = NOW()
WHERE task_id = $5
`, [b.process_evidence, b.action_required, b.incomplete_reason, b.next_step, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, old_status, new_status, operator, comment)
VALUES ($1, '执行反馈', '待启动', '进行中', $2, $3)
`, [id, req.user?.name || 'store', b.process_evidence])
})
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id/weekly-check', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task
SET process_evidence = COALESCE($1, process_evidence),
next_step = $2, updated_at = NOW()
WHERE task_id = $3
`, [b.check_evidence, b.next_step, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '周度检查', NULL, $2, $3)
`, [id, req.user?.name || 'regional', b.check_comment])
const task = await client.query(`SELECT store_code, store_name, problem_indicator, plan_month FROM analytics.store_task WHERE task_id = $1`, [id])
if (task.rows.length > 0) {
const t = task.rows[0]
await client.query(`
INSERT INTO analytics.task_weekly_check
(task_id, store_code, store_name, problem_indicator, iso_week,
this_week_value, last_week_value, change_direction,
consecutive_no_improve_weeks, check_comment, checked_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, [id, t.store_code, t.store_name, t.problem_indicator, b.iso_week,
b.this_week_value, b.last_week_value, b.change_direction,
b.consecutive_no_improve_weeks || 0, b.check_comment, req.user?.name])
}
})
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id/verify', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
const reviewResult = b.review_result || '改善中'
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task
SET status = CASE WHEN $1 = '达标' THEN '已验收'
WHEN $1 = '未改善' THEN '已验收'
ELSE '进行中' END,
verification_result = $1,
verification_indicator = $2,
incomplete_reason = $3,
next_step = $4,
updated_at = NOW()
WHERE task_id = $5
`, [reviewResult, b.verification_indicator, b.incomplete_reason, b.next_step, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '月度验收', $2, $3, $4)
`, [id, reviewResult, req.user?.name || 'hq', JSON.stringify({
revenue_stable: b.revenue_stable,
margin_improved: b.margin_improved,
customer_stable: b.customer_stable,
anomaly_decreased: b.anomaly_decreased,
})])
const task = await client.query(`SELECT store_code, store_name, problem_indicator, plan_month, baseline_value, target_value FROM analytics.store_task WHERE task_id = $1`, [id])
if (task.rows.length > 0) {
const t = task.rows[0]
await client.query(`
INSERT INTO analytics.task_monthly_review
(task_id, store_code, store_name, plan_month, problem_indicator,
baseline_value, target_value, actual_value, review_result,
revenue_stable, margin_improved, customer_stable, anomaly_decreased)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`, [id, t.store_code, t.store_name, t.plan_month, t.problem_indicator,
t.baseline_value, t.target_value, b.actual_value, reviewResult,
b.revenue_stable || false, b.margin_improved || false,
b.customer_stable || false, b.anomaly_decreased || false])
}
})
sendSuccess(res, { task_id: id, review_result: reviewResult })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/:id/rollback', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const reason = req.body.reason
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task SET status = '已回滚', incomplete_reason = $1, updated_at = NOW()
WHERE task_id = $2
`, [reason, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '回滚', '已回滚', $2, $3)
`, [id, req.user?.name || 'hq', reason])
})
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 本体标准 (Ontology Standard)
// ============================================================
router.get('/ontology/overview', async (req: AuthRequest, res) => {
try {
const dims = await query(`
SELECT table_name,
(SELECT count(*) FROM information_schema.columns WHERE table_schema='analytics' AND table_name=t.table_name) AS column_count,
(SELECT reltuples::bigint FROM pg_class WHERE relname=t.table_name) AS row_count
FROM information_schema.tables t
WHERE t.table_schema='analytics' AND t.table_name LIKE 'dim\_%'
ORDER BY t.table_name
`)
const facts = await query(`
SELECT table_name,
(SELECT count(*) FROM information_schema.columns WHERE table_schema='analytics' AND table_name=t.table_name) AS column_count,
(SELECT reltuples::bigint FROM pg_class WHERE relname=t.table_name) AS row_count
FROM information_schema.tables t
WHERE t.table_schema='analytics' AND t.table_name LIKE 'fact\_%'
ORDER BY t.table_name
`)
const enums = await query(`
SELECT table_name,
(SELECT count(*) FROM information_schema.columns WHERE table_schema='analytics' AND table_name=t.table_name) AS column_count,
(SELECT reltuples::bigint FROM pg_class WHERE relname=t.table_name) AS row_count
FROM information_schema.tables t
WHERE t.table_schema='analytics' AND t.table_name LIKE 'enum\_%'
ORDER BY t.table_name
`)
const metrics = await query(`
SELECT count(*) AS total,
count(*) FILTER (WHERE metric_category IS NOT NULL) AS standardized,
count(*) FILTER (WHERE is_active = true) AS active
FROM analytics.indicator_dictionary
`)
sendSuccess(res, {
dimensions: dims.rows,
facts: facts.rows,
enums: enums.rows,
metrics: metrics.rows[0],
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/dim/:table', async (req: AuthRequest, res) => {
try {
const tableName = req.params.table
if (!/^dim_\w+$/.test(tableName)) { sendError(res, 'Invalid table name'); return }
const cols = await query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema='analytics' AND table_name=$1
ORDER BY ordinal_position
`, [tableName])
const rows = await query(`SELECT * FROM analytics.${tableName} LIMIT 100`)
sendSuccess(res, { columns: cols.rows, rows: rows.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/fact/:table', async (req: AuthRequest, res) => {
try {
const tableName = req.params.table
if (!/^fact_\w+$/.test(tableName)) { sendError(res, 'Invalid table name'); return }
const cols = await query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema='analytics' AND table_name=$1
ORDER BY ordinal_position
`, [tableName])
const count = await query(`SELECT count(*) AS cnt FROM analytics.${tableName}`)
sendSuccess(res, { columns: cols.rows, total: count.rows[0].cnt })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/enum/:table', async (req: AuthRequest, res) => {
try {
const tableName = req.params.table
if (!/^enum_\w+$/.test(tableName)) { sendError(res, 'Invalid table name'); return }
const rows = await query(`SELECT * FROM analytics.${tableName} ORDER BY sort_order`)
sendSuccess(res, rows.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/metrics', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT * FROM analytics.indicator_dictionary
ORDER BY metric_category NULLS LAST, indicator_name
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+154
View File
@@ -0,0 +1,154 @@
export type UserRole = 'hq' | 'regional' | 'store' | 'dept'
export interface AuthUser {
id: string
role: UserRole
storeCode?: string
region?: string
dept?: string
name: string
}
export interface ApiResponse<T = any> {
success: boolean
data: T
meta?: {
total?: number
page?: number
page_size?: number
}
error?: string
}
export interface StoreScorecard {
store_code: string
store_name: string
bill_count: number
active_days: number
received: number
avg_daily_received: number
avg_bill_value: number
avg_guest_value: number
discount_rate_pct: number
theoretical_margin_pct: number
member_bill_share_pct: number
}
export interface StoreRiskRating extends StoreScorecard {
anomaly_rate_pct: number
risk_level: string
primary_issue: string
}
export interface StorePriority {
store_code: string
store_name: string
action_priority: string
problem_count: number
problem_combination: string
received: number
scale_tier: string
business_type: string
}
export interface StoreTask {
task_id: number
plan_month: string
store_code: string
store_name: string
priority: string
problem_indicator: string
current_value: number | null
benchmark_value: number | null
target_value: number | null
problem_description: string | null
action_required: string | null
owner: string
collaborators: string | null
deadline: string
status: string
process_evidence: string | null
verification_indicator: string | null
verification_result: string | null
incomplete_reason: string | null
next_step: string | null
created_at: string
updated_at: string
}
export interface WeeklyCheck {
task_id: number
store_code: string
store_name: string
iso_week: number
this_week_value: number | null
last_week_value: number | null
change_direction: string
consecutive_no_improve_weeks: number
check_comment: string | null
checked_by: string | null
checked_at: string | null
}
export interface MonthlyReview {
task_id: number
store_code: string
store_name: string
priority: string
problem_indicator: string
baseline_value: number | null
target_value: number | null
actual_value: number | null
review_result: string
revenue_stable: boolean
margin_improved: boolean
customer_stable: boolean
anomaly_decreased: boolean
}
export interface LoopHealth {
task_generation_rate: number
store_execution_rate: number
weekly_check_rate: number
monthly_review_rate: number
practice_promotion_rate: number
}
export interface IndicatorDict {
id: number
indicator_name: string
business_definition: string
formula: string
data_source: string
update_frequency: string
owner: string
scope: string
yellow_threshold: number | null
red_threshold: number | null
version_date: string
}
export interface StandardizedPractice {
id: number
practice_module: string
benchmark_store_code: string
benchmark_store_name: string
key_actions: string
verification_indicators: string
status: string
created_at: string
}
export interface PracticeReplication {
id: number
practice_id: number
trial_store_code: string
trial_store_name: string
observation_weeks: number
before_value: number | null
after_value: number | null
revenue_impacted: boolean
customer_impacted: boolean
inventory_impacted: boolean
status: string
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+154
View File
@@ -0,0 +1,154 @@
export type UserRole = 'hq' | 'regional' | 'store' | 'dept'
export interface AuthUser {
id: string
role: UserRole
storeCode?: string
region?: string
dept?: string
name: string
}
export interface ApiResponse<T = any> {
success: boolean
data: T
meta?: {
total?: number
page?: number
page_size?: number
}
error?: string
}
export interface StoreScorecard {
store_code: string
store_name: string
bill_count: number
active_days: number
received: number
avg_daily_received: number
avg_bill_value: number
avg_guest_value: number
discount_rate_pct: number
theoretical_margin_pct: number
member_bill_share_pct: number
}
export interface StoreRiskRating extends StoreScorecard {
anomaly_rate_pct: number
risk_level: '红色' | '黄色' | '绿色'
primary_issue: string
}
export interface StorePriority {
store_code: string
store_name: string
action_priority: string
problem_count: number
problem_combination: string
received: number
scale_tier: string
business_type: string
}
export interface StoreTask {
task_id: number
plan_month: string
store_code: string
store_name: string
priority: string
problem_indicator: string
current_value: number | null
benchmark_value: number | null
target_value: number | null
problem_description: string | null
action_required: string | null
owner: string
collaborators: string | null
deadline: string
status: string
process_evidence: string | null
verification_indicator: string | null
verification_result: string | null
incomplete_reason: string | null
next_step: string | null
created_at: string
updated_at: string
}
export interface WeeklyCheck {
task_id: number
store_code: string
store_name: string
iso_week: number
this_week_value: number | null
last_week_value: number | null
change_direction: 'up' | 'down' | 'flat' | 'unknown'
consecutive_no_improve_weeks: number
check_comment: string | null
checked_by: string | null
checked_at: string | null
}
export interface MonthlyReview {
task_id: number
store_code: string
store_name: string
priority: string
problem_indicator: string
baseline_value: number | null
target_value: number | null
actual_value: number | null
review_result: '达标' | '改善中' | '未改善'
revenue_stable: boolean
margin_improved: boolean
customer_stable: boolean
anomaly_decreased: boolean
}
export interface LoopHealth {
task_generation_rate: number
store_execution_rate: number
weekly_check_rate: number
monthly_review_rate: number
practice_promotion_rate: number
}
export interface IndicatorDict {
id: number
indicator_name: string
business_definition: string
formula: string
data_source: string
update_frequency: string
owner: string
scope: string
yellow_threshold: number | null
red_threshold: number | null
version_date: string
}
export interface StandardizedPractice {
id: number
practice_module: string
benchmark_store_code: string
benchmark_store_name: string
key_actions: string
verification_indicators: string
status: string
created_at: string
}
export interface PracticeReplication {
id: number
practice_id: number
trial_store_code: string
trial_store_name: string
observation_weeks: number
before_value: number | null
after_value: number | null
revenue_impacted: boolean
customer_impacted: boolean
inventory_impacted: boolean
status: string
}
+317
View File
@@ -0,0 +1,317 @@
# 项目任务清单 — 连锁餐饮数字化运营管理平台
> 闭环目标:数据发现问题 → 自动分级 → 生成门店任务 → 现场执行 → 周度检查 → 月度验收 → 有效经验标准化
> 基线数据:2026年4月 | 91家门店 | 167.8万账单 | PostgreSQL `bill_query` @ localhost:5432
---
## 进度图例
- [ ] 未开始
- [~] 进行中
- [x] 已完成
- [!] 有阻塞
---
## Phase 0:项目初始化
- [x] 0.1 创建 monorepo 结构(server/ + client/ + shared/
- [x] 0.2 初始化 server/package.json + TypeScript 配置
- [x] 0.3 初始化 client/package.json + Vite + TypeScript 配置
- [x] 0.4 安装后端依赖(express, pg, cors, jsonwebtoken, dotenv, tsx
- [x] 0.5 安装前端依赖(react, react-router-dom, @tanstack/react-query, tailwindcss, recharts, lucide-react
- [x] 0.6 配置 TailwindCSS
- [x] 0.7 创建 server/.env 环境变量配置
- [x] 0.8 验证后端可连接 PostgreSQL
## Phase 1:数据层 — 任务闭环表与自动分级
- [x] 1.1 创建 `analytics.store_task` 表(门店整改任务,含§7.2全部字段:问题门店/指标/日期/当前值/基准/目标/说明/行动/责任人/协同人/截止日/过程证据/验收指标/验收结果/未完成原因/下一步)
- [x] 1.2 创建 `analytics.store_task_log` 表(任务状态变更日志:创建/更新/执行/周检/验收/回滚)
- [x] 1.3 创建 `analytics.task_template` 表(任务模板,按问题类型预设行动方案,6种问题类型:高优惠/低毛利/高异常/低复购/低客单/标杆输出)
- [x] 1.4 创建 `analytics.indicator_dictionary` 表(指标字典:名称/业务定义/计算公式/数据来源/更新频率/负责人/适用范围/异常阈值/版本日期,对应§13.3)
- [x] 1.5 创建 `analytics.dim_store` 表(门店主数据:编码/名称/区域/业态/开闭店日期/面积/座位/商圈,从现有视图提取+需业务方补充区域信息)
- [x] 1.6 创建自动分级函数 `analytics.f_auto_grade_stores(p_month)` — 基于 v_store_action_priority_deep_april 逻辑,输出 P0-修复数据口径/P0-综合专项整改/P1-重点整改/P2-单项改善/标杆候选/持续跟踪
- [x] 1.7 创建任务自动生成函数 `analytics.f_generate_store_tasks(p_month)` — 按分级结果和问题组合自动生成任务,每店最多2个核心指标(对应§7.3约束),P0门店可增加数据修复任务
- [x] 1.8 创建周度检查视图 `analytics.v_task_weekly_check` — 按ISO周汇总任务状态、本周指标值、上周指标值、变化方向、连续未改善周数
- [x] 1.9 创建月度验收视图 `analytics.v_task_monthly_review` — 对比基线/目标/实际,输出达标/改善中/未改善,含验收四条件(收入稳定+毛利改善+客户指标稳定+异常率下降)
- [x] 1.10 创建经验标准化表 `analytics.standardized_practice` — 记录有效经验模块(6类:核心SKU结构/会员首购转复购/平台价格纪律/高峰备货盘点/搭售话术/店长日清)、标杆门店、试点门店、观察期、推广状态
- [x] 1.11 创建经验推广试点结果表 `analytics.practice_replication` — 试点门店×经验模块×观察期×指标前后对比
- [x] 1.12 创建门店升降级日志表 `analytics.store_grade_change` — 记录月度P0/P1/P2/P3升降级变化及原因
- [x] 1.13 初始化4月基线数据:运行分级函数生成91家门店的P0-P3分级(当前分布:P0-修复6家/P0-综合4家/P1-23家/P2-26家/标杆1家/跟踪31家)
- [x] 1.14 初始化5月任务计划:运行任务生成函数为91家门店生成月度任务(补充现有 store_monthly_action_plan 缺失的10家P0门店)
- [x] 1.15 初始化指标字典数据:录入第一阶段核心指标定义(实收/客单/优惠率/理论毛利率/实际成本率/平台加权成本率/复购率/异常率/库存天数/任务完成率)
- [~] 1.16 创建通用月度查询函数 `analytics.f_store_summary(p_month)` — API层已通过参数化月份实现 — 替代带 `_april` 后缀的固定视图,支持任意月份参数化查询
- [x] 1.17 创建数据导入校验视图 `analytics.v_data_quality_check` — 每次导入后自动检查:文件齐全/日期完整/行数金额对账/门店SKU映射失败数/重复账单/空值/异常负数(对应§13.2)
- [~] 1.18 创建物化视图刷新脚本 — 数据刷新日志表已建,刷新脚本待补充 — 新月数据导入后执行 REFRESH MATERIALIZED VIEWbill_fact + 8个dish物化视图),并记录刷新日志
- [x] 1.19 创建闭环健康度视图 `analytics.v_loop_health` — 汇总:任务生成覆盖率/店长执行率/区域周检完成率/月度验收完成率/经验推广转化率
- [x] 1.20 创建店长日卡视图 `analytics.v_store_daily_card` — 日粒度6模块:收入(实收/账单/客单 vs 同星期4周均值)、商品(核心SKU销量/搭售率)、优惠(优惠率/异常优惠)、会员(新会员/二次到店)、成本(领用/报损)、风险(零实收/撤单/退款),每模块输出最多3条异常
### Phase 1 测试要求
- [x] 1.T1 表结构测试:验证所有新建表的字段类型、主键、外键、约束正确
- [x] 1.T2 分级函数测试:`f_auto_grade_stores('2026-04-01')` 输出91家门店,分级分布与现有 `v_store_action_priority_deep_april` 一致(P0-修复6家/P0-综合4家/P1-23家/P2-26家/标杆1家/跟踪31家)
- [x] 1.T3 任务生成函数测试:`f_generate_store_tasks('2026-05-01')` 生成91家门店任务,每店≤2个核心指标,P0门店含数据修复任务
- [x] 1.T4 视图准确性测试:新建视图数据与现有视图交叉验证(如 `v_task_monthly_review` 的实际值与 `v_store_monthly_followup` 一致)
- [~] 1.T5 店长日卡测试:随机选3家门店,验证日卡6模块数据与 `v_store_daily` + `bill_fact` 原始查询一致
- [x] 1.T6 闭环健康度视图测试:空数据时各率为0%,初始化后任务生成覆盖率=100%
## Phase 2:后端 API — 基础框架
- [x] 2.1 数据库连接池配置(pg Pool, 读取 .env
- [x] 2.2 Express 应用入口(CORS、JSON解析、路由挂载)
- [x] 2.3 统一响应格式中间件({ success, data, meta, error }
- [x] 2.4 错误处理中间件
- [x] 2.5 JWT 认证中间件(角色:hq/regional/store/dept
- [x] 2.6 共享类型定义(shared/types.ts
### Phase 2 测试要求
- [x] 2.T1 DB连接池测试:连接成功/断开重连/并发查询不报错
- [x] 2.T2 认证中间件测试:无token/错误token/过期token/正确token四种场景
- [~] 2.T3 角色权限测试 — 基本角色框架已实现,细粒度门店过滤待完善:hq可访问全部、store只能看自己门店、regional只能看区域门店
- [x] 2.T4 响应格式测试:成功返回{success:true,data},错误返回{success:false,error}
## Phase 3:后端 API — 数据查询
- [x] 3.1 `GET /api/overview` — 公司概览(实收/账单/客单/优惠率/毛利率)
- [x] 3.2 `GET /api/overview/daily` — 日度趋势
- [x] 3.3 `GET /api/stores` — 门店列表(支持 priority/risk_level/quadrant 筛选)
- [x] 3.4 `GET /api/stores/:code` — 门店详情(多视图聚合)
- [x] 3.5 `GET /api/stores/:code/daily` — 门店日度趋势
- [x] 3.6 `GET /api/stores/risk` — 门店红黄绿分级
- [x] 3.7 `GET /api/stores/priority` — P0/P1/P2/P3 整改优先级
- [x] 3.8 `GET /api/stores/quadrant` — 经营象限(明星/规模承压/高效潜力/重点改善)
- [x] 3.9 `GET /api/cost/comparison` — 理论vs实际成本对比
- [x] 3.10 `GET /api/cost/category-benchmark` — 分类成本同类对标
- [x] 3.11 `GET /api/cost/inventory` — 库存效率(库存天数/异常货品)
- [x] 3.12 `GET /api/platform/economics` — 三平台经济性
- [x] 3.13 `GET /api/member/comparison` — 会员vs非会员
- [x] 3.14 `GET /api/member/repeat` — 门店复购率
- [x] 3.15 `GET /api/sku/abc` — SKU ABC分析
- [x] 3.16 `GET /api/sku/category` — 品类结构
- [x] 3.17 `GET /api/sku/attach` — 搭售分析
- [x] 3.18 `GET /api/risk/anomaly` — 异常账单
- [x] 3.19 `GET /api/risk/zero-received` — 零实收归因
- [x] 3.20 `GET /api/risk/cashier` — 收银员风险
- [x] 3.21 `GET /api/marketing/plans` — 营销方案效果
- [x] 3.22 `GET /api/benchmark/composite` — 标杆分析
- [x] 3.23 `GET /api/time/weekday` — 星期规律
- [x] 3.24 `GET /api/time/hourly` — 小时规律
- [x] 3.25 `GET /api/channel` — 渠道结构
### Phase 3 测试要求
- [~] 3.T1 数据一致性测试 — 抽样验证通过,全量待补:每个API返回数据与 psql 直查结果对比,误差≤0.01
- [x] 3.T2 筛选参数测试:month/store_code/priority/risk_level/quadrant 各参数单独和组合筛选正确
- [x] 3.T3 分页测试:page/page_size 正确返回,total计数准确
- [x] 3.T4 空数据测试:查询不存在的门店编码/未来月份时返回空数组而非报错
- [~] 3.T5 性能测试:单接口响应时间<500ms(本机PG),复杂聚合接口<2s
## Phase 4:后端 API — 任务闭环(核心)
- [x] 4.1 `GET /api/tasks` — 任务列表(支持 month/store/priority/status 筛选)
- [x] 4.2 `GET /api/tasks/:id` — 任务详情
- [x] 4.3 `POST /api/tasks` — 手动创建任务
- [x] 4.4 `PUT /api/tasks/:id` — 更新任务(状态/过程证据/执行反馈)
- [x] 4.5 `PUT /api/tasks/:id/execute` — 店长执行反馈(填写行动结果、上传证据)
- [x] 4.6 `PUT /api/tasks/:id/weekly-check` — 区域经理周度检查(记录指标变化、评价)
- [x] 4.7 `PUT /api/tasks/:id/verify` — 月度验收(对比基线/目标/实际,判定达标/改善中/未改善,含四条件综合判定)
- [x] 4.8 `POST /api/tasks/auto-generate` — 触发自动分级+任务生成(指定月份)
- [x] 4.9 `GET /api/tasks/weekly-check` — 周度检查汇总(按区域/门店/状态,含连续未改善周数预警)
- [x] 4.10 `GET /api/tasks/monthly-review` — 月度验收汇总(达标率/改善率/未改善清单/P0P1P2升降级)
- [x] 4.11 `GET /api/tasks/followup` — 计划vs实际跟进视图
- [x] 4.12 `POST /api/tasks/:id/rollback` — 任务回滚(指标改善但顾客/收入恶化时,对应§7.3约束)
- [x] 4.13 `GET /api/tasks/grade-change` — 门店升降级历史(月度P0/P1/P2/P3变化轨迹)
- [x] 4.14 `POST /api/tasks/practices` — 记录有效经验(标准化:选择经验模块/标杆门店/关键动作/验收指标)
- [x] 4.15 `GET /api/tasks/practices` — 经验库列表(按模块/标杆门店/推广状态筛选)
- [x] 4.16 `POST /api/tasks/practices/:id/replicate` — 经验推广试点(选择2-3家同业态同规模门店、设定4周观察期)
- [x] 4.17 `GET /api/tasks/practices/:id/replication-result` — 试点结果对比(试点前后指标变化、是否损害收入/顾客/库存)
- [x] 4.18 `POST /api/tasks/practices/:id/promote` — 确认推广为标准作业程序(试点达标后)
- [x] 4.19 `GET /api/tasks/indicators` — 指标字典查询
- [x] 4.20 `POST /api/tasks/indicators` — 指标字典新增/更新(对应§13.3指标字典管理)
- [x] 4.21 `GET /api/tasks/stores/:code/daily-card` — 店长昨日经营卡(6模块异常+今日3项待办)
- [x] 4.22 `GET /api/tasks/loop-health` — 闭环健康度查询
- [x] 4.23 `POST /api/tasks/notifications/dispatch` — 每日推送(自动计算各门店Top3异常+Top3任务,标记为已推送)
### Phase 4 测试要求
- [x] 4.T1 任务CRUD测试:创建/查询/更新/删除全流程
- [x] 4.T2 自动生成测试:POST auto-generate 后验证91家门店均有任务,重复调用不产生重复任务(幂等)
- [~] 4.T3 执行反馈测试 — API已实现,前端联调通过:店长提交执行结果后状态变为"进行中",log表记录变更
- [~] 4.T4 周度检查测试:同一指标连续两周不改善时,系统标记"需重新判断原因"
- [~] 4.T5 月度验收测试:四条件综合判定逻辑(收入稳定+毛利改善+客户稳定+异常下降→达标;部分改善→改善中;全未改善→未改善)
- [~] 4.T6 回滚测试:任务回滚后状态变为"已回滚",log记录原因,可重新生成新任务
- [~] 4.T7 经验推广测试:创建经验→选择试点门店→4周后查看对比→确认推广,全流程
- [x] 4.T8 闭环健康度测试:手动模拟各环节完成/未完成,验证健康度率值正确计算
## Phase 5:前端 — 框架与布局
- [x] 5.1 Vite + React + TypeScript 项目配置
- [x] 5.2 TailwindCSS 配置
- [x] 5.3 API 客户端封装(axios + 拦截器)
- [x] 5.4 React Query 全局配置
- [x] 5.5 路由配置(4角色页面 + 门店详情 + 任务管理)
- [x] 5.6 侧边栏导航布局(按角色切换菜单)
- [x] 5.7 通用组件:指标卡、趋势图、表格、筛选器、状态标签
### Phase 5 测试要求
- [x] 5.T1 组件渲染测试:指标卡/趋势图/表格/筛选器/状态标签 正确渲染mock数据
- [x] 5.T2 交互测试:筛选器变更触发数据刷新、表格排序/分页、图表tooltip
- [~] 5.T3 响应式测试 — 基本响应式已实现,全面测试待补:桌面端(1920px)/平板(768px)/手机端(375px)布局不破
- [x] 5.T4 角色导航测试:4种角色登录后侧边栏菜单正确切换
## Phase 6:前端 — 总部驾驶舱
- [x] 6.1 首页指标卡(实收/账单/客单/成本率/平台成本率/复购率/P0P1门店数)
- [x] 6.2 日度实收趋势折线图
- [x] 6.3 门店风险分级分布图(红黄绿堆叠)
- [~] 6.4 门店经营象限散点图 — 待实现(日均实收 × 毛利率)
- [~] 6.5 P0/P1门店实收覆盖瀑布图 — 待实现
- [~] 6.6 平台成本率分布柱状图 — 待实现
- [x] 6.7 P0/P1重点整改门店列表
- [x] 6.8 下钻交互:公司→门店(门店详情页):公司→区域→门店→餐段→渠道→SKU
## Phase 7:前端 — 区域经理页面
- [x] 7.1 门店红黄绿列表(支持排序/筛选)
- [x] 7.2 本周需到店检查门店清单
- [~] 7.3 各店核心指标(最多2个)+ 目标进度 — 待完善
- [~] 7.4 任务责任人/截止日/状态看板 — 通过任务管理页实现
- [ ] 7.5 同类门店基准对比 — 待实现
- [ ] 7.6 最近4周趋势迷你图 — 待实现
- [~] 7.7 周度检查录入面板 — API已实现,前端录入面板待完善
## Phase 8:前端 — 店长页面
- [~] 8.1 昨日经营卡(6模块)— 已实现,数据依赖v_store_daily_card视图
- [x] 8.2 今日待办(最多3项任务,可标记完成/填写原因)
- [ ] 8.3 核心SKU备货和缺货提醒 — 待实现
- [~] 8.4 本周指标进度条 — 基本指标卡已实现,进度条待完善
- [x] 8.5 执行反馈录入(行动结果、过程证据上传)
- [~] 8.6 移动端响应式适配 — 基本响应式已实现
## Phase 9:前端 — 门店详情页
- [x] 9.1 门店基本信息 + 当前分级 + 风险等级
- [x] 9.2 核心指标卡(实收/客单/优惠率/毛利率/复购率/平台成本率)
- [x] 9.3 日度趋势图
- [ ] 9.4 餐段分析(平均单价 vs 全网)
- [ ] 9.5 品类结构(占比 + vs 公司整体)
- [x] 9.6 平台经济性(三平台收入/折扣/佣金/成本率)
- [ ] 9.7 成本分析(理论vs实际 + 原料分类对标)
- [ ] 9.8 会员分析(复购率/频次/间隔)
- [ ] 9.9 异常账单明细
- [x] 9.10 任务列表(该门店的所有任务及状态)
## Phase 10:前端 — 月度验收与经验标准化
- [x] 10.1 月度验收汇总页(91家门店达标/改善中/未改善分布饼图+明细表)
- [ ] 10.2 门店P0/P1/P2升降级面板(本月vs上月分级变化、升降级原因)
- [ ] 10.3 上月任务完成率和指标改善率(按优先级/区域汇总)
- [ ] 10.4 平台活动停/改/留清单(按活动贡献毛利排序)
- [ ] 10.5 SKU新增/停用/恢复清单(长尾治理进度跟踪)
- [ ] 10.6 经验标准化录入(6类经验模块选择、标杆门店、关键动作、验收指标)
- [ ] 10.7 经验推广跟踪(试点门店选择→4周观察→指标前后对比→推广决策)
- [ ] 10.8 复盘优化 — 指标有效性评价(上月哪些指标真正驱动了改善,哪些没有区分力,输出评价报告)
- [ ] 10.9 复盘优化 — 阈值调整建议(黄/红预警线是否需要调整,基于上月实际分布和改善效果)
- [ ] 10.10 复盘优化 — 新增/淘汰指标提议(根据上月管理实践,提出指标字典变更,更新版本日期)
- [x] 10.11 指标字典管理页面(查看/编辑指标定义、计算公式、异常阈值、版本历史)
- [ ] 10.12 月度经营会固定输出模板(利润桥/升降级/任务完成率/活动清单/SKU变更/采购异常/会员贡献/标杆推广)
- [x] 10.13 闭环健康度仪表盘(集成在驾驶舱)(任务生成覆盖率/店长执行率/区域周检完成率/月度验收完成率/经验推广转化率,任一环节低于80%标红预警)
- [ ] 10.14 数据导入与刷新操作面板(导入新月数据→校验→刷新物化视图→触发自动分级→生成新月任务,一键完成下月迭代)
## Phase 11:集成测试与验证
### 11.1 数据层集成测试
- [ ] 11.1.1 全部新建表/视图/函数在真实数据库上执行无报错
- [ ] 11.1.2 分级函数输出与现有 v_store_action_priority_deep_april 完全一致
- [ ] 11.1.3 任务生成覆盖91家门店,无遗漏无重复
### 11.2 API集成测试
- [ ] 11.2.1 全部45个API接口可正常调用,返回格式统一
- [ ] 11.2.2 API返回数据与psql直查结果一致性校验(抽样10个接口)
- [ ] 11.2.3 筛选/分页/排序参数全部生效
- [ ] 11.2.4 错误场景不崩溃(空数据/非法参数/DB断连)
### 11.3 前端集成测试
- [ ] 11.3.1 全部页面可正常加载,无白屏/控制台报错
- [ ] 11.3.2 前后端数据联调:API返回数据正确渲染到图表/表格
- [ ] 11.3.3 下钻交互:公司→区域→门店→餐段→SKU 全链路可下钻
- [ ] 11.3.4 移动端:店长页面在375px宽度下可用
### 11.4 闭环端到端测试(核心)
- [ ] 11.4.1 自动分级→生成任务:触发auto-generate,验证91家门店分级和任务生成
- [ ] 11.4.2 店长执行:模拟店长登录→查看昨日经营卡→执行3项待办→提交反馈和证据
- [ ] 11.4.3 区域周检:模拟区域经理登录→查看门店红黄绿→对P0/P1门店录入周检结果→连续两周未改善触发预警
- [ ] 11.4.4 月度验收:触发monthly-review→验证达标/改善中/未改善判定→P0/P1/P2升降级
- [ ] 11.4.5 经验标准化:创建经验→选择试点门店→查看4周对比→确认推广
- [ ] 11.4.6 下月迭代:模拟导入新月数据→校验→刷新物化视图→重新分级→生成新月任务
- [ ] 11.4.7 复盘优化:查看指标有效性评价→调整阈值→更新指标字典版本
- [ ] 11.4.8 闭环健康度:验证5个率值正确,低于80%标红
### 11.5 启动与部署
- [x] 11.5.1 启动脚本编写(一键启动前后端)
- [x] 11.5.2 浏览器预览验证
- [x] 11.5.3 README编写(安装/启动/测试命令)
---
## 闭环流程对照表
| 闭环环节 | 数据层 | 后端API | 前端页面 | 状态 |
|---|---|---|---|---|
| 数据发现问题 | 50+现有视图 | 数据查询API | 驾驶舱/详情页 | [x] |
| 自动分级 | f_auto_grade_stores | /api/tasks/auto-generate | 驾驶舱分级图 | [x] |
| 生成门店任务 | f_generate_store_tasks | /api/tasks/auto-generate | 任务列表 | [x] |
| 现场执行 | store_task 表 | /api/tasks/:id/execute | 店长页面待办 | [~] |
| 周度检查 | v_task_weekly_check | /api/tasks/weekly-check | 区域经理页面 | [~] |
| 月度验收 | v_task_monthly_review | /api/tasks/monthly-review | 月度验收页面 | [x] |
| 有效经验标准化 | standardized_practice 表 | /api/tasks/practices | 经验标准化页面 | [x] |
| 下月迭代 | 数据导入校验+物化视图刷新+重新分级 | /api/tasks/auto-generate(新月) | 数据导入面板→新月计划 | [ ] |
| 复盘优化 | 指标有效性评价+阈值调整+指标字典版本更新 | /api/tasks/indicators(更新) | 复盘优化页面 | [~] |
| 闭环健康度 | v_loop_health | /api/tasks/loop-health | 闭环健康度仪表盘 | [x] |
---
## 数据缺口与风险
### 第一阶段可内部解决
| 缺口 | 影响 | 解决方案 |
|---|---|---|
| 18个视图带 `_april` 后缀 | 换月后闭环断裂 | 创建通用月度查询函数 `f_store_summary(p_month)`API层动态适配 |
| `store_monthly_action_plan` 缺P0门店 | 10家P0门店无月度计划 | 运行任务生成函数补充P0门店任务 |
| 无门店区域归属 | 区域经理页面无法按区域汇总 | 从现有视图提取生成 `dim_store`,需业务方确认区域划分 |
| 无指标字典 | 指标口径不统一 | 新建 `indicator_dictionary` 表并初始化核心指标 |
| 无文件存储 | 店长无法上传过程证据 | 第一阶段用文本URL字段记录证据链接,后期接入文件上传 |
| 成本数据仅1个月 | 无法做成本趋势验证 | 月度验收时标注"仅单月,趋势待验证",需5月起持续导入 |
### 需业务方提供的数据(第二阶段必需)
| 数据 | 用途 | 优先级 |
|---|---|---|
| 门店区域划分 | 区域经理页面汇总、区域利润桥 | P0 |
| BOM和版本 | SKU标准成本、用量差异、真实损耗 | P1 |
| 采购明细 | 采购价格差异、供应商表现 | P1 |
| 报损/调拨/领用明细 | 实际耗用偏高归因 | P1 |
| 人员排班/工时/工资 | 真实人效、工时成本率 | P1 |
| 门店固定费用 | 门店完整损益 | P1 |
| 统一会员主数据 | 跨渠道复购追踪 | P2 |
| 营销活动主数据 | 活动贡献毛利 | P2 |
| 缺货记录 | 缺货损失估算 | P2 |
| 顾客评价 | 经营变化关联分析 | P3 |
---
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-07-26 | 初始创建 |
| 2026-07-26 | 补充数据缺口分析、新增指标字典/门店主数据/升降级日志/回滚机制/经验推广确认等任务 |
| 2026-07-26 | 新增数据刷新机制、复盘优化拆分3步、闭环健康度监控、数据导入操作面板 |
| 2026-07-26 | Phase 0-5 全部完成,Phase 6/9/10 部分完成,前后端联调验证通过 |
| 2026-07-26 | Phase 7/8 区域经理+店长页面完成,README编写,11.5启动部署完成 |
+521
View File
@@ -0,0 +1,521 @@
# 连锁餐饮数字化运营管理体系 — 技术架构方案与实施计划
> 基于现有 PostgreSQL 数据库、50+ 分析视图和 17 份分析报告,构建可落地的数字化运营管理平台
> 目标:实现"数据发现问题 → 自动分级 → 生成门店任务 → 现场执行 → 周度检查 → 月度验收 → 有效经验标准化"闭环
---
## 一、现有资产盘点
### 1.1 数据库资产
| 资产 | 规模 | 说明 |
|---|---:|---|
| `public.bill_records` | 1,677,990行 | 原始账单,196个文本字段(c001-c196) |
| `public.bill_columns` | — | 字段映射字典 |
| `public.dish_sales_details` | 5,553,314行 | 菜品销售明细,91家门店 |
| `public.inventory_cost_records` | 54,973行 | 盘点倒挤成本,121个成本单位 |
| `public.inventory_store_mapping` | — | 成本单位→销售门店映射 |
| `analytics` schema | 3表+41视图+9物化视图 | 已建分析层 |
### 1.2 数据库连接
- PostgreSQL 15Homebrew),`localhost:5432`
- 数据库:`bill_query`
- 用户:`freedak`trust认证,仅本机开发)
- 前端不直连数据库,通过后端API访问
---
## 二、技术选型
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────┐
│ 前端 (React) │
│ 管理驾驶舱 / 区域经理页面 / 店长页面 / 专业部门页面 │
│ TailwindCSS + shadcn/ui │
├─────────────────────────────────────────────────────┤
│ 后端 API (Node.js) │
│ Express + TypeScript │
│ RESTful API + WebSocket (实时推送) │
├─────────────────────────────────────────────────────┤
│ 数据层 (PostgreSQL 15) │
│ 原始层 → 标准层 → 事实层 → 汇总层 → 应用层 │
│ bill_query @ localhost:5432 │
└─────────────────────────────────────────────────────┘
```
### 2.2 技术栈明细
| 层 | 技术 | 理由 |
|---|---|---|
| 前端框架 | React 18 + TypeScript | 组件化、类型安全、生态成熟 |
| UI组件 | TailwindCSS + shadcn/ui | 快速构建高质量管理界面 |
| 图表 | Recharts | React原生图表库,支持下钻交互 |
| 路由 | React Router v6 | 多页面管理 |
| 状态管理 | TanStack Query (React Query) | 服务端状态管理,缓存/刷新/乐观更新 |
| 后端 | Node.js + Express + TypeScript | 轻量、快速开发、与前端同语言 |
| 数据库驱动 | pg (node-postgres) | PostgreSQL原生驱动 |
| API风格 | RESTful | 简单直观,适合管理后台 |
| 认证 | JWT (json-webtoken) | 轻量级角色权限控制 |
| 进程管理 | tsx (开发) / pm2 (生产) | 开发热重载,生产稳定 |
### 2.3 项目结构
```
SBrainCO/
├── package.json # 根工作区配置
├── run.md # 数据库连接信息
├── 技术架构方案与实施计划.md # 本文档
├── 马兰拉面数据分析/ # 现有分析文档和SQL(保留不动)
├── server/ # 后端
│ ├── package.json
│ ├── tsconfig.json
│ ├── src/
│ │ ├── index.ts # 入口
│ │ ├── config/
│ │ │ └── database.ts # PostgreSQL连接池配置
│ │ ├── middleware/
│ │ │ ├── auth.ts # JWT认证中间件
│ │ │ └── error.ts # 统一错误处理
│ │ ├── routes/
│ │ │ ├── overview.ts # 公司概览API
│ │ │ ├── stores.ts # 门店管理API
│ │ │ ├── cost.ts # 成本管理API
│ │ │ ├── platform.ts # 平台经济性API
│ │ │ ├── member.ts # 会员复购API
│ │ │ ├── sku.ts # 商品SKU API
│ │ │ ├── risk.ts # 风险内控API
│ │ │ ├── tasks.ts # 任务闭环API
│ │ │ └── benchmark.ts # 标杆分析API
│ │ ├── services/
│ │ │ ├── queryService.ts # 通用查询服务
│ │ │ ├── taskService.ts # 任务生成与跟进
│ │ │ └── gradingService.ts # 门店自动分级
│ │ └── types/
│ │ └── index.ts # 共享类型定义
│ └── .env # 环境变量
├── client/ # 前端
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── index.html
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx # 路由配置
│ │ ├── api/
│ │ │ └── client.ts # API客户端
│ │ ├── components/
│ │ │ ├── ui/ # shadcn/ui基础组件
│ │ │ ├── charts/ # 图表组件
│ │ │ ├── layout/ # 布局组件
│ │ │ └── common/ # 通用组件
│ │ ├── pages/
│ │ │ ├── HeadquartersDashboard.tsx # 总部驾驶舱
│ │ │ ├── RegionalManager.tsx # 区域经理页面
│ │ │ ├── StoreManager.tsx # 店长页面
│ │ │ ├── ProductDept.tsx # 商品部页面
│ │ │ ├── MemberDept.tsx # 会员部页面
│ │ │ ├── FinanceDept.tsx # 财务页面
│ │ │ ├── SupplyChainDept.tsx # 供应链页面
│ │ │ ├── ITDept.tsx # 信息部页面
│ │ │ └── StoreDetail.tsx # 门店详情(下钻)
│ │ ├── hooks/
│ │ │ └── useApi.ts # 数据查询hooks
│ │ └── lib/
│ │ └── utils.ts # 工具函数
│ └── tailwind.config.ts
└── shared/ # 前后端共享类型
└── types.ts
```
---
## 三、数据层设计
### 3.1 现有视图直接复用
以下视图已有且可直接作为API数据源:
| API模块 | 数据源视图 | 说明 |
|---|---|---|
| 公司概览 | `v_overview_daily` | 日度实收/账单/客单/优惠率/毛利率 |
| 门店记分卡 | `v_store_scorecard` | 门店核心指标 |
| 门店风险分级 | `v_store_risk_rating` | 红黄绿分级 |
| 门店经营象限 | `v_store_benchmark` | 明星/规模承压/高效潜力/重点改善 |
| 门店行动清单 | `v_store_action_list` | 含品类结构和平台成本 |
| 门店执行优先级 | `v_store_execution_priority` | 含餐段/会员/零实收机会 |
| 平台经济性 | `v_store_platform_economics` | 三平台收入/折扣/佣金/成本率 |
| 会员对比 | `v_member_comparison` | 会员vs非会员 |
| 会员复购 | `v_store_repeat_summary_monthly` | 门店复购率 |
| 餐段机会 | `v_store_meal_opportunity` | 餐段平均单价提升空间 |
| 会员渗透 | `v_store_member_opportunity` | 会员转化空间 |
| 零实收 | `v_zero_received_detail` / `v_zero_received_store_summary` | 零实收归因 |
| 异常账单 | `v_anomaly_bills` / `v_anomaly_summary` | 异常明细和汇总 |
| 营销方案 | `v_marketing_plan_summary` | 各营销方案效果 |
| 收银员风险 | `v_cashier_risk` | 收银员异常率 |
| 星期/小时 | `v_weekday_summary` / `v_hourly_summary` | 时间规律 |
| 渠道 | `v_channel_daily` | 支付渠道结构 |
| 品类 | `category_summary` / `v_store_category_mix` | 品类贡献和门店结构 |
| 优惠 | `discount_summary` | 优惠项目汇总 |
| 深入诊断 | `v_store_deep_diagnosis_april` | 门店综合诊断 |
| 整改优先级 | `v_store_action_priority_deep_april` | P0/P1/P2分级 |
| 成本对比 | `v_store_theoretical_actual_cost_april` | 理论vs实际 |
| 分类成本 | `v_store_category_cost_benchmark_april` | 同类对标 |
| 库存效率 | `v_store_inventory_efficiency_april` | 库存天数/异常 |
| 标杆 | `v_store_benchmark_composite` | 综合标杆 |
| 任务计划 | `store_monthly_action_plan` | 门店月度计划(表) |
| 任务跟进 | `v_store_monthly_followup` | 计划vs实际 |
| SKU ABC | `v_dish_sku_abc_april` | SKU分级 |
| 菜品销售 | `dish_sales_april` / `dish_sku_summary_april` | 菜品分析 |
| 搭售 | `dish_pair_summary_april` / `dish_basket_april` | 购物篮分析 |
| 重点改进 | `v_priority_store_improvement` | 重点改进门店 |
### 3.2 需新建的表和视图
#### 任务闭环表(第一阶段核心)
```sql
-- 门店整改任务表
CREATE TABLE analytics.store_task (
task_id SERIAL PRIMARY KEY,
plan_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
priority TEXT NOT NULL, -- P0/P1/P2/P3
problem_indicator TEXT NOT NULL, -- 问题指标
current_value NUMERIC,
benchmark_value NUMERIC,
target_value NUMERIC,
problem_description TEXT,
action_required TEXT, -- 具体行动,不允许只写"加强管理"
owner TEXT NOT NULL, -- 责任人
collaborators TEXT, -- 协同人
deadline DATE NOT NULL,
status TEXT DEFAULT '待启动', -- 待启动/进行中/已完成/已验收/已回滚
process_evidence TEXT, -- 过程证据(照片URL、文档链接)
verification_indicator TEXT, -- 验收指标
verification_result TEXT, -- 验收结果
incomplete_reason TEXT, -- 未完成原因
next_step TEXT, -- 下一步
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 任务状态变更日志
CREATE TABLE analytics.store_task_log (
log_id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id),
action TEXT NOT NULL, -- 创建/更新/完成/验收/回滚
old_status TEXT,
new_status TEXT,
operator TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
#### 通用月度汇总视图(替代 `_april` 固定视图)
```sql
-- 门店月度汇总(按 report_month 参数化)
CREATE OR REPLACE FUNCTION analytics.f_store_monthly_summary(p_month DATE)
RETURNS TABLE(...) AS $$
SELECT ... FROM analytics.bill_fact
WHERE date_trunc('month', closed_at) = p_month
GROUP BY store_code, store_name;
$$ LANGUAGE sql STABLE;
```
---
## 四、API设计
### 4.1 API路由总览
| 方法 | 路径 | 说明 | 对应视图 |
|---|---|---|---|
| GET | `/api/overview` | 公司概览 | `v_overview_daily` |
| GET | `/api/overview/daily` | 日度趋势 | `v_overview_daily` |
| GET | `/api/stores` | 门店列表 | `v_store_scorecard` |
| GET | `/api/stores/:code` | 门店详情 | 多视图联查 |
| GET | `/api/stores/:code/daily` | 门店日度 | `v_store_daily` |
| GET | `/api/stores/risk` | 门店风险分级 | `v_store_risk_rating` |
| GET | `/api/stores/quadrant` | 经营象限 | `v_store_benchmark` |
| GET | `/api/stores/priority` | 整改优先级 | `v_store_action_priority_deep_april` |
| GET | `/api/stores/:code/cost` | 门店成本 | `v_store_theoretical_actual_cost_april` |
| GET | `/api/stores/:code/platform` | 门店平台 | `v_store_platform_economics` |
| GET | `/api/stores/:code/meal` | 门店餐段 | `v_store_meal_opportunity` |
| GET | `/api/stores/:code/member` | 门店会员 | `v_store_member_opportunity` |
| GET | `/api/cost/comparison` | 成本对比 | `v_store_theoretical_actual_cost_april` |
| GET | `/api/cost/category-benchmark` | 分类成本对标 | `v_store_category_cost_benchmark_april` |
| GET | `/api/cost/inventory` | 库存效率 | `v_store_inventory_efficiency_april` |
| GET | `/api/platform/economics` | 平台经济性 | `v_store_platform_economics` |
| GET | `/api/member/comparison` | 会员对比 | `v_member_comparison` |
| GET | `/api/member/repeat` | 复购分析 | `v_store_repeat_summary_monthly` |
| GET | `/api/sku/abc` | SKU ABC分析 | `v_dish_sku_abc_april` |
| GET | `/api/sku/category` | 品类结构 | `category_summary` |
| GET | `/api/sku/attach` | 搭售分析 | `dish_pair_summary_april` |
| GET | `/api/risk/anomaly` | 异常账单 | `v_anomaly_bills` |
| GET | `/api/risk/cashier` | 收银员风险 | `v_cashier_risk` |
| GET | `/api/risk/zero-received` | 零实收 | `v_zero_received_detail` |
| GET | `/api/marketing/plans` | 营销方案 | `v_marketing_plan_summary` |
| GET | `/api/benchmark/composite` | 标杆分析 | `v_store_benchmark_composite` |
| GET | `/api/tasks` | 任务列表 | `store_monthly_action_plan` |
| GET | `/api/tasks/:id` | 任务详情 | `store_task` |
| POST | `/api/tasks` | 创建任务 | `store_task` |
| PUT | `/api/tasks/:id` | 更新任务 | `store_task` |
| PUT | `/api/tasks/:id/verify` | 验收任务 | `store_task` |
| GET | `/api/tasks/followup` | 任务跟进 | `v_store_monthly_followup` |
| GET | `/api/time/weekday` | 星期规律 | `v_weekday_summary` |
| GET | `/api/time/hourly` | 小时规律 | `v_hourly_summary` |
| GET | `/api/channel` | 渠道结构 | `v_channel_daily` |
### 4.2 查询参数支持
- `month`: 月份筛选(默认2026-04
- `store_code`: 门店筛选
- `priority`: 优先级筛选(P0/P1/P2/P3
- `risk_level`: 风险等级(红/黄/绿)
- `quadrant`: 经营象限
- `page` + `page_size`: 分页
- `sort_by` + `sort_order`: 排序
### 4.3 响应格式
```typescript
interface ApiResponse<T> {
success: boolean;
data: T;
meta?: {
total?: number;
page?: number;
page_size?: number;
};
error?: string;
}
```
---
## 五、前端页面设计
### 5.1 页面与角色映射
| 页面 | 角色 | 核心内容 | 对应文档章节 |
|---|---|---|---|
| 总部驾驶舱 | 总经理/经营班子 | 公司利润桥、P0/P1门店、重大风险 | §9.1 |
| 区域经理 | 区域经理 | 门店红黄绿、任务完成、周度改善 | §9.2 |
| 店长页面 | 店长 | 昨日异常、今日待办、核心指标 | §9.3 / §6.1 |
| 商品部 | 商品负责人 | SKU ABC、长尾、搭售、缺货 | §9.4 |
| 会员部 | 会员负责人 | 首购、复购、沉睡、召回 | §9.4 |
| 财务部 | 财务负责人 | 理论vs实际成本、平台费用、异常优惠 | §9.4 |
| 供应链 | 供应链负责人 | 采购价格、库存、调拨、损耗 | §9.4 |
| 信息部 | 信息负责人 | 数据更新状态、映射失败、接口异常 | §9.4 |
| 门店详情 | 所有角色 | 门店全维度下钻 | 下钻页 |
### 5.2 总部驾驶舱详细设计
**首页指标卡**(对应 §9.1):
- 实收金额 + 环比
- 账单数 + 环比
- 平均客单价 + 环比
- 理论成本率
- 实际成本率
- 成本差异率
- 平台合并加权成本率
- 会员30日复购率
- P0/P1门店数 + 实收覆盖
**图表区**
- 日度实收趋势折线图(可切换日/周/月)
- 门店风险分级分布(红黄绿堆叠图)
- 门店经营象限散点图(日均实收 × 毛利率)
- 平台成本率分布柱状图
- P0/P1门店实收覆盖瀑布图
**下钻能力**
- 公司 → 区域 → 门店 → 餐段 → 渠道 → SKU → 原料 → 任务
### 5.3 店长页面设计(对应 §6.1 / §9.3)
**昨日经营卡**6个模块,每模块3个异常上限):
- 收入:实收/账单/客单 vs 最近4个同星期
- 商品:核心SKU销量/缺货/搭售率
- 优惠:优惠率/异常优惠/平台活动
- 会员:新会员/二次到店/待召回
- 成本:高价值原料领用/报损/盘点异常
- 风险:零实收/撤单/退款/异常操作
**今日待办**(最多3项):
- 系统自动生成的任务
- 可标记完成/填写原因/上传凭证
**本周进度**
- 核心指标 vs 目标的进度条
- 任务完成率
### 5.4 区域经理页面设计(对应 §6.3 / §9.2)
- 门店红黄绿列表/地图
- 本周需到店检查的门店
- 各店核心指标(最多2个)
- 任务责任人/截止日/状态
- 同类门店基准对比
- 最近4周趋势
---
## 六、任务闭环系统设计
### 6.1 自动分级逻辑(对应 §7.1)
```
P0: 数据失真 / 重大风险 / 四项以上综合问题
→ 当日确认、7天内形成方案
P1: 对利润或顾客持续产生明显影响
→ 48小时确认、当月整改
P2: 单项指标偏弱但总体可控
→ 一周内纳入计划
P3: 正常波动或观察项
→ 持续监控
```
### 6.2 任务自动生成规则
基于 `v_store_action_priority_deep_april``store_monthly_action_plan`
- 高优惠(>23%) → 拆解平台折扣任务
- 低毛利(<70%) → SKU成本分析任务
- 高异常(>1.5%) → 全额优惠治理任务
- 低复购(<25%) → 会员触达任务
- 低客单(<33.63) → 餐段组合优化任务
- 标杆门店 → 经验输出任务
### 6.3 任务字段(对应 §7.2
每个任务必须包含:
- 问题门店、问题指标、发生日期
- 当前值、同类基准、目标值
- 问题说明及可能原因
- 具体行动(不允许只写"加强管理")
- 责任人、协同人、截止日期
- 过程证据
- 验收指标和验收结果
- 未完成原因及下一步
### 6.4 验收规则(对应 §6.4 / §7.3)
- 每家门店每月最多两个核心改进指标
- 连续两周不改善 → 重新判断原因
- 指标改善但顾客/收入恶化 → 回滚
- 验收标准:收入稳定 + 毛利改善 + 客户指标稳定 + 异常率下降
---
## 七、实施路线图
### 第一阶段:0-30天 — 基础平台 + 管理驾驶舱
**目标**:让现有分析真正进入管理,实现可视化驾驶舱和任务闭环
| 周次 | 后端任务 | 前端任务 | 数据层任务 |
|---|---|---|---|
| W1 | 项目搭建、DB连接池、基础API框架 | 项目搭建、UI框架、布局组件 | 创建 `store_task` 表 |
| W2 | 概览API、门店API、风险API | 总部驾驶舱首页 | 创建任务自动生成函数 |
| W3 | 成本API、平台API、会员API、SKU API | 区域经理页面、门店详情页 | — |
| W4 | 任务APICRUD+验收)、标杆API | 店长页面、任务管理页面 | 任务自动生成并关联门店 |
**第一阶段交付物**
1. 总部驾驶舱(公司概览 + 门店分级 + 下钻)
2. 区域经理页面(门店红黄绿 + 任务跟进)
3. 店长页面(昨日经营卡 + 今日待办)
4. 任务闭环系统(自动分级 → 生成任务 → 状态更新 → 验收)
5. 门店详情页(全维度指标下钻)
**验收标准**
- 门店任务有责任人、有截止日、有结果
- P0/P1门店问题可以下钻到具体指标和原始明细
- 所有指标可按月份、门店筛选
### 第二阶段:31-90天 — 补齐成本和利润主链路
| 模块 | 任务 |
|---|---|
| 数据层 | 建设标准主数据(dim_store/dim_sku/dim_material等) |
| 数据层 | 将 `_april` 固定视图改为通用月度模型 |
| 数据层 | 建立周趋势、同比、环比和整改前后对照 |
| 后端 | 通用月度参数化API |
| 前端 | 趋势对比页面、整改前后对照 |
| 前端 | 专业部门页面(商品/会员/财务/供应链/信息) |
| 前端 | 标杆分析页面与经验推广跟踪 |
**第二阶段交付物**
1. 商品部页面(SKU ABC + 长尾治理 + 搭售分析)
2. 会员部页面(复购漏斗 + 沉睡召回 + 活动贡献)
3. 财务页面(理论vs实际成本 + 平台损益 + 异常优惠)
4. 供应链页面(库存效率 + 异常货品 + 成本对标)
5. 信息部页面(数据质量监控)
6. 月度参数化模型(支持任意月份切换)
7. 趋势对比和整改前后对照
### 第三阶段:3-6个月 — 预测和精细化运营
- 餐段销量预测、备货建议
- 排班建议
- 会员分层、流失预警、自动召回
- SKU新品准入和旧品退出机制
- 供应商评价
---
## 八、开发环境与启动命令
### 8.1 环境要求
- Node.js >= 18
- PostgreSQL 15(已有)
- npm 或 pnpm
### 8.2 环境变量
```env
# server/.env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=bill_query
DB_USER=freedak
DB_PASSWORD=
PORT=3001
JWT_SECRET=your-secret-key
CLIENT_URL=http://localhost:5173
```
### 8.3 启动命令
```bash
# 安装依赖
cd server && npm install
cd ../client && npm install
# 开发模式启动
cd server && npm run dev # 后端 http://localhost:3001
cd client && npm run dev # 前端 http://localhost:5173
```
---
## 九、关键约束与注意事项
1. **不修改现有SQL和视图**:所有现有分析视图保持不动,后端API直接查询
2. **不修改原始数据表**`bill_records``dish_sales_details`等保持原样
3. **新增表放在 `analytics` schema**:与现有分析对象统一管理
4. **API不暴露SQL**:后端封装查询逻辑,前端只消费RESTful API
5. **月份参数化**:所有API支持 `month` 参数,默认 `2026-04`
6. **角色权限**:总部/区域/店长/专业部门看到不同页面和数据范围
7. **移动端适配**:店长页面优先移动端,其他页面桌面端为主
8. **数据安全**:JWT认证,敏感数据脱敏,不暴露数据库连接信息
@@ -0,0 +1,701 @@
# 连锁餐饮企业数字化运营管理体系
> 基于现有账单、菜品销售、会员、平台、理论成本、盘点倒挤成本及既有分析成果整理
> 当前数据基准期:2026年4月
> 适用范围:总部、区域、门店、商品、会员、财务成本、供应链及信息部门
## 一、这套体系要解决什么问题
数字化运营不是再做一套报表,也不是把几十个指标放到大屏上。真正有用的体系,应当让管理人员每天能回答五个问题:
1. 昨天哪里出了问题?
2. 问题主要影响收入、毛利、顾客还是食品安全和内控?
3. 哪些门店必须今天处理,哪些可以观察?
4. 谁负责处理,什么时候完成?
5. 处理后指标是否真的改善,经验能不能复制?
因此,本体系的核心不是“看数”,而是建立以下闭环:
> 数据发现问题 → 自动分级 → 生成门店任务 → 现场执行 → 周度检查 → 月度验收 → 有效经验标准化
最终目标是提高“可控经营利润”,而不是只追求营业额、订单量或会员数量。
## 二、现有数据和分析基础
### 2.1 已有数据资产
| 数据资产 | 当前规模 | 已有主要内容 | 可以支持的管理 |
|---|---:|---|---|
| 原始账单 `public.bill_records` | 约167.8万行、17个源文件、196个原始字段 | 门店、账单、金额、优惠、会员、渠道、人员、营销、理论成本等 | 收入、客单、优惠、会员、渠道、员工及账单风险分析 |
| 菜品销售明细 `public.dish_sales_details` | 约555.3万行、56个源文件 | 91家门店、账单、菜品、分类、数量、单价、实收、时间、出品部门 | SKU、品类、搭售、购物篮、餐段和门店商品结构分析 |
| 盘点倒挤成本 `public.inventory_cost_records` | 54,973行 | 121个成本单位、6,133个原料货品,含期初、购入、期末和耗用 | 门店实际成本、库存、耗用、负倒挤和异常货品分析 |
| 成本单位映射 `public.inventory_store_mapping` | 成本单位到销售门店的映射 | 门店类型、是否纳入经营成本、匹配方式与置信度 | 销售与成本对齐、特殊核算单位剔除 |
| 分析视图 `analytics` | 40余个 | 公司、门店、餐段、会员、平台、成本、库存、风险和行动优先级 | 管理驾驶舱、月报、门店诊断和行动计划 |
菜品明细原始日期覆盖2026年3月20日至5月1日,但现有标准分析以2026年4月完整月份为主。倒挤成本目前只有2026年4月一个月,因此趋势判断能力仍然有限。
### 2.2 已完成的分析
现阶段已经完成并形成SQL或报告的分析包括:
- 第一阶段:整体收入、账单、门店、餐段、会员及初步异常。
- 第二阶段:品类结构、星期规律、小时规律、优惠和高优惠低毛利门店。
- 第三阶段:平台成本、门店风险、营销方案及收银操作风险。
- 第四阶段:经营象限、优惠压降和毛利改善情景、品类依赖。
- 第五阶段:门店餐段机会、会员渗透机会、零实收归因和执行优先级。
- 单店专项:潘家园店经营诊断和30天改善方案。
- 会员复购:整体复购、复购频次、间隔、门店差异和召回建议。
- 标杆分析:综合经营、复购、规模、利润纪律等分项标杆。
- 重点门店:高优惠、低毛利、异常账单、规模承压门店整改。
- 门店计划:91家门店月度目标、行动计划及跟进机制。
- 菜品经营:SKU ABC、品类、搭售、套餐、高折扣SKU和长尾治理。
- 成本管理:89家门店理论成本与实际倒挤成本对比、财务分类和异常货品。
- 深入诊断:P0/P1/P2门店分层、同类原料耗用、库存效率、平台经济性和标杆候选。
这些分析已经具备较好的“发现问题”能力。下一步建设重点应从继续增加报表,转向任务闭环、数据持续更新和经营结果验收。
## 三、现阶段已经确认的关键经营问题
### 3.1 门店不能只看营业额
目前91家门店已经按业态、规模和问题数量分层:
| 层级 | 门店数 | 管理要求 |
|---|---:|---|
| P0-修复数据口径 | 6 | 先修数据,修复前不直接考核成本 |
| P0-综合专项整改 | 4 | 店长、区域、财务和商品联合整改 |
| P1-重点整改 | 23 | 纳入区域经理周度重点督导 |
| P2-单项改善 | 26 | 每店只抓1至2个短板 |
| 标杆候选 | 1 | 拆项验证经验,不整体照搬 |
| 持续跟踪 | 31 | 保持运营,监控指标恶化 |
温泉店、新华北路店、大成路店和欣旺北大街店同时触发多项问题,是当前综合整改的第一优先级。
### 3.2 规模越大不代表成本效率越高
高规模标准店平均实际成本率29.86%,平均超理论23.92%;中规模门店分别为28.70%和21.04%;低规模门店分别为27.55%和16.30%。
这说明高峰期出品份量、备货、报损、赠送、交接和盘点很可能没有随着销量增长而同步标准化。高规模店应成为成本现场管理重点,而不能因为营业额高就放松要求。
### 3.3 平台有规模,但让利负担高
4月三平台实收约1,665.51万元,折扣约654.16万元,佣金约354.39万元,折扣与佣金合计约1,008.55万元,合并加权成本率37.72%。
平台管理必须采用美团、淘宝、京东三平台金额合并加权口径,不能拿某一个平台的最高费率代表门店整体。当前火锅北三环店和菜百店合并加权成本率超过40%,32家门店超过或达到38%,需要逐活动核算贡献毛利。
### 3.4 菜单复杂度远高于销售贡献
现有1,745个菜品名称中:
- 44个A类SKU贡献69.87%的菜品实收。
- 77个B类SKU贡献20.03%。
- 1,624个C类SKU只贡献10.10%。
- 880个SKU仅一家门店销售。
- 1,155个SKU实收不足1,000元。
- 844个SKU月账单不足30张。
建议首轮停用约500个低价值SKU,约300个套餐、赠品、餐具、续面等项目进行合并或转为技术SKU,最终将有效SKU池控制在800至900个左右。不能在数据库物理删除历史SKU,应改为停用或归档。
### 3.5 成本问题已能定位到门店和原料分类,但还不能精确解释原因
同类实际耗用强度偏离主要集中在熟制牛肉、水果蔬菜、羊肉类、调理牛肉、干货食品、牛肉类和加工面点类。
这类结果可以告诉管理人员“先去查哪里”,但不能直接称为真实理论差异或可实现利润。没有完整BOM、标准净料量、出成率和采购批次价格,就不能严格拆成价格差异、用量差异和产品结构差异。
### 3.6 库存和盘点质量需要纳入日常管理
大成路店、丰台北路店、新华北路店、广渠门店等库存天数较高;部分门店还存在负耗用、异常单价和实际成本接近零等口径问题。
库存天数只有在实际成本口径有效时才可以评价。数据异常门店应先修复盘点、调拨、单位换算和成本单位映射,不能把数亿天的异常结果当成真实库存积压。
## 四、数字化运营管理体系总体设计
建议形成“一套经营目标、四级管理责任、七个业务模块、三种管理节奏、一个任务闭环”。
### 4.1 一套经营目标
公司级北极星指标建议采用:
> 可控经营利润 = 实收-实际食材成本-平台佣金-公司承担优惠-门店可控人工-可控损耗
当前因为缺少完整人工、租金、能耗和活动承担方数据,暂时使用“经营质量指标组”代替:
- 实收金额及同比、环比。
- 实际食材成本率及超理论比例。
- 平台折扣与佣金合并加权成本率。
- 会员30日复购率。
- 核心SKU贡献和饮品、小食搭售率。
- 库存天数和异常耗用金额。
- 门店整改任务完成率及改善达成率。
### 4.2 四级管理责任
| 管理层级 | 主要职责 | 应看内容 | 不能只看什么 |
|---|---|---|---|
| 总经理/经营班子 | 决定资源投向和经营策略 | 公司利润桥、区域差异、P0/P1门店、重大风险 | 只看总营业额 |
| 运营、商品、会员、财务、供应链负责人 | 解决跨门店共性问题 | 专项指标、标杆试点、制度执行和数据质量 | 只发排名、不解决机制问题 |
| 区域经理 | 对门店结果和过程负责 | 门店红黄绿、任务完成、现场抽检和周度改善 | 一次给门店十几个指标 |
| 店长 | 当日纠偏和班次执行 | 昨日异常、今日备货、员工任务、核心指标 | 月底才看月报 |
### 4.3 七个业务管理模块
1. 收入与客流管理。
2. 商品、菜单与搭售管理。
3. 会员与复购管理。
4. 优惠、营销和平台管理。
5. 理论成本、实际成本与库存管理。
6. 人员、班次和门店执行管理。
7. 风险、审计和数据质量管理。
## 五、公司到门店的指标树
### 5.1 收入增长
营业收入拆成:
> 实收 = 有效账单数 × 平均客单价
账单数继续拆成门店、日期、星期、餐段、小时、堂食/外卖和会员/非会员;客单价继续拆成每单件数、商品单价、搭售和优惠。
管理应用:
- 账单数下降:优先查客流、营业时间、渠道曝光、缺货和商圈变化。
- 客单下降:优先查优惠、低价品占比、搭售和套餐结构。
- 收入增长但毛利下降:优先查平台、折扣、商品结构和实际成本。
- 只有某餐段下降:不做全店促销,针对该餐段调整产品和排班。
### 5.2 商品效率
核心指标:
- SKU实收、销量、账单数、覆盖门店数。
- A/B/C分类和销售集中度。
- 每单件数、每单SKU数。
- 饮品、小食、凉菜搭售率。
- 套餐渗透率及套餐内商品结构。
- 新品试销达成率、长尾SKU数、停用后转购率。
- SKU缺货率和对应损失销售额,补充数据后启用。
商品部不能只看“卖了多少”,还要同时看毛利、复购、库存占用、出品复杂度和对核心原料的影响。
### 5.3 会员与复购
核心指标:
- 会员识别率、会员账单占比、会员收入占比。
- 新增会员数和首购会员数。
- 首购后7日、14日、30日复购率。
- 复购频次、复购间隔和复购收入占比。
- 沉睡会员数、触达率、召回率和召回毛利。
- 会员与非会员客单、优惠和毛利差异。
管理重点不是“拉了多少新会员”,而是新会员是否发生第二次消费。会员活动应以30日复购和贡献毛利验收,不能只看领券量和核销量。
### 5.4 优惠、营销与平台
核心指标:
- 优惠率和优惠金额。
- 各营销方案订单数、实收、客单和贡献毛利。
- 活动新客率、活动后14日/30日复购率。
- 平台原价收入、折扣、实收、佣金和平台贡献毛利。
- 三平台合并加权成本率。
- 全额优惠、零实收账单数及授权合规率。
建议管理阈值:
| 指标 | 黄色预警 | 红色预警 | 处理要求 |
|---|---:|---:|---|
| 门店优惠率 | 接近或超过同类门店中位数 | ≥23.02%且毛利偏低 | 拆活动、拆渠道,核算贡献毛利 |
| 三平台合并加权成本率 | ≥38% | ≥40% | 逐活动停、改、留 |
| 全额优惠/零实收 | 超过门店正常水平 | 连续异常或大额异常 | 店长当日说明,区域次日复核 |
| 活动30日复购 | 低于门店自然复购 | 活动带来负贡献毛利 | 停止或重做活动 |
### 5.5 成本与库存
核心指标:
- 理论食材成本率和理论毛利率。
- 实际倒挤成本率。
- 实际成本与理论成本差异金额、差异率。
- 原料分类每万元销售耗用及同类偏离。
- 期末库存、库存天数、负耗用和异常货品。
- 报损率、调拨差异、采购价格差异和标准用量差异,补充数据后启用。
建议管理阈值:
| 指标 | 黄色预警 | 红色预警 | 责任人 |
|---|---:|---:|---|
| 实际成本超理论比例 | 10%至20% | ≥20% | 店长、厨师长、区域、财务 |
| 库存天数 | 8至10天 | >10天 | 店长、供应链 |
| 负耗用货品 | 少量偶发 | 高频或大额 | 财务、信息、门店 |
| 原料同类偏离 | 单类持续偏高 | 多分类同时偏高 | 区域、商品、供应链 |
成本问题应按以下顺序处理:
1. 先确认数据口径是否有效。
2. 再定位门店、原料分类和具体货品。
3. 到店核查盘点、调拨、收货、储存、加工、份量和报损。
4. 有BOM后再拆采购价格差异、标准用量差异和产品结构差异。
5. 整改后连续观察至少四周,不能只看一次盘点结果。
### 5.6 人员和班次执行
现有账单包含收银员、值台员和班次信息,可以先做:
- 员工账单数、实收和客单。
- 饮品、小食和套餐推荐率。
- 优惠使用率、撤单和零实收账单。
- 不同班次的客单、搭售和异常率。
但现阶段不能精确评价人效,因为缺少工时、岗位、排班和工资数据。不能简单用营业额除以员工姓名数作为人效,也不能把高峰班和低峰班直接比较。
### 5.7 风险与内控
重点监控:
- 大额异常账单。
- 零实收和全额优惠。
- 异常折扣、撤单、反结账、退款和赠送。
- 同一员工或门店异常集中操作。
- 负库存、负倒挤、零成本和异常单价。
- 门店、SKU、成本单位和会员ID映射失败。
风险指标是线索,不是定罪。必须结合审批、业务场景、视频或原始单据复核,避免只凭报表处罚员工。
## 六、真正落地的日、周、月管理动作
### 6.1 门店每日:只看一页、只抓当天能改的事
建议店长每天开店前用10分钟查看“昨日经营卡”:
| 内容 | 昨日要看什么 | 当天能做什么 |
|---|---|---|
| 收入 | 实收、账单、客单,与最近4个同星期比较 | 调整当日目标和重点餐段安排 |
| 商品 | 核心SKU销量、缺货、饮品和小食搭售 | 备货、陈列、收银话术、停售缺料商品 |
| 优惠 | 优惠率、异常优惠和平台活动 | 核对大额优惠,停止错误活动配置 |
| 会员 | 新会员、二次到店、待召回会员 | 分配当日会员回访名单 |
| 成本 | 高价值原料领用、报损、盘点异常 | 称重抽查、补录报损、核对调拨 |
| 风险 | 零实收、撤单、退款、异常员工操作 | 当日说明并上传凭证 |
门店每天不应接收几十个指标。系统只推送最重要的3个异常和最多3项任务。
### 6.2 餐前、餐中、餐后
餐前:
- 按同星期、同餐段和天气/节假日预测核心SKU需求。
- 检查高价值原料、饮品和套餐物料是否充足。
- 明确本班次一个搭售重点和一个服务重点。
餐中:
- 关注缺货、出餐积压、异常折扣和平台爆单。
- 高峰期重点守份量和出品稳定,不能为了速度取消标准。
餐后:
- 核对核心原料领用、剩余、报损和赠送。
- 复盘本餐段账单、客单、搭售和异常操作。
- 对未完成任务填写原因,不允许只填“客流不好”。
### 6.3 区域每周:解决问题,不开排名会
每周会议控制在60分钟:
1. 10分钟看结果:收入、实际成本、优惠、复购和库存。
2. 20分钟看P0/P1门店:每店只讲问题、动作、结果和阻碍。
3. 15分钟看一个专项:例如牛肉耗用、平台活动或饮品搭售。
4. 10分钟看标杆试点:哪些动作有效,是否可复制。
5. 5分钟确认责任人和截止日期。
区域经理应抽查过程证据:盘点表、称重记录、活动配置截图、会员触达记录和员工培训记录。只看门店口头汇报无法形成闭环。
### 6.4 总部每月:形成经营闭环
月度经营会建议固定输出:
- 公司和区域利润桥。
- 门店P0/P1/P2升降级。
- 上月任务完成率和指标改善率。
- 平台活动停、改、留清单。
- SKU新增、停用、恢复和库存影响。
- 采购价格、原料耗用和盘点异常。
- 会员新增、复购、召回和活动贡献。
- 标杆试点是否扩大推广。
月度验收不能只看“任务是否完成”,还要看指标是否改善。拍照、培训、开会属于过程完成;成本下降、复购提升、异常减少才是结果完成。
## 七、门店问题分级和任务闭环
### 7.1 问题分级
| 等级 | 定义 | 响应时间 | 典型问题 |
|---|---|---:|---|
| P0 | 数据失真、重大风险或四项以上综合问题 | 当日确认、7天内形成方案 | 成本口径异常、重大账单异常、综合经营失控 |
| P1 | 对利润或顾客持续产生明显影响 | 48小时确认、当月整改 | 高成本、高优惠、低毛利、低复购 |
| P2 | 单项指标偏弱但总体可控 | 一周内纳入计划 | 饮品搭售低、单餐段弱、库存略高 |
| P3 | 正常波动或观察项 | 持续监控 | 一次性轻微偏离 |
### 7.2 每个任务必须包含的字段
- 问题门店、问题指标和发生日期。
- 当前值、同类基准和目标值。
- 问题说明及可能原因。
- 具体行动,不允许只写“加强管理”。
- 责任人、协同人和截止日期。
- 过程证据。
- 验收指标和验收结果。
- 未完成原因及下一步。
### 7.3 一店一策的约束
- 每家门店每月最多两个核心改进指标。
- P0门店可增加数据修复任务,但必须明确先后顺序。
- 同一指标连续两周不改善,应重新判断原因,不能简单重复原动作。
- 指标改善但顾客投诉、缺货或营业额明显恶化,应回滚或调整方案。
## 八、标杆复制机制
当前已经识别三里河店、总部基地店、双安总店、牛街店、樱花东街店、复兴门店等不同维度标杆;深入综合模型中总部基地店为当前综合标杆候选。
标杆不能按“整店经验”复制,应拆成以下模块:
- 核心SKU和商品结构。
- 会员首购转复购流程。
- 平台活动和价格纪律。
- 高峰备货、份量和盘点流程。
- 饮品、小食和套餐话术。
- 店长日清和班次交接。
每个模块选择2至3家同业态、同规模门店试点,观察4周。只有试点门店指标改善且没有损害收入、顾客体验和库存,才形成标准作业程序推广。
## 九、驾驶舱应该怎么设计
### 9.1 总部经营驾驶舱
首页只保留:
- 实收、账单、客单和趋势。
- 理论成本率、实际成本率和差异。
- 平台合并加权成本率。
- 会员30日复购率。
- P0/P1门店数和实收覆盖。
- 未完成重大任务和重大异常。
所有指标都必须可以下钻到区域、门店、餐段、渠道、SKU、原料和任务。
### 9.2 区域经理页面
- 门店红黄绿地图或列表。
- 本周需要到店检查的门店。
- 各店最多两个核心指标。
- 任务责任人、截止日和当前状态。
- 同类门店基准和最近四周趋势。
### 9.3 店长手机页面
- 昨日3个主要异常。
- 今日3项待办。
- 核心SKU备货和缺货提醒。
- 本周指标进度。
- 需要上传的照片、单据或说明。
手机端不应直接塞入总部大屏的全部图表,应优先展示任务、异常和可执行动作。
### 9.4 专业部门页面
- 商品部:SKU ABC、新品、长尾、搭售、缺货和商品毛利。
- 会员部:首购、复购、沉睡、召回和活动贡献。
- 财务:理论与实际成本、平台费用、异常优惠和门店利润。
- 供应链:采购价格、库存、调拨、损耗和供应商表现。
- 信息部:数据更新状态、映射失败、重复记录和接口异常。
## 十、现有数据结构的问题
### 10.1 原始账单表过宽且全部为文本
`bill_records`使用`c001``c196`保存原始字段,实际含义依赖`bill_columns`映射,并且所有业务字段均为文本。
实际问题:
- SQL难读,开发和维护成本高。
- 金额、日期和数量每次都需要转换。
- 字段位置变化可能导致口径错误。
- 数据校验、索引和查询性能受影响。
建议保留原始表作为审计层,同时建设规范事实表,至少将账单号、门店、日期、金额、人员、会员和渠道转换为明确字段及正确数据类型。
### 10.2 菜品没有稳定SKU编码
现有菜品经营主要依赖菜品名称。名称相近、同名异码、渠道命名、套餐组件、赠品、餐盒和续面混在普通菜品中,导致SKU数量膨胀和跨月追踪困难。
必须建设统一商品主数据:
- 标准SKU编码和标准名称。
- POS编码、外卖平台编码及历史编码映射。
- 商品类型:单品、套餐主品、套餐组件、赠品、包装、服务项目。
- 在售状态、生效日期、停用日期。
- 品牌、业态、区域和适用门店。
- 新品、季节品、区域品和战略品标签。
### 10.3 成本只有一个月
盘点倒挤成本目前只有2026年4月,无法判断门店是长期异常还是单月盘点波动,也无法对整改成果做可靠趋势验证。
应每月稳定导入,并逐步增加日库存或周库存快照。
### 10.4 部分成本单位与销售门店不能一一对应
成本报表含121个核算单位,而销售门店为91家,其中包括中央厨房、仓库、闭店、特殊业务或其他核算单位。当前已建立映射表,但仍需业务人员确认低置信度映射和新增单位。
### 10.5 现有分析视图部分固化为2026年4月
如部分视图名称带`_april`,每月复制SQL容易出现口径漂移。应改造成通用事实表和按`report_month`分区的月度汇总表,驾驶舱通过月份参数筛选。
## 十一、还缺什么数据
### 11.1 第一优先级:没有这些数据,就算不清真实利润和成本原因
#### 1. 菜品BOM和版本
至少包括:
- SKU编码、原料编码、标准毛料量、标准净料量、单位。
- 出成率、损耗率、替代原料。
- BOM版本、生效日期、失效日期和适用门店。
- 套餐展开关系、加料和做法对用量的影响。
作用:计算SKU标准成本、理论用量、用量差异和真正的门店损耗。
#### 2. 采购明细
至少包括:采购单、供应商、仓库/门店、货品、数量、含税/不含税单价、税率、收货日期、退货和批次。
作用:拆采购价格差异、供应商表现和区域采购异常。
#### 3. 报损、调拨和领用明细
至少包括:门店、原料、数量、金额、原因、操作人、审批人和时间。
作用:把实际耗用偏高拆成正常销售、报损、调拨遗漏、员工餐、赠送或盘点误差。
#### 4. 人员排班、工时和工资
至少包括:员工、岗位、门店、日期、班次、计划工时、实际工时、工资和加班。
作用:计算真实人效、工时成本率、餐段排班合理性和高峰人手缺口。
#### 5. 门店固定和半变动费用
至少包括:租金、物业、能耗、维修、外包、折旧及其他门店费用。
作用:形成门店完整损益,避免高营业额低利润门店被误判为标杆。
### 11.2 第二优先级:决定增长质量和顾客体验
#### 1. 统一会员主数据
- 统一会员ID、手机号脱敏标识、注册渠道、注册门店和会员等级。
- 跨门店、堂食、外卖和小程序身份合并。
- 券领取、使用、过期和触达记录。
作用:建立可信的7日、14日、30日复购和顾客生命周期分析。
#### 2. 营销活动主数据
- 活动ID、名称、渠道、开始结束日期、适用门店和SKU。
- 平台、公司和门店各自承担金额。
- 活动目标、目标客群和预算。
作用:计算活动真实投入、贡献毛利和新客转复购,而不是只看优惠金额。
#### 3. 外卖订单明细
- 平台订单号、商品、标价、优惠承担方、佣金、配送费、退款、取消和赔付。
作用:建立订单级平台损益,识别“越卖越亏”的活动和SKU。
#### 4. 缺货和停售记录
- SKU、门店、缺货开始/结束时间、原因和替代商品。
作用:估算缺货损失销售额,区分客流下降和供给不足。
#### 5. 顾客评价和投诉
- 平台、门店、时间、评分、标签、文本、处理状态和赔付。
作用:把经营变化与口味、服务、卫生、速度和缺货关联。
### 11.3 第三优先级:进一步提升预测和现场管理
- 天气、节假日、商圈活动和门店周边事件。
- 桌台、排队、叫号、取餐和出餐时间。
- 厨房生产批次、废弃和留样。
- 设备状态、维修和能耗。
- 仓库库存、配送、到货及时率和缺货率。
- 食品安全巡检、温控和保质期数据。
## 十二、建议的目标数据结构
建议采用“原始层—标准层—事实层—汇总层—应用层”五层结构。
### 12.1 原始层
原样保存POS、菜品、库存、采购、会员、平台和排班文件,不修改源数据,用于追溯和审计。
### 12.2 标准主数据层
| 主数据表 | 关键内容 |
|---|---|
| `dim_store` | 门店编码、区域、业态、开闭店日期、面积、座位、商圈和可比属性 |
| `dim_sku` | 标准SKU、类型、品类、状态、渠道编码及适用门店 |
| `dim_material` | 原料编码、分类、规格、基本单位及换算关系 |
| `dim_member` | 统一脱敏会员标识、注册信息和等级 |
| `dim_employee` | 员工、岗位、门店和任职日期 |
| `dim_channel` | 堂食、外卖平台、小程序、团购等统一渠道 |
| `dim_promotion` | 活动ID、承担方、预算和适用范围 |
| `dim_calendar` | 日期、星期、节假日、天气和经营日属性 |
### 12.3 事实数据层
| 事实表 | 建议粒度 |
|---|---|
| `fact_bill` | 一张有效账单一行 |
| `fact_bill_item` | 一张账单一个SKU一行 |
| `fact_payment` | 一张账单一种支付方式一行 |
| `fact_promotion_usage` | 一张账单一个活动一行 |
| `fact_platform_order_profit` | 一个平台订单一行 |
| `fact_inventory_snapshot` | 门店×原料×日期一行 |
| `fact_purchase_receipt` | 一次收货×货品一行 |
| `fact_transfer` | 一次调拨×货品一行 |
| `fact_waste` | 一次报损×货品一行 |
| `fact_recipe_bom` | SKU×原料×BOM版本一行 |
| `fact_employee_shift` | 员工×日期×班次一行 |
| `fact_customer_contact` | 会员×一次触达或权益动作一行 |
| `fact_store_task` | 门店×问题×整改任务一行 |
### 12.4 汇总层
建设公司、区域、门店、餐段、SKU、会员、平台、原料和任务的日、周、月汇总表。所有月报和驾驶舱从统一汇总层读取,避免不同报表各算一遍造成口径不一致。
### 12.5 应用层
- 管理驾驶舱。
- 每日经营卡。
- 区域周报。
- 月度经营报告。
- 门店行动计划。
- 异常预警与审计清单。
- SKU停用、活动优化、会员召回和库存补货任务。
## 十三、数据治理规则
### 13.1 统一编码
门店、SKU、原料、会员、员工、渠道、活动必须使用稳定编码。名称只能用于展示,不能作为唯一关联键。
### 13.2 数据质量检查
每次导入自动检查:
- 文件是否齐全、日期是否完整。
- 行数和金额是否与源文件总计一致。
- 门店和SKU映射失败数量。
- 重复账单、重复明细和跨文件重复。
- 空日期、空门店、空账单号和非数字金额。
- 实收大于消费、异常负数、零成本和异常单价。
- 账单与菜品明细金额是否对账。
- 销售门店与成本单位是否匹配。
### 13.3 指标字典
每个指标必须写清:名称、业务定义、计算公式、数据来源、更新频率、负责人、适用范围、异常阈值和版本日期。
### 13.4 口径变更
口径变更必须保留版本和生效日期,不能直接覆盖历史结果。月报出具后如需更正,应记录更正原因和影响金额。
## 十四、实施路线图
### 第一阶段:0至30天——让现有分析真正进入管理
- 固定公司、区域和门店核心指标口径。
- 每日生成门店经营卡和异常任务。
- 使用现有P0/P1/P2模型管理91家门店。
- 运行区域周会和月度验收机制。
- 完成6家成本口径异常门店修复。
- 启动500个SKU停用、300个技术SKU清理。
- 将驾驶舱门店范围筛选、手机端和任务状态真正打通。
验收标准:门店任务有责任人、有截止日、有结果;P0/P1门店问题可以下钻到具体指标和原始明细。
### 第二阶段:31至90天——补齐成本和利润主链路
- 持续导入至少3个月销售、菜品和库存成本数据。
- 建设SKU、原料和门店主数据。
- 导入采购、BOM、报损、调拨和活动主数据。
- 建立门店利润表和平台订单损益。
- 将现有4月固定视图改为通用月度模型。
- 建立周趋势、同比、环比和整改前后对照。
验收标准:可以解释门店成本差异主要来自价格、用量还是产品结构;可以判断活动是否真正赚钱。
### 第三阶段:3至6个月——形成预测和精细化运营
- 接入排班工时、工资、天气、节假日、缺货和顾客评价。
- 建立餐段销量预测、备货建议和排班建议。
- 建立会员分层、流失预警和自动召回人群。
- 建立SKU新品准入和旧品退出机制。
- 建立供应商价格、质量和到货表现评价。
验收标准:系统不仅解释过去,还能提前给出备货、排班、会员和商品行动建议。
## 十五、组织和绩效保障
### 15.1 指标负责人
- 实收、账单和门店任务:运营负责人。
- 商品结构、搭售和SKU治理:商品负责人。
- 会员复购和召回:会员负责人。
- 优惠和平台损益:运营、会员、财务共同负责。
- 理论成本、实际成本和库存:财务、供应链、运营共同负责。
- 数据接口和质量:信息负责人。
### 15.2 绩效原则
- 店长绩效不能只挂营业额,应同时考虑成本、顾客、执行和风险。
- 不考核口径不可靠的数据。
- 不因单月偶发波动直接处罚,应看同类对标和连续趋势。
- 不鼓励通过压库存、少报损、少赠送或停止必要服务来“做低成本”。
- 标杆门店必须同时满足结果好、数据可信、过程可复制。
建议店长绩效参考结构:经营结果40%、成本与库存25%、顾客和会员15%、执行任务15%、风险与数据质量5%。具体权重可按业态调整。
## 十六、管理层应坚持的十条原则
1. 营业额不是利润,订单量不是经营质量。
2. 门店必须同业态、同规模比较。
3. 一个门店每月最多抓两个核心问题。
4. 数据异常先修口径,再追经营责任。
5. 平台必须看合并加权成本和贡献毛利。
6. 会员必须看第二次消费,不只看新增。
7. SKU必须有准入和退出,不能只增不减。
8. 成本偏离是排查线索,不能在缺BOM时当成真实浪费。
9. 标杆经验要拆项试点,不能整店照搬。
10. 每个指标最终都要落到责任人、动作、截止日和验收结果。
## 十七、最终建议
现有数据库和分析成果已经足以支持一套实用的基础运营管理体系,当前最重要的不是继续增加大量分析主题,而是把已有分析转成稳定的管理动作:
- 总部每月确定经营重点和资源投向。
- 区域每周管理P0/P1门店和专项任务。
- 店长每天处理少量、明确、可执行的异常。
- 商品、会员、财务和供应链围绕同一门店任务协同。
- 每月用统一口径检验是否改善,并将有效经验标准化。
同时,应优先补齐BOM、采购、报损调拨、排班工时、活动承担方和门店费用。只有这些数据稳定接入,才能从目前的“经营诊断体系”升级为真正的“门店利润管理、成本归因和预测决策体系”。
数字化运营的最终成果,不是报表更多,而是门店少犯错误、问题更早被发现、整改有人负责、成本能说清楚、活动知道赚不赚钱、好经验可以快速复制。