diff --git a/docs/智脑实施方法论/01-项目概述与架构.md b/docs/智脑实施方法论/01-项目概述与架构.md new file mode 100644 index 0000000..0e382f8 --- /dev/null +++ b/docs/智脑实施方法论/01-项目概述与架构.md @@ -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` 常量,避免新月份无数据时空白 diff --git a/docs/智脑实施方法论/02-数据接入与治理.md b/docs/智脑实施方法论/02-数据接入与治理.md new file mode 100644 index 0000000..9ad1069 --- /dev/null +++ b/docs/智脑实施方法论/02-数据接入与治理.md @@ -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 = {} +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` | diff --git a/docs/智脑实施方法论/03-指标体系与API开发.md b/docs/智脑实施方法论/03-指标体系与API开发.md new file mode 100644 index 0000000..8974418 --- /dev/null +++ b/docs/智脑实施方法论/03-指标体系与API开发.md @@ -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天对齐。 diff --git a/docs/智脑实施方法论/04-前端页面开发.md b/docs/智脑实施方法论/04-前端页面开发.md new file mode 100644 index 0000000..a70ebe3 --- /dev/null +++ b/docs/智脑实施方法论/04-前端页面开发.md @@ -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 ( +
+ {/* 1. 顶部标题 + MonthPicker */} +
+

页面标题

+ +
+ + {/* 2. MetricCard 概览行 */} +
+ + +
+ + {/* 3. CollapsibleSection 包裹图表 */} + + + + + + + + + + + {/* 4. FilterableTable 展示明细 */} + +
+ ) +} +``` + +## 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 ? : + data?.length === 0 ?
暂无数据
: + } +``` + +## 6. 典型参考页面 + +| 页面 | 文件 | 特点 | +|------|------|------| +| 老板驾驶舱 | `BossPage.tsx` | 多Tab + MetricCard + 图表组合 | +| 会员复购 | `MemberPage.tsx` | FilterableTable + BarChart + PieChart | +| 店长工作台 | `StorePage.tsx` | 多Tab + FilterableTable + 图表 | +| SKU分析 | `SKUPage.tsx` | ABC分类 + 可展开详情 | +| 态势感知 | `SituationalAwarenessPage.tsx` | 多Tab + 预警卡片 + 关联分析 | diff --git a/docs/智脑实施方法论/05-部署与运维.md b/docs/智脑实施方法论/05-部署与运维.md new file mode 100644 index 0000000..13ed8f1 --- /dev/null +++ b/docs/智脑实施方法论/05-部署与运维.md @@ -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\"" +``` diff --git a/docs/智脑实施方法论/06-调试排查手册.md b/docs/智脑实施方法论/06-调试排查手册.md new file mode 100644 index 0000000..b55d008 --- /dev/null +++ b/docs/智脑实施方法论/06-调试排查手册.md @@ -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 - 增强功能**:新指标、新图表 → 按需开发 diff --git a/docs/智脑实施方法论/07-通用方法论.md b/docs/智脑实施方法论/07-通用方法论.md new file mode 100644 index 0000000..1f1eb80 --- /dev/null +++ b/docs/智脑实施方法论/07-通用方法论.md @@ -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. 建立门店名映射表 +``` + +**关键交付物**: +- 原始表 + 物化视图 +- 字段映射文档 +- 门店名映射表 +- 数据质量报告 + +### 阶段二:后端API(2-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. **不要跳过数据库备份**:部署前必须备份 diff --git a/docs/智脑实施方法论/README.md b/docs/智脑实施方法论/README.md new file mode 100644 index 0000000..e3a6836 --- /dev/null +++ b/docs/智脑实施方法论/README.md @@ -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架构的垂直行业数据产品 diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index c01a03b..cea4e9b 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -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> = {} + 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> = {} + 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 = {} + 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> = {} + 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 = {} + 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(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 (