初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
*.png
|
||||
马兰拉面数据分析/
|
||||
*.xlsx
|
||||
*.zip
|
||||
.playwright-mcp/
|
||||
@@ -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% |
|
||||
@@ -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>
|
||||
Generated
+3487
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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实际成本 · 原料分类差异 · 库存效率 · 2026年4月</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>
|
||||
)
|
||||
}
|
||||
@@ -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">全部门店经营总览 · 2026年4月</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">会员对比 · 复购率 · 沉睡分析 · 2026年4月</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 · 优惠结构 · 2026年4月</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">异常账单 · 零实收 · 收银风险 · 2026年4月</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>
|
||||
)
|
||||
}
|
||||
@@ -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分析 · 长尾治理 · 品类结构 · 2026年4月</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,000的SKU,停用前需确认是否有区域特色或套餐组件</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">最新经营概览(5月1日)</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">星期规律 · 小时分布 · 渠道趋势 · 2026年4月</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>
|
||||
)
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 $$;
|
||||
Generated
+372
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
当前数据库是本机Homebrew PostgreSQL 15,数据库名为 bill_query,端口5432,仅监听 localhost;当前用户 freedak 是超级用户,本机认证方式为 trust。这适合本机开发,但不适合直接照搬到服务器生产环境。前端浏览器也不应直接连接PostgreSQL,必须通过后端API访问。
|
||||
Generated
+1840
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = '月度验收';
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 VIEW(bill_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
@@ -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 15(Homebrew),`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 | 任务API(CRUD+验收)、标杆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、采购、报损调拨、排班工时、活动承担方和门店费用。只有这些数据稳定接入,才能从目前的“经营诊断体系”升级为真正的“门店利润管理、成本归因和预测决策体系”。
|
||||
|
||||
数字化运营的最终成果,不是报表更多,而是门店少犯错误、问题更早被发现、整改有人负责、成本能说清楚、活动知道赚不赚钱、好经验可以快速复制。
|
||||
Reference in New Issue
Block a user