docs: 新增智脑实施方法论文档体系(7篇) + fix: 态势感知关联分析修复

文档:
- 01-项目概述与架构: 技术架构、数据流、部署拓扑
- 02-数据接入与治理: 原始数据导入、物化视图、门店名映射
- 03-指标体系与API开发: 指标分层、SQL模式、常见陷阱
- 04-前端页面开发: 组件规范、页面模板、月份参数管理
- 05-部署与运维: 部署脚本、FRP隧道、PM2、备份
- 06-调试排查手册: 问题分类、8个实际案例、工具速查
- 07-通用方法论: 核心原则、实施阶段、快速复制Checklist

修复:
- situational-awareness.ts: salary_month→salary_period, 日期格式改中文
- 客流-人力匹配: 改用attendance_records打卡数据解析在岗人数
- 客流数据除以30天对齐日均
This commit is contained in:
freedakgmail
2026-08-12 11:00:54 +08:00
parent 2c5b03e41b
commit c2a9e27e49
9 changed files with 1066 additions and 30 deletions
@@ -0,0 +1,71 @@
# 01 · 项目概述与架构
## 1. 项目背景
为连锁餐饮企业(西部马华,100+门店)构建数据分析智脑平台,覆盖:
- **老板驾驶舱**:门店风险评级、营收概览、平台经济性
- **门店分级**:红/黄/绿三色风险评级与明细
- **态势感知**:健康度评分、自动化预警、跨模块关联分析、趋势预测
- **风险明细**:异常账单、零实收、收银员风险
- **智能排班**:客流热力图、排班匹配度、人效对标、考勤预警
- **成本分析**:理论成本vs实际成本、成本方差、菜品级分析
- **会员复购**:会员占比、复购率、营销方案效果
- **SKU分析**:ABC分类、菜品销售结构
## 2. 技术架构
```
┌─────────────────────────────────────────────────┐
│ 前端 (React + TypeScript + Vite) │
│ - Recharts 图表库 │
│ - @tanstack/react-query 数据请求 │
│ - 自研组件: MetricCard / FilterableTable / │
│ CollapsibleSection / MonthPicker │
│ - 部署到 Nginx 静态文件 │
├─────────────────────────────────────────────────┤
│ 后端 (Express + TypeScript) │
│ - 路由按模块拆分: data.ts / situational- │
│ awareness.ts / smart-scheduling.ts / │
│ cost-analysis.ts / store-expense.ts │
│ - pg 库直连 PostgreSQL │
│ - JWT 认证 + 数据权限 (getDataScope) │
│ - PM2 进程管理 │
├─────────────────────────────────────────────────┤
│ 数据库 (PostgreSQL 15) │
│ - bill_query: 业务数据 (原始表 + 物化视图) │
│ - sbrain_admin: 平台管理 (租户/用户/配置) │
│ - FRP 隧道: 本地5432 → 服务器15432 │
└─────────────────────────────────────────────────┘
```
## 3. 数据流
```
原始Excel/CSV → 导入脚本 → bill_records / salary_detail_records / attendance_records
物化视图层 (mv_*)
┌─────────────┼─────────────┐
↓ ↓ ↓
mv_bill_hourly mv_risk_* mv_store_*_monthly
↓ ↓ ↓
后端API (SQL查询)
前端Dashboard
```
## 4. 部署拓扑
| 组件 | 位置 | 说明 |
|------|------|------|
| 前端 | 服务器 Nginx | `/var/www/dm.all8ai.top` 静态文件 |
| 后端 | 服务器 PM2 | `sbrain-server` 进程,端口9333 |
| 数据库 | 本地 PostgreSQL | 通过FRP隧道暴露到服务器15432 |
| 域名 | dm.all8ai.top | Nginx反向代理 `/api/` → 后端 |
## 5. 关键设计决策
1. **物化视图而非实时查询**:原始表千万级,物化视图预聚合保证API响应速度
2. **FRP隧道而非云数据库**:数据安全要求高,数据库留在本地,通过隧道供服务器访问
3. **多租户SaaS架构**`sbrain_admin` 管理租户配置,`tenant_configs` 表驱动数据源连接
4. **数据权限分层**:总部看全部门店、区域经理看本区域、店长看本店
5. **默认月份参数**:前端统一使用 `DEFAULT_MONTH` 常量,避免新月份无数据时空白
@@ -0,0 +1,107 @@
# 02 · 数据接入与治理
## 1. 原始数据导入
### 1.1 数据源
| 表名 | 数据来源 | 关键字段 | 数据量级 |
|------|---------|---------|---------|
| `bill_records` | 收银系统导出 | c003(门店) c005(账单号) c009(消费) c068(优惠) c114(实收) c175(下单时间) c176(结账时间) c191(收银员) | 千万级/月 |
| `salary_detail_records` | 薪资系统导出 | org_level5(门店) salary_period(薪资周期) actual_attend(实际出勤) actual_hours(实际工时) | 万级/月 |
| `attendance_records` | 考勤系统导出 | department(部门路径) position(岗位) day_01~day_31(每日打卡) | 万级/月 |
### 1.2 导入关键点
- **字段映射**:原始Excel列名(c001~c200)需建立映射文档,明确每列含义
- **日期格式**`salary_period` 格式为中文"2026年4月",非"2026-04",查询时需用 `to_char($1::date, 'YYYY"年"FMMM"月"')`
- **空值处理**:实收字段 c114 可能为空字符串,需 `COALESCE(NULLIF(c114,'')::numeric, 0)`
- **数据去重**:导入时按 record_id + import_id 去重
### 1.3 门店名映射
不同数据源门店名不一致,需维护 `store_name_mapping` 映射表:
```sql
CREATE TABLE public.store_name_mapping (
salary_name TEXT, -- 薪资/考勤系统中的名称
bill_name TEXT -- 账单系统中的名称
);
```
**典型映射**
| salary_name | bill_name |
|-------------|-----------|
| 双安店 | 双安总店 |
| 百子湾店 | 百子湾路店 |
| 安宁庄快手店 | 安宁庄快手 |
| 海淀大街店 | 海淀大街 |
| 哈马尔罕大钟寺店 | 大钟寺店 |
| 阿里疆(温泉路店) | 温泉店 |
**映射查找模式**(代码中复用):
```typescript
// 构建查找表:同时用原名和映射名
const staffLookup: Record<string, any> = {}
for (const [store, data] of Object.entries(staffSummary)) {
staffLookup[store] = data // 原名
const mapped = nameMap[store]
if (mapped && mapped !== store) {
staffLookup[mapped] = data // 映射名
}
}
```
## 2. 物化视图体系
### 2.1 核心物化视图
| 视图名 | Schema | 用途 | 关键字段 |
|--------|--------|------|---------|
| `mv_store_risk_rating_monthly` | analytics | 门店风险评级 | store_code, store_name, risk_level, received, month_start |
| `mv_store_platform_economics_monthly` | analytics | 平台经济性 | store_code, meituan_received, taobao_received, jd_received |
| `mv_bill_hourly` | public | 小时客流 | store_name, hour, bills, avg_guests |
| `mv_risk_anomaly` | public | 异常账单 | store_name, bill_no, consumption, anomaly_reason, month |
| `mv_risk_zero` | public | 零实收 | store_name, bill_no, zero_received_type, month |
| `mv_risk_cashier` | public | 收银员风险 | store_name, cashier, bill_count, anomaly_bills, month |
| `mv_channel_daily` | public | 支付渠道 | business_date, cash, alipay, wechat, meituan, month |
| `mv_time_hourly` | public | 时间维度汇总 | closing_hour, bill_count, received, month |
### 2.2 物化视图管理要点
1. **Schema前缀**:部分视图在 `analytics` schema,部分在 `public` schema,代码中需注意
2. **刷新机制**:物化视图需手动刷新 `REFRESH MATERIALIZED VIEW`,不自动更新
3. **索引**:重建物化视图后需重新创建索引
4. **阈值调优**:异常判断阈值需根据业务调整(如"消费-优惠与实收不平"阈值从0.05元提高到1元)
### 2.3 异常账单阈值设计
```sql
-- 异常类型判断(CASE WHEN顺序重要)
CASE
WHEN consumption > 0 AND received = 0 THEN '有消费无实收'
WHEN discount > consumption THEN '优惠大于消费'
WHEN abs(consumption - discount - received) > 1 THEN '消费-优惠与实收不平'
ELSE NULL
END
```
**关键教训**:阈值0.05元太严格,会将舍入差异标为异常。建议初始阈值设为1元,后续根据数据分布调整。
## 3. 数据质量校验
### 3.1 校验流程
1. **数据范围**`SELECT DISTINCT month FROM bill_records ORDER BY month` 确认最新月份
2. **门店数**`SELECT count(DISTINCT c003) FROM bill_records WHERE month = '2026-04'`
3. **金额一致性**`SELECT sum(c009), sum(c068), sum(c114)` 对比前后端
4. **物化视图刷新状态**:对比物化视图和原始表的记录数
### 3.2 常见数据问题
| 问题 | 症状 | 排查方法 |
|------|------|---------|
| 物化视图stale | API数据与数据库不一致 | 直接查原始表对比物化视图 |
| 月份格式不匹配 | 查询返回0条 | 检查 salary_period 实际格式 |
| 门店名不一致 | 部分门店无数据 | 对比不同表的门店名列表 |
| Schema前缀错误 | `relation does not exist` | 检查 `\dn``pg_matviews` |
| 列不存在 | `column does not exist` | 检查 `information_schema.columns` |
@@ -0,0 +1,186 @@
# 03 · 指标体系与API开发
## 1. 指标分层设计
```
L0: 原始数据层 (bill_records, salary_detail_records, attendance_records)
L1: 预聚合层 (物化视图 mv_*)
L2: API层 (后端路由,SQL查询 + 业务逻辑)
L3: 展示层 (前端页面,图表 + 表格)
```
## 2. 后端API规范
### 2.1 路由组织
```
server/src/routes/
├── data.ts # 核心数据API(概览、门店、风险明细等)
├── situational-awareness.ts # 态势感知(健康度、预警、关联分析)
├── smart-scheduling.ts # 智能排班
├── cost-analysis.ts # 成本分析
├── store-expense.ts # 门店费用
├── analytics-enhanced.ts # 增强分析
├── admin.ts # 管理
└── tts.ts # 语音
```
### 2.2 通用模式
```typescript
// 月份参数解析
const month = parseMonth(req) // 从 query 参数获取,默认 '2026-04'
// 分页
const { page, pageSize, offset } = parsePagination(req)
// 数据权限
const scope = getDataScope(req) // 返回 store_names 数组或 null(全部)
// 响应格式
sendSuccess(res, data, meta?) // { success: true, data, meta }
sendError(res, message) // { success: false, error: message }
```
### 2.3 SQL查询模式
**安全参数化**
```typescript
// 正确:参数化查询
const result = await query(`SELECT * FROM mv_risk_anomaly WHERE month = to_char($1::date, 'YYYY-MM')`, [month])
// 错误:字符串拼接
const result = await query(`SELECT * FROM mv_risk_anomaly WHERE month = '${month}'`)
```
**动态条件构建**
```typescript
const conditions: string[] = [`month = to_char($1::date, 'YYYY-MM')`]
const params: any[] = [month]
let paramIdx = 2
if (storeName) {
conditions.push(`store_name = $${paramIdx}`)
params.push(storeName)
paramIdx++
}
const whereClause = conditions.join(' AND ')
```
**聚合汇总+分页**
```typescript
// 先查总数和汇总
const countResult = await query(`SELECT count(*) AS total, sum(consumption) AS sum_consumption FROM mv_risk_anomaly WHERE ${whereClause}`, params)
// 再查明细
const result = await query(`SELECT * FROM mv_risk_anomaly WHERE ${whereClause} ORDER BY ${sortCol} ${order} LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, [...params, pageSize, offset])
```
## 3. 常见SQL陷阱与修复
### 3.1 Schema前缀错误
**症状**`relation "analytics.mv_channel_daily" does not exist`
**排查**
```sql
SELECT schemaname, matviewname FROM pg_matviews WHERE matviewname LIKE '%channel%';
```
**修复**:确认实际schema,移除或修正前缀
```typescript
// 错误:FROM analytics.mv_channel_daily
// 正确:FROM mv_channel_daily (实际在public schema)
```
### 3.2 列名不存在
**症状**`column "risk_score" does not exist`
**排查**
```sql
SELECT column_name FROM information_schema.columns WHERE table_name = 'mv_store_risk_rating_monthly';
```
**修复策略**
- 策略A:改用 `SELECT *`(当不需要特定列名时)
- 策略B:用 `0 AS column_name` 替代不存在的列(当前端期望该字段时)
- 策略C:修正为正确的列名
### 3.3 日期格式不匹配
**症状**:薪资相关查询返回0条
**排查**
```sql
SELECT DISTINCT salary_period FROM salary_detail_records ORDER BY 1 DESC LIMIT 5;
-- 结果:2026年4月(非2026-04
```
**修复**
```typescript
// 错误:s.salary_month = to_char($1::date, 'YYYY-MM')
// 正确:s.salary_period = to_char($1::date, 'YYYY"年"FMMM"月"')
```
### 3.4 物化视图stale
**症状**API返回数据与直接查原始表不一致
**排查**:对比物化视图和原始表的count
```sql
SELECT count(*) FROM mv_risk_anomaly WHERE month = '2026-04';
SELECT count(*) FROM bill_records WHERE to_char(c176::timestamp, 'YYYY-MM') = '2026-04' AND ...;
```
**修复**:重建物化视图
```sql
DROP MATERIALIZED VIEW mv_risk_anomaly;
CREATE MATERIALIZED VIEW mv_risk_anomaly AS ...;
CREATE INDEX idx_mv_risk_anomaly_month ON mv_risk_anomaly(month);
```
## 4. 考勤打卡数据解析
### 4.1 department路径解析门店名
```
北京西部马华餐饮有限公司/西部马华品牌门店/胡庆鹏区/HQP2区/双安店/前厅/服务组
```
**提取逻辑**(从后往前找以"店"结尾的层级):
```typescript
function extractStore(dept: string): string {
const parts = dept.split('/')
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].endsWith('店')) return parts[i]
}
return ''
}
```
### 4.2 打卡时间解析
格式:`08:43(考勤机:指纹)\n21:23(考勤机:指纹)`
```typescript
function parseClockTimes(raw: string): { start: number; end: number } | null {
const times = raw.match(/(\d{1,2}):(\d{2})/g)
if (!times || times.length < 2) return null
const startHour = parseInt(times[0].match(/(\d{1,2}):/)[1])
let endHour = parseInt(times[times.length - 1].match(/(\d{1,2}):/)[1])
if (endHour < startHour) endHour = 23 // 跨天
return { start: startHour, end: endHour }
}
```
### 4.3 日均在岗人数估算
```typescript
// 每个员工每天打卡算1人在岗,按小时累计
// 月度汇总后除以30天得到日均同时在岗人数
staffSummary[store][hour] = Math.round(totalPersonHours[store][hour] / 30)
```
**关键点**:客流数据 `mv_bill_hourly.bills` 也是月度汇总,展示时需除以30天对齐。
@@ -0,0 +1,135 @@
# 04 · 前端页面开发
## 1. 技术栈
- React 18 + TypeScript
- Vite 构建
- @tanstack/react-query 数据请求
- Recharts 图表
- 自研UI组件库(无第三方UI框架)
## 2. 组件规范
### 2.1 核心组件
| 组件 | 用途 | 关键Props |
|------|------|-----------|
| `MetricCard` | 指标卡 | `label`, `value`, `format: 'number'\|'percent'\|'currency'` |
| `FilterableTable` | 可筛选排序表格 | `columns`, `filterKey`, `sortOptions`, `data` |
| `CollapsibleSection` | 可折叠区域 | `title`, `subtitle`, `defaultOpen` |
| `MonthPicker` | 月份选择器 | `month`, `onChange` |
| `LoadingSpinner` | 加载动画 | `text` |
### 2.2 页面结构模板
```tsx
export default function XxxPage() {
const { month, setMonth } = useMonthParam()
return (
<div className="space-y-6">
{/* 1. 顶部标题 + MonthPicker */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 2. MetricCard 概览行 */}
<div className="grid grid-cols-4 gap-4">
<MetricCard label="指标1" value={data?.metric1} format="currency" />
<MetricCard label="指标2" value={data?.metric2} format="number" />
</div>
{/* 3. CollapsibleSection 包裹图表 */}
<CollapsibleSection title="图表区" defaultOpen>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data?.chart}>
<XAxis dataKey="name" />
<YAxis />
<Bar dataKey="value" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 4. FilterableTable 展示明细 */}
<FilterableTable
columns={columns}
data={data?.list || []}
filterKey="store_name"
sortOptions={[{ key: 'received', label: '按实收' }]}
/>
</div>
)
}
```
## 3. 月份参数管理
### 3.1 统一Hook
```typescript
// client/src/lib/useMonthParam.ts
export const DEFAULT_MONTH = '2026-04'
export function useMonthParam() {
const [month, setMonth] = useState(DEFAULT_MONTH)
// 同步URL参数
useEffect(() => {
const params = new URLSearchParams(location.search)
const m = params.get('month')
if (m) setMonth(m)
}, [])
return { month, setMonth }
}
```
### 3.2 关键原则
- **所有页面统一使用 `useMonthParam`**,不各自 `new Date().toISOString().slice(0,7)`
- **默认月份设为有数据的最新月份**,避免空白页面
- 修改默认月份只需改 `DEFAULT_MONTH` 一处
## 4. 数据请求模式
```typescript
const { data, isLoading } = useQuery({
queryKey: ['xxx-data', month],
queryFn: () => api.get('/api/xxx', { params: { month } }),
staleTime: 5 * 60 * 1000, // 5分钟缓存
})
```
## 5. 数据展示约定
### 5.1 日期格式
后端返回ISO格式 `2026-04-29T16:00:00.000Z`,前端必须截取:
```typescript
{new Date(item.business_date).toISOString().substring(0, 10)}
```
### 5.2 金额格式化
```typescript
formatCurrency(1234567.89) // ¥1,234,567.89
formatNumber(12345) // 12,345
formatPercent(85.5) // 85.5%
```
### 5.3 空状态处理
```tsx
{isLoading ? <LoadingSpinner text="加载中..." /> :
data?.length === 0 ? <div className="text-center py-8 text-gray-400"></div> :
<FilterableTable data={data} ... />}
```
## 6. 典型参考页面
| 页面 | 文件 | 特点 |
|------|------|------|
| 老板驾驶舱 | `BossPage.tsx` | 多Tab + MetricCard + 图表组合 |
| 会员复购 | `MemberPage.tsx` | FilterableTable + BarChart + PieChart |
| 店长工作台 | `StorePage.tsx` | 多Tab + FilterableTable + 图表 |
| SKU分析 | `SKUPage.tsx` | ABC分类 + 可展开详情 |
| 态势感知 | `SituationalAwarenessPage.tsx` | 多Tab + 预警卡片 + 关联分析 |
@@ -0,0 +1,124 @@
# 05 · 部署与运维
## 1. 部署脚本
### 1.1 deploy.sh
```bash
# 后端部署
bash deploy.sh server
# → rsync 同步代码到服务器
# → npm install
# → pm2 restart sbrain-server
# 前端部署
bash deploy.sh front
# → npm run build
# → scp dist/* 到服务器 /var/www/dm.all8ai.top
```
### 1.2 服务器环境
| 配置项 | 值 |
|--------|-----|
| 服务器IP | 152.136.182.184 |
| SSH用户 | ubuntu |
| 后端目录 | /opt/sbrain-server |
| 前端目录 | /var/www/dm.all8ai.top |
| 后端端口 | 9333 |
| PM2进程名 | sbrain-server |
| Node版本 | 20+ |
## 2. 数据库连接
### 2.1 连接配置
| 用途 | 地址 | 端口 | 数据库 | 用户 | 密码 |
|------|------|------|--------|------|------|
| 业务数据 | 127.0.0.1 (FRP) | 15432 | bill_query | freedak | (空) |
| 平台管理 | 127.0.0.1 | 5432 | sbrain_admin | sbrain_admin | sbrain2026 |
### 2.2 FRP隧道
```toml
[[proxies]]
name = "dm-db"
type = "tcp"
localIP = "127.0.0.1"
localPort = 5432
remotePort = 15432
```
- macOS launchd 系统级服务,开机自启
- 管理命令:
- 启动:`launchctl load ~/Library/LaunchAgents/com.frp.client.plist`
- 停止:`launchctl bootout gui/$(id -u)/com.frp.client`
- 日志:`tail -f ~/Library/Logs/frpc.log`
### 2.3 远程数据库操作
```bash
# 通过FRP隧道查询
ssh ubuntu@152.136.182.184 "PGPASSWORD= psql -h 127.0.0.1 -p 15432 -U freedak -d bill_query -c \"SQL\""
# 本地直接查询
psql -h 127.0.0.1 -p 5432 -U freedak -d bill_query -c "SQL"
```
## 3. 数据库备份
```bash
# 本地备份
pg_dump -h 127.0.0.1 -p 5432 -U freedak bill_query > backups/bill_query_$(date +%Y%m%d).sql
# 恢复
psql -h 127.0.0.1 -p 5432 -U freedak -d bill_query < backups/bill_query_YYYYMMDD.sql
```
## 4. PM2 进程管理
```bash
# 查看状态
ssh ubuntu@152.136.182.184 "pm2 status"
# 查看日志
ssh ubuntu@152.136.182.184 "pm2 logs sbrain-server --lines 50"
# 重启
ssh ubuntu@152.136.182.184 "pm2 restart sbrain-server"
# 保存进程列表
ssh ubuntu@152.136.182.184 "pm2 save"
```
## 5. 部署前检查清单
- [ ] 数据库已备份
- [ ] 代码已 commit + push
- [ ] FRP隧道正常运行(`pgrep -l frpc`
- [ ] 后端本地可正常启动
- [ ] 前端本地可正常构建
- [ ] 部署后API健康检查(`curl -s https://dm.all8ai.top/api/health`
## 6. 常用运维命令
```bash
# API健康检查
curl -s https://dm.all8ai.top/api/health
# 登录获取Token
TOKEN=$(curl -s 'https://dm.all8ai.top/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"总部管理员","password":"123"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
# 测试API
curl -s "https://dm.all8ai.top/api/xxx?month=2026-04" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 查看物化视图列表
ssh ubuntu@152.136.182.184 "PGPASSWORD= psql -h 127.0.0.1 -p 15432 -U freedak -d bill_query -c \"SELECT schemaname, matviewname FROM pg_matviews ORDER BY 1,2\""
# 查看表结构
ssh ubuntu@152.136.182.184 "PGPASSWORD= psql -h 127.0.0.1 -p 15432 -U freedak -d bill_query -c \"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'xxx' ORDER BY ordinal_position\""
```
@@ -0,0 +1,132 @@
# 06 · 调试排查手册
## 1. 问题分类与排查流程
### 1.1 页面空白 / 无数据
```
页面空白
检查API返回 → curl测试
API报错? → 查看error字段
SQL报错? → 直查数据库验证
数据不存在? → 检查月份/物化视图刷新状态
```
### 1.2 API报错排查
| 错误信息 | 根因 | 修复方法 |
|---------|------|---------|
| `relation "xxx" does not exist` | Schema前缀错误 | 查 `pg_matviews` 确认实际schema |
| `column "xxx" does not exist` | 列名不匹配 | 查 `information_schema.columns` |
| `Route not found` | 路由未注册或文件路径错 | 检查 `index.ts``app.use` |
| 查询返回0条 | 日期格式不匹配 | 检查实际数据格式(如中文日期) |
| 数据量异常增大 | 物化视图stale | 重建物化视图 |
### 1.3 数据不一致排查
```
前端显示 vs 数据库实际
1. curl API 看返回值
2. 直接 psql 查同一条件
3. 不一致?→ 检查物化视图是否stale
4. 一致但数值异常?→ 检查SQL逻辑(如月度汇总vs日均)
```
## 2. 实际案例与修复记录
### 2.1 Dashboard空白(默认月份问题)
**症状**:部署后Dashboard显示"0家"
**根因**:前端用 `new Date().toISOString().slice(0,7)` 获取当前月份(8月),但数据只到4月
**修复**:统一使用 `DEFAULT_MONTH = '2026-04'`
### 2.2 /stores/risk 报错(列名不存在)
**症状**`column "risk_score" does not exist`
**根因**:SQL显式列名包含物化视图中不存在的列
**修复**:改为 `SELECT *` 或用 `0 AS column_name` 替代
### 2.3 /channel 报错(Schema前缀)
**症状**`relation "analytics.mv_channel_daily" does not exist`
**根因**`mv_channel_daily``public` schema,代码写了 `analytics.`
**修复**:移除schema前缀
### 2.4 /situational-awareness/correlation 报错(列名错误)
**症状**`column s.salary_month does not exist`
**根因**:实际列名是 `salary_period`,代码写了 `salary_month`
**修复**:改为 `s.salary_period`
### 2.5 考勤数据返回0条(日期格式)
**症状**hr_revenue 为空
**根因**`salary_period` 格式是"2026年4月",代码用 `to_char($1, 'YYYY-MM')` 生成"2026-04"
**修复**:改为 `to_char($1::date, 'YYYY"年"FMMM"月"')`
### 2.6 异常账单数据量暴增(物化视图stale)
**症状**:异常账单从3万变3.5万,实收从225万变461万
**根因**:旧物化视图数据stale,重建后刷新到最新
**附加修复**:异常阈值从0.05元提高到1元,过滤舍入差异
### 2.7 客流-人力匹配在岗人数不合理
**症状**:双安总店74人全天在岗不变
**根因**:用月度总员工数作为每小时在岗人数
**修复**:改用 `attendance_records` 打卡记录解析每小时实际在岗人数
### 2.8 客流-人力匹配人均产出过高
**症状**:人均产出300+单/小时
**根因**:客流是月度汇总(3万+),在岗人数是日均(30人)
**修复**:客流也除以30天对齐日均
## 3. 调试工具速查
```bash
# 1. 获取Token
TOKEN=$(curl -s 'https://dm.all8ai.top/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"总部管理员","password":"123"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
# 2. 测试单个API
curl -s "https://dm.all8ai.top/api/xxx?month=2026-04" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 3. 批量测试API
for api in "/api/a" "/api/b" "/api/c"; do
echo -n "$api => "
curl -s "https://dm.all8ai.top${api}?month=2026-04" \
-H "Authorization: Bearer $TOKEN" \
| python3 -c "import sys,json;d=json.load(sys.stdin);print('OK' if d['success'] else 'ERROR: '+d.get('error','?'))"
done
# 4. 直查数据库
ssh ubuntu@152.136.182.184 "PGPASSWORD= psql -h 127.0.0.1 -p 15432 -U freedak -d bill_query -c \"SQL\""
# 5. 查看后端日志
ssh ubuntu@152.136.182.184 "pm2 logs sbrain-server --lines 100"
# 6. 检查物化视图
ssh ubuntu@152.136.182.184 "PGPASSWORD= psql -h 127.0.0.1 -p 15432 -U freedak -d bill_query -c \"SELECT schemaname, matviewname FROM pg_matviews ORDER BY 1,2\""
# 7. 检查表结构
ssh ubuntu@152.136.182.184 "PGPASSWORD= psql -h 127.0.0.1 -p 15432 -U freedak -d bill_query -c \"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'xxx' ORDER BY ordinal_position\""
```
## 4. 修复优先级
1. **P0 - 页面完全不可用**:API报错、路由不存在 → 立即修复
2. **P1 - 数据不正确**:数值异常、数据不一致 → 验证后修复
3. **P2 - 体验问题**:空状态、加载慢 → 优化处理
4. **P3 - 增强功能**:新指标、新图表 → 按需开发
@@ -0,0 +1,161 @@
# 07 · 通用方法论
> 从智脑项目实践中提炼的核心原则和可复制方法论,适用于类似的连锁企业数据产品建设。
## 1. 核心原则
### 1.1 数据先行
- **先摸清数据再写代码**:在开发任何API前,先用psql直查数据库,了解表结构、数据范围、数据质量
- **物化视图是核心**:千万级原始表不要直接查,必须通过物化视图预聚合
- **验证闭环**:每个API开发完成后,必须用curl + psql双向验证数据一致性
### 1.2 渐进式修复
- **先跑通再优化**:遇到列名不存在,先用 `SELECT *` 跑通,再逐步修正
- **最小改动原则**:只改出问题的那行,不重构无关代码
- **保留容错**:不确定的查询用 try-catch 包裹,返回空数组而非报错
### 1.3 统一约定
- **默认参数集中管理**`DEFAULT_MONTH` 一处定义,全局引用
- **日期格式统一**:后端用 `to_char` 适配各种格式,前端用 `substring(0,10)` 处理ISO日期
- **门店名映射集中管理**:维护 `store_name_mapping` 表,代码中统一查找模式
## 2. 项目实施阶段
### 阶段一:数据接入(1-2周)
```
1. 接收原始数据文件(Excel/CSV
2. 设计导入脚本,写入原始表
3. 建立字段映射文档(c001~c200 → 业务含义)
4. 创建物化视图(预聚合)
5. 数据质量校验(范围、完整性、一致性)
6. 建立门店名映射表
```
**关键交付物**
- 原始表 + 物化视图
- 字段映射文档
- 门店名映射表
- 数据质量报告
### 阶段二:后端API2-3周)
```
1. 设计API路由结构(按业务模块拆分)
2. 实现核心查询(SELECT * 先跑通)
3. 添加分页、排序、筛选
4. 添加数据权限(getDataScope
5. curl + psql 双向验证
6. 修复SQL陷阱(schema前缀、列名、日期格式)
```
**关键检查点**
- [ ] 每个API的 success=true
- [ ] 返回数据条数与直查数据库一致
- [ ] 聚合金额与数据库一致
- [ ] 无数据时返回空数组而非报错
### 阶段三:前端页面(2-3周)
```
1. 确定页面结构(参考典型页面模板)
2. 实现 MetricCard 概览行
3. 实现图表区域(Recharts
4. 实现 FilterableTable 明细
5. 联调API
6. 处理空状态和加载状态
```
**关键检查点**
- [ ] 月份选择器工作正常
- [ ] 图表数据与API返回一致
- [ ] 表格筛选、排序正常
- [ ] 空状态显示"暂无数据"
### 阶段四:部署上线(1天)
```
1. 数据库备份
2. Git commit + push
3. 执行 deploy.sh
4. 验证线上API
5. 验证线上页面
6. 修复线上特有问题
```
## 3. 风险清单
| 风险 | 影响 | 预防措施 |
|------|------|---------|
| 物化视图stale | 数据不一致 | 定期刷新,重建后验证 |
| 门店名不一致 | 部分门店无数据 | 维护映射表,代码中统一查找 |
| 日期格式差异 | 查询返回0条 | 先查实际格式再写SQL |
| Schema前缀混用 | API报错 | 统一查pg_matviews确认 |
| 默认月份超出数据范围 | 页面空白 | DEFAULT_MONTH设为最新有数据月份 |
| FRP隧道断开 | 线上API不可用 | launchd自启 + 监控 |
| 月度vs日均未对齐 | 数值异常大 | 统一时间维度(都除以30天) |
## 4. 快速复制Checklist
### 4.1 新项目启动
- [ ] 确认数据源(Excel/CSV/API
- [ ] 确认数据库环境(本地/云)
- [ ] 确认部署环境(服务器/域名)
- [ ] 确认用户角色和权限
### 4.2 数据层
- [ ] 原始表创建 + 导入脚本
- [ ] 字段映射文档
- [ ] 物化视图创建
- [ ] 门店名映射表
- [ ] 数据质量校验报告
### 4.3 API层
- [ ] 路由文件按模块拆分
- [ ] 通用工具函数(parseMonth, parsePagination, getDataScope
- [ ] 每个API curl验证通过
- [ ] 错误处理(sendError返回明确信息)
### 4.4 前端层
- [ ] useMonthParam hook
- [ ] 典型页面模板
- [ ] 组件库(MetricCard, FilterableTable等)
- [ ] 空状态处理
### 4.5 部署
- [ ] deploy.sh 脚本
- [ ] FRP隧道配置(如需)
- [ ] PM2进程配置
- [ ] Nginx配置
- [ ] 数据库备份脚本
## 5. 技术选型建议
| 层面 | 推荐 | 理由 |
|------|------|------|
| 数据库 | PostgreSQL | 物化视图、窗口函数、丰富的数据类型 |
| 后端 | Express + TypeScript | 轻量、灵活、类型安全 |
| 前端 | React + Vite | 生态成熟、构建快 |
| 图表 | Recharts | React原生、API简洁 |
| 数据请求 | @tanstack/react-query | 缓存、重试、loading状态 |
| 进程管理 | PM2 | 自动重启、日志管理 |
| 隧道 | FRP | 穿透内网、稳定可靠 |
## 6. 避坑指南
1. **不要硬编码列名**:用 `SELECT *` 或先查 `information_schema.columns` 确认
2. **不要假设日期格式**:先 `SELECT DISTINCT` 看实际值
3. **不要假设schema**:先查 `pg_matviews` 确认
4. **不要用当前月份做默认值**:用有数据的最新月份
5. **不要忽略物化视图刷新**:数据更新后必须 `REFRESH MATERIALIZED VIEW`
6. **不要在代码中拼接SQL**:始终用参数化查询
7. **不要引入新UI库**:复用现有组件,保持一致性
8. **不要跳过数据库备份**:部署前必须备份
+22
View File
@@ -0,0 +1,22 @@
# 玄谋智脑 · 实施方法论与执行手册
> 本文档体系总结"连锁餐饮企业智脑"项目的全部工作步骤、关键决策点和通用方法论,目标是形成可复制的执行手册,对类似项目快速落地。
## 文档结构
| 文件 | 内容 |
|------|------|
| [01-项目概述与架构.md](01-项目概述与架构.md) | 项目背景、技术架构、数据流、部署拓扑 |
| [02-数据接入与治理.md](02-数据接入与治理.md) | 原始数据导入、物化视图体系、数据质量校验、映射表管理 |
| [03-指标体系与API开发.md](03-指标体系与API开发.md) | 指标分层设计、后端API规范、常见SQL陷阱与修复模式 |
| [04-前端页面开发.md](04-前端页面开发.md) | 页面模板、组件规范、月份参数管理、数据展示约定 |
| [05-部署与运维.md](05-部署与运维.md) | 部署脚本、数据库备份、FRP隧道、PM2进程管理 |
| [06-调试排查手册.md](06-调试排查手册.md) | 常见问题分类、排查流程、修复模式速查 |
| [07-通用方法论.md](07-通用方法论.md) | 可复制的核心原则、风险清单、快速复制Checklist |
## 适用场景
- 连锁餐饮/零售企业的数据分析平台建设
- 基于PostgreSQL物化视图的BI系统
- Express + React + Recharts 技术栈的Dashboard项目
- 多租户SaaS架构的垂直行业数据产品
+128 -30
View File
@@ -266,38 +266,136 @@ router.get('/correlation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 客流-人力匹配度:每门店每小时"每人在岗产出账单数"
// mv_store_hourly_staffing 可能不存在,容错处理
// 从 attendance_records 打卡记录解析每门店每小时实际在岗人数
let staffingRows: any[] = []
try {
const staffingEfficiency = await query(`
WITH hourly_bills AS (
SELECT store_name, hour, sum(bills) AS bills
FROM mv_bill_hourly
GROUP BY store_name, hour
),
hourly_staff AS (
SELECT store_name, hour, total_staff
FROM mv_store_hourly_staffing
WHERE total_staff > 0
)
SELECT
COALESCE(b.store_name, s.store_name) AS store_name,
COALESCE(b.hour, s.hour) AS hour,
COALESCE(b.bills, 0) AS bills,
COALESCE(s.total_staff, 0) AS staff,
round(COALESCE(b.bills, 0)::numeric / nullif(COALESCE(s.total_staff, 0), 0), 1) AS bills_per_staff,
CASE
WHEN COALESCE(s.total_staff, 0) = 0 THEN '无在岗数据'
WHEN COALESCE(b.bills, 0) > 0 AND COALESCE(b.bills, 0)::numeric / COALESCE(s.total_staff, 0) > 15 THEN '高峰人手不足'
WHEN COALESCE(b.bills, 0) = 0 AND COALESCE(s.total_staff, 0) > 3 THEN '低谷人员冗余'
ELSE '配置合理'
END AS match_status
FROM hourly_bills b
FULL OUTER JOIN hourly_staff s ON b.store_name = s.store_name AND b.hour = s.hour
WHERE COALESCE(b.bills, 0) > 0 OR COALESCE(s.total_staff, 0) > 0
ORDER BY COALESCE(b.store_name, s.store_name), COALESCE(b.hour, s.hour)
// 1. 取打卡记录
const attResult = await query(`
SELECT department, position, day_01, day_02, day_03, day_04, day_05, day_06, day_07,
day_08, day_09, day_10, day_11, day_12, day_13, day_14, day_15, day_16, day_17,
day_18, day_19, day_20, day_21, day_22, day_23, day_24, day_25, day_26, day_27,
day_28, day_29, day_30, day_31
FROM attendance_records
WHERE department LIKE '%西部马华品牌门店%'
`)
staffingRows = staffingEfficiency.rows
// 2. 解析打卡时间,汇总 store -> hour -> count
function extractStore(dept: string): string {
const parts = dept.split('/')
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].endsWith('店')) return parts[i]
}
return ''
}
function parseClockTimes(raw: string): { start: number; end: number } | null {
const times = raw.match(/(\d{1,2}):(\d{2})/g)
if (!times || times.length < 2) return null
const startParts = times[0].match(/(\d{1,2}):(\d{2})/)
const endParts = times[times.length - 1].match(/(\d{1,2}):(\d{2})/)
if (!startParts || !endParts) return null
const startHour = parseInt(startParts[1])
let endHour = parseInt(endParts[1])
if (endHour < startHour) endHour = 23
return { start: startHour, end: endHour }
}
const staffingMap: Record<string, Record<number, number>> = {}
const dayFields = ['day_01','day_02','day_03','day_04','day_05','day_06','day_07',
'day_08','day_09','day_10','day_11','day_12','day_13','day_14','day_15','day_16','day_17',
'day_18','day_19','day_20','day_21','day_22','day_23','day_24','day_25','day_26','day_27',
'day_28','day_29','day_30','day_31']
for (const row of attResult.rows) {
const store = extractStore(row.department as string)
if (!store) continue
if (!staffingMap[store]) staffingMap[store] = {}
for (const day of dayFields) {
const raw = (row as any)[day] as string
if (!raw || raw === '') continue
const clock = parseClockTimes(raw)
if (!clock) continue
// 每天打卡算1人在岗,按小时累计(取所有天的平均)
for (let h = clock.start; h <= clock.end; h++) {
staffingMap[store][h] = (staffingMap[store][h] || 0) + 1
}
}
}
// 3. 计算每店每天平均在岗人数(总打卡人天 / 30天)
const staffSummary: Record<string, Record<number, number>> = {}
for (const [store, hours] of Object.entries(staffingMap)) {
staffSummary[store] = {}
for (let h = 0; h < 24; h++) {
// 除以30天得到日均同时在岗人数
staffSummary[store][h] = Math.round((hours[h] || 0) / 30)
}
}
// 4. 映射表
const mappingResult = await query(`SELECT salary_name, bill_name FROM public.store_name_mapping`)
const nameMap: Record<string, string> = {}
for (const r of mappingResult.rows) {
nameMap[r.salary_name as string] = r.bill_name as string
}
// 5. 取客流数据
const hourlyResult = await query(`
SELECT store_name, hour, sum(bills) AS bills
FROM mv_bill_hourly
GROUP BY store_name, hour
`)
// 6. 构建门店→在岗人数的统一查找表(同时用打卡名和映射名)
const staffLookup: Record<string, Record<number, number>> = {}
for (const [store, hours] of Object.entries(staffSummary)) {
staffLookup[store] = hours
const mapped = nameMap[store]
if (mapped && mapped !== store) {
staffLookup[mapped] = hours
}
}
// 7. 构建输出
const billMap: Record<string, any[]> = {}
for (const r of hourlyResult.rows) {
const sn = r.store_name as string
if (!billMap[sn]) billMap[sn] = []
billMap[sn].push(r)
}
const allStores = new Set<string>(Object.keys(billMap))
for (const s of Object.keys(staffLookup)) allStores.add(s)
const output: any[] = []
for (const store of allStores) {
const staffHours = staffLookup[store] || null
const storeHours = billMap[store] || []
if (storeHours.length === 0 && !staffHours) continue
if (storeHours.length > 0) {
const minHour = Math.min(...storeHours.map((r: any) => r.hour))
const maxHour = Math.max(...storeHours.map((r: any) => r.hour))
for (let h = minHour; h <= maxHour; h++) {
const rawBills = (storeHours.find((r: any) => r.hour === h)?.bills as string) || '0'
const billsNum = Math.round(parseInt(rawBills) / 30)
const staff = staffHours ? staffHours[h] || 0 : 0
const ratio = staff > 0 ? billsNum / staff : null
output.push({
store_name: store,
hour: h,
bills: String(billsNum),
staff: staff,
bills_per_staff: ratio ? Math.round(ratio * 10) / 10 : null,
match_status: staff === 0 ? '无在岗数据'
: billsNum > 0 && ratio! > 15 ? '高峰人手不足'
: billsNum === 0 && staff > 3 ? '低谷人员冗余'
: '配置合理'
})
}
}
}
output.sort((a, b) => a.store_name.localeCompare(b.store_name) || a.hour - b.hour)
staffingRows = output
} catch {
staffingRows = []
}
@@ -371,7 +469,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attend_rate
FROM salary_detail_records s
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
AND s.salary_month = to_char($1::date, 'YYYY-MM')
AND s.salary_period = to_char($1::date, 'YYYY"年"FMMM"月"')
GROUP BY s.org_level5
),
rev AS (