feat: 连锁餐饮数字本体与AI经营智脑 - 新增分析页面、ETL脚本、总结文档

This commit is contained in:
freedakgmail
2026-08-02 10:54:48 +08:00
parent 73f12f40d1
commit a1a8b7ee5e
19 changed files with 3453 additions and 2 deletions
+152
View File
@@ -0,0 +1,152 @@
# 需要补充的数据清单
> 日期:2026-08-02
> 基于数据库实际表结构和行数验证
---
## 一、可从现有数据自动ETL生成(无需人工录入)
### 1. `dim_member` — 会员主数据(空表 → 可从bill_fact聚合)
| 字段 | 来源 | 生成方式 |
|------|------|----------|
| `member_id` | `bill_fact.member_id` | DISTINCT 提取8.3万个会员ID |
| `register_store` | `bill_fact.store_code` | 该会员首笔消费的门店 |
| `register_date` | `bill_fact.opened_at` | 该会员首笔消费日期 |
| `register_channel` | `bill_fact` 各平台收入字段 | 首笔消费的支付渠道判断 |
| `member_level` | `bill_fact.member_level` | 取最新等级(需标准化:1-7 → 普通/银卡/金卡/钻石) |
| `total_orders` | `bill_fact` | 按member_id COUNT(DISTINCT bill_no) |
| `total_revenue` | `bill_fact.received_total` | 按member_id SUM |
| `last_order_date` | `bill_fact.opened_at` | 按member_id MAX |
| `status` | 计算 | 活跃(30天内有消费)/沉睡(90天内)/流失(>90天) |
### 2. `dim_employee` — 员工主数据(空表 → 可从salary/attendance聚合)
| 字段 | 来源 | 生成方式 |
|------|------|----------|
| `employee_id` | `salary_detail_records.employee_code` | DISTINCT 提取3,326个员工编码 |
| `employee_name` | 需人工补录 | 薪资表无姓名字段 |
| `position` | `salary_detail_records.position` | 直接取(262种岗位需标准化) |
| `store_code` | `salary_detail_records.org_level3` | 需映射org_level3到门店编码 |
| `hire_date` | `salary_detail_records.hire_date` | 直接取(text格式需转date |
| `leave_date` | `salary_detail_records.leave_date` | 直接取(非空则为离职) |
| `status` | 计算 | 在职(hire_date有值且leave_date为空)/离职 |
### 3. `dim_sku` — SKU主数据(空表 → 可从dish_sales_details聚合)
| 字段 | 来源 | 生成方式 |
|------|------|----------|
| `sku_code` | 需人工生成 | 当前无SKU编码体系,需建立编码规则 |
| `standard_name` | `dish_sales_details.dish_name` | DISTINCT 提取1,745个菜品名 |
| `category_l1` | `dish_sales_details.category_level1` | 直接取(53个一级分类) |
| `category_l2` | `dish_sales_details.category_level2` | 直接取(59个二级分类) |
| `abc_class` | `mv_dish_sku_abc_monthly.abc_class` | 取最新月度分类 |
| `status` | 需人工判断 | 在售/停用/季节停 |
| `tags` | 需人工标记 | 新品/季节品/区域品/战略品 |
### 4. `dim_channel` — 渠道主数据(空表 → 可从bill_fact字段推断)
| 字段 | 来源 | 生成方式 |
|------|------|----------|
| `channel_code` | 人工定义 | 堂食/美团外卖/淘宝外卖/京东外卖/支付宝/微信/现金/银联/抖音 |
| `channel_name` | 人工定义 | 对应中文名 |
| `channel_group` | 人工定义 | 堂食/外卖/支付 |
| `commission_rate` | 需人工录入 | 各平台佣金率% |
---
## 二、需人工补录的数据
### 5. `dim_store` 补充字段(91行已有,但关键字段为空)
| 字段 | 现状 | 需补录内容 |
|------|------|------------|
| `open_date` | 大部分为空 | 91家店的开业日期(需查档案) |
| `area_sqm` | 大部分为空 | 91家店的营业面积(㎡) |
| `seat_count` | 大部分为空 | 91家店的座位数 |
| `business_area` | 大部分为空 | 所在商圈名称 |
| `region` | 87/91有值 | 4家缺失区域需补录 |
### 6. `dim_promotion` — 活动主数据(空表,需人工录入)
| 字段 | 需录入内容 |
|------|------------|
| `promotion_id` | 活动编码(如 P001-P031 |
| `promotion_name` | 31种营销方案名称(从bill_fact.marketing_plan提取) |
| `start_date` / `end_date` | 每个活动的起止日期 |
| `platform_bear` | 平台承担金额或比例 |
| `company_bear` | 公司承担金额或比例 |
| `store_bear` | 门店承担金额或比例 |
| `target_audience` | 新客/老客/全客 |
| `budget` | 活动预算 |
### 7. `member_level` 标准化映射
当前 `bill_fact.member_level` 值为:`1, 2, 3, 4, 5, 6, 7, LV6, LV7, 空`,需统一映射:
| 当前值 | 标准化 |
|--------|--------|
| 1 | 普通 |
| 2 | 银卡 |
| 3 | 银卡 |
| 4 | 金卡 |
| 5 | 金卡 |
| 6 | 钻石 |
| 7 | 钻石 |
| LV6 | 钻石 |
| LV7 | 钻石 |
### 8. `position` 岗位标准化映射
当前 `salary_detail_records.position` 有262种不同值,需映射到标准岗位:
| 标准岗位 | 可能的原始值 |
|----------|-------------|
| 店长 | 店长、门店经理、店长助理 |
| 厨师长 | 厨师长、后厨主管 |
| 厨师 | 厨师、拉面师、炒菜师、配菜 |
| 服务员 | 服务员、前厅、迎宾 |
| 收银员 | 收银员、出纳 |
| 配送员 | 配送、骑手 |
| 其他 | 其他所有岗位 |
---
## 三、需新建数据源(无法从现有系统获取)
| 数据项 | 需要内容 | 获取方式 | 优先级 |
|--------|----------|----------|--------|
| **现金流数据** | 应收账款、应付账款、租金支付周期、供应商账期 | 接入财务系统(金蝶/用友等) | 高 |
| **评价/口碑数据** | 美团/大众点评评分、差评内容、NPS | 美团商家API或爬虫 | 高 |
| **投诉记录** | 投诉时间、类型、处理人、处理时效、满意度 | 接入客服系统或新建录入表 | 中 |
| **SOP检查记录** | 出餐时间、卫生检查、服务标准达标率 | 新建检查录入表(可做小程序) | 中 |
| **食品安全检查** | 检查项、结果、问题、整改跟踪 | 新建录入表或接入监管系统 | 中 |
| **培训记录** | 培训课程、参训人、时间、考核成绩 | 接入培训系统或新建录入表 | 低 |
| **竞品数据** | 周边竞品价格、菜单、客流 | 爬虫或第三方数据服务 | 低 |
| **天气数据** | 每日天气、温度 | `dim_calendar`已有字段,接入天气API填充 | 低 |
---
## 四、优先级排序:先补什么
### 立即可做(ETL脚本,1-3天)
1. **填充 `dim_member`** — 从 `bill_fact` 聚合,解锁会员LTV/分层/活跃度分析
2. **填充 `dim_employee`** — 从 `salary_detail_records` 聚合,解锁人才流动/绩效分析
3. **填充 `dim_channel`** — 人工定义9个渠道,完善渠道分析
4. **标准化 `member_level`** — 编写映射SQL,统一会员等级
5. **标准化 `position`** — 编写映射SQL,统一岗位分类
### 需人工录入(1-2周)
6. **补录 `dim_store.open_date`** — 91家店开业日期,查档案补录
7. **补录 `dim_store.area_sqm` / `seat_count`** — 91家店面积和座位数
8. **录入 `dim_promotion`** — 31种营销方案的元数据
9. **建立 `dim_sku` 编码体系** — 1,745个菜品的SKU编码和状态标记
### 需外部接入(长期)
10. **财务系统数据** — 现金流分析
11. **评价平台API** — 口碑监控
12. **客服/检查系统** — 投诉/SOP/食安分析
+323
View File
@@ -0,0 +1,323 @@
# 应用全盘审查 — 缺失分析与指导建议
> 审查日期:2026-08-02
> 目标:提升利润、效率、复购率
> 角色覆盖:企业老板 / 总部管理 / 区域管理 / 店长
---
## 一、现有功能盘点
| 模块组 | 已有页面 |
|--------|----------|
| 经营总览 | 老板驾驶舱、总部驾驶舱、态势感知、银行授信 |
| 角色工作台 | 区域经理、店长工作台、任务管理、月度验收 |
| 收入分析 | 营收分析(渠道/餐段/门店排名)、平台优惠、会员复购 |
| 成本与费用 | 菜品成本(8Tab)、成本库存、中央厨房、配送对账、BOM穿透、生产要货、门店费用(10Tab) |
| 运营分析 | 商品SKU(ABC/长尾治理)、时间分析(周/小时)、智能排班(8Tab) |
| 风险与选址 | 风险内控(异常账单/零收入/收银)、门店选址 |
---
## 二、按角色缺失分析
### 1. 老板 (Boss) — 缺失"战略决策级"分析
**已有**:利润瀑布、利润机会池、风险态势、门店营收/利润排名、日度趋势
**缺失**
- **现金流分析** — 当前只有"贡献利润"估算,缺少现金流视角(应收应付、租金支付周期、供应商账期),老板无法判断资金链安全
- **投资回报预测** — 新店投资回收期、ROI模拟;关停门店的止损金额vs迁址成本对比,目前 `store-evaluation` 只有定性建议,缺量化模型
- **品牌/口碑监控** — 门店评分趋势、差评率、NPS,直接影响复购率但目前无数据
- **营销ROI总览** — 平台页有优惠成本统计,但缺少"投入产出比"视角(每元优惠带来多少增量收入/增量利润)
- **门店生命周期分析** — 新店爬坡曲线(开业N个月收入趋势vs基准)、成熟店衰退预警
- **竞争对标** — 周边竞品价格、商圈客流变化,目前完全空白
### 2. 总部管理 (HQ) — 缺失"运营优化级"指导
**已有**:营收/渠道/餐段分析、菜品成本/BOM/损耗、SKU治理、平台优惠、会员对比、排班、风险
**缺失**
- **会员生命周期管理 (LTV)** — 当前 `MemberPage` 只有复购率和会员/非会员对比,缺少:
- 会员LTV(生命周期价值)计算与趋势
- 会员分层(新客/活跃/沉睡/流失)及各层规模、消费贡献
- 沉睡会员唤醒率与成本
- 会员拉新成本(CAC)与回收周期
- **复购驱动因子分析** — 哪些品类/活动/优惠对复购率提升最有效
- **营销活动ROI追踪** — `PlatformPage` 有营销方案列表但缺ROI闭环:活动前后对比、增量收入、增量利润、费效比
- **菜单工程优化建议** — `cost-analysis` 有菜单工程矩阵(明星/瘦狗),但缺少:
- 自动生成"淘汰/保留/提价/降本"行动清单
- 价格弹性测试建议(哪些菜品可以提价不影响销量)
- 菜品关联销售分析(哪些菜品搭配卖最好,用于套餐设计)
- **库存周转与损耗趋势** — 有成本库存页但缺少:
- 库存周转率(天)及趋势
- 临期物料预警
- 损耗率月度趋势(是否在改善)
- **新品上市追踪** — 新品销售表现、顾客接受度、cannibalization 效应(是否吃掉了老品份额)
- **SOP执行率** — 标准化流程的执行达标率,如出餐时间、卫生检查、服务标准
- **培训效果评估** — 培训前后指标对比(如培训后客诉率是否下降、人效是否提升)
- **顾客满意度/投诉分析** — 投诉率、投诉类型分布、处理时效、重复投诉率
- **食品安全/卫生** — 检查通过率、问题分布、整改跟踪
### 3. 区域管理 (Regional) — 缺失"辖区对比与赋能"分析
**已有**:门店风险列表、P0/P1整改、周巡检、基准对比、区域汇总
**缺失**
- **区域间对比排名** — 各区域(如北京东/西/南/北)的营收、成本率、费用率、复购率横向对比,找出最佳/最差区域
- **区域最佳实践提炼** — 从绿色门店中提取共性特征(排班模式、菜品结构、会员策略),形成可推广的标准化方案
- **区域人才流动** — 店长/员工离职率、晋升率、跨店调动,影响区域稳定性
- **区域营销效果对比** — 同一活动在不同区域的落地效果差异
- **辖区巡检计划生成** — 基于门店风险等级自动生成巡检优先级和时间表
- **区域KPI达成预测** — 基于当前进度预测月末达成率,提前预警
### 4. 店长 (Store) — 缺失"日常经营抓手"
**已有**:经营概览、日度趋势、月度指标对比、任务管理、餐段/品类/成本/会员/异常Tab、核心SKU备货、健康度评分
**缺失**
- **每日销售目标设定与追踪** — 当前有月度目标,但缺少按日/按班次分解的目标和实时追踪(今天要做多少、午市/晚市各多少)
- **员工绩效与激励** — 个人销售额、出餐速度、服务评分,用于绩效奖金计算
- **本店会员活跃度** — 本店会员数、活跃率、沉睡数、本月新增/流失,以及针对性的会员激活行动建议
- **库存预警与补货建议** — 基于历史销售和当前库存自动生成补货清单和预警
- **食材损耗改善追踪** — 当前有成本差异但缺少"本周损耗金额、损耗TOP3食材、改善措施记录与效果追踪"
- **顾客投诉/差评处理** — 本店投诉清单、处理状态、重复投诉预警
- **改善行动模板库** — 针对常见问题(如毛利率低、复购率低、人效低)提供可执行的改善模板,店长一键认领
- **每日经营复盘** — 班次结束后的快速复盘模板(今日亮点/问题/明日重点)
- **本店竞争分析** — 周边竞品价格、客流对比
---
## 三、按目标优先级排序的建议
### 提升利润(最高优先级)
1. **营销ROI追踪** — 总部需要知道每元优惠带来多少增量利润,当前完全是盲区
2. **菜单工程行动清单** — 将现有矩阵转化为"提价/降本/淘汰"的具体行动
3. **现金流分析** — 老板需要看到资金链全貌
4. **库存周转与损耗趋势** — 减少库存积压和损耗是直接的利润提升点
5. **菜品关联销售分析** — 套餐设计优化可提升客单价
### 提升效率(高优先级)
1. **SOP执行率监控** — 标准化是效率的基础
2. **区域间对比排名** — 找出最佳实践并推广
3. **每日销售目标分解** — 店长需要日颗粒度的目标管理
4. **改善行动模板库** — 减少店长"不知道做什么"的时间
5. **辖区巡检计划自动生成** — 区域经理的时间优化
### 提升复购率(高优先级)
1. **会员生命周期管理(LTV)** — 从"有复购率数据"到"有会员运营策略"
2. **复购驱动因子分析** — 哪些行为真正驱动复购
3. **会员分层运营** — 沉睡会员唤醒、高价值会员维护
4. **顾客满意度/口碑监控** — 评分直接影响复购
5. **本店会员活跃度面板** — 店长需要看到本店会员的实时状态
---
## 四、建议实施路线图
### 第一批(快速见效,1-2周)
| 序号 | 功能 | 受益角色 | 数据来源 | 复杂度 |
|------|------|----------|----------|--------|
| 1 | 营销活动ROI追踪 | 总部/老板 | 现有平台数据+活动列表 | 中 |
| 2 | 菜单工程行动清单 | 总部 | 现有菜单工程矩阵数据 | 低 |
| 3 | 会员分层与LTV | 总部 | 现有会员数据扩展 | 中 |
| 4 | 每日销售目标分解 | 店长 | 现有日度数据 | 低 |
| 5 | 区域间对比排名 | 区域/总部 | 现有门店数据聚合 | 低 |
### 第二批(中期推进,2-4周)
| 序号 | 功能 | 受益角色 | 数据来源 | 复杂度 |
|------|------|----------|----------|--------|
| 6 | 现金流分析 | 老板 | 需新增应收应付数据 | 高 |
| 7 | 库存周转与损耗趋势 | 总部 | 现有库存数据扩展 | 中 |
| 8 | 改善行动模板库 | 店长/区域 | 需新建模板库 | 中 |
| 9 | 复购驱动因子分析 | 总部 | 会员消费明细关联 | 高 |
| 10 | 本店会员活跃度面板 | 店长 | 现有会员数据扩展 | 中 |
### 第三批(长期建设,1-2月)
| 序号 | 功能 | 受益角色 | 数据来源 | 复杂度 |
|------|------|----------|----------|--------|
| 11 | 品牌口碑监控 | 老板/总部 | 需接入评价平台API | 高 |
| 12 | 门店生命周期分析 | 老板/总部 | 历史数据建模 | 高 |
| 13 | SOP执行率监控 | 总部/区域 | 需新建检查录入系统 | 高 |
| 14 | 员工绩效与激励 | 店长 | 需接入考勤/绩效数据 | 高 |
| 15 | 竞争对标分析 | 老板/区域 | 需外部数据源 | 高 |
---
## 五、现有数据支撑能力评估
> 基于数据库实际表结构和行数验证(2026-08-02)
### 有数据支撑的表/物化视图
| 表名 | 行数 | 说明 |
|------|------|------|
| `analytics.bill_fact` | 1,669,925 | 账单事实表,含会员ID、收银员、服务员、班次、营销方案、各平台收入/佣金等47个字段 |
| `public.dish_sales_details` | 5,553,314 | 菜品明细,含菜品名、分类、数量、金额、门店、账单号 |
| `public.bill_records` | 1,669,928 | 原始账单记录,199列(c001-c196),含餐段/区域/桌型/各品类金额/平台收入/优惠/时间戳 |
| `public.distribution_detail_records` | 325,906 | 配送明细记录 |
| `public.salary_detail_records` | 3,596 | 薪资明细,含员工编码、岗位、组织层级、绩效评分、出勤、加班费等 |
| `public.attendance_records` | 3,556 | 考勤记录,含员工编码、岗位、部门、每日打卡 |
| `analytics.fact_inventory_snapshot` | 54,842 | 库存快照,含期初/采购/消耗/期末/报损/调拨 |
| `analytics.mv_inventory_cost_classified_monthly` | 41,866 | 库存成本分类 |
| `analytics.dim_material` | 6,777 | 原料主数据 |
| `analytics.dim_supplier` | 198 | 供应商主数据 |
| `analytics.dim_store` | 91 | 门店主数据,含区域/业态/面积/座位数(但大部分面积/座位/开业日期为空) |
| `analytics.dim_calendar` | 365 | 日历主数据,含周末/节假日标记 |
| `analytics.mv_store_risk_rating_monthly` | 91 | 门店风险评级 |
| `analytics.mv_store_operating_expense_monthly` | 89 | 门店运营费用(含工资/租金/水电/配送佣金等34字段) |
| `analytics.mv_store_deep_diagnosis_monthly` | 91 | 门店深度诊断(38字段,含搭售率/套餐占比/成本差异等) |
| `analytics.mv_store_action_priority_deep_monthly` | 91 | 门店行动优先级 |
| `analytics.mv_store_member_opportunity_monthly` | 91 | 会员机会(含会员占比/转化场景/收入提升空间) |
| `analytics.mv_store_repeat_summary_monthly` | 89 | 复购汇总(含识别会员数/复购会员数/复购率/复购收入占比) |
| `analytics.mv_dish_sku_summary_monthly` | 1,745 | 菜品SKU月度汇总 |
| `analytics.mv_dish_sku_abc_monthly` | 1,745 | 菜品ABC分类 |
| `analytics.mv_dish_pair_summary_monthly` | 86,836 | 菜品搭配分析 |
| `analytics.store_task` | 138 | 门店整改任务 |
| `analytics.standardized_practice` | 3 | 标准化经验 |
| `analytics.indicator_dictionary` | 26 | 指标字典 |
### 空表(已定义但无数据)
| 表名 | 说明 | 影响 |
|------|------|------|
| `analytics.dim_member` | 会员主数据(含注册渠道/等级/状态/总消费) | 无法做会员LTV/分层分析 |
| `analytics.dim_employee` | 员工主数据(含入职/离职/岗位/门店) | 无法做人才流动分析 |
| `analytics.dim_promotion` | 活动主数据(含预算/承担方/目标客群) | 无法做营销ROI追踪 |
| `analytics.dim_sku` | SKU主数据(含编码/分类/状态/标签) | 无法做新品追踪 |
| `analytics.dim_channel` | 渠道主数据 | 渠道分析依赖bill_fact已有字段 |
| `analytics.fact_promotion_usage` | 优惠使用事实表 | 无法做活动级别ROI |
| `analytics.fact_employee_shift` | 员工排班事实表 | 智能排班页使用的是salary/attendance原始表 |
| `analytics.fact_customer_contact` | 会员触达事实表 | 无法做唤醒率/转化率分析 |
| `analytics.fact_waste` | 报损事实表 | 无法做损耗趋势分析 |
| `analytics.fact_purchase_receipt` | 采购收货事实表 | 无法做采购分析 |
| `analytics.fact_recipe_bom` | BOM配方事实表 | BOM分析使用其他数据源 |
| `analytics.fact_bill` / `fact_bill_item` | 规范事实表 | 实际使用bill_fact和dish_sales_details |
| `analytics.fact_payment` | 支付事实表 | 支付数据在bill_fact中已有 |
| `analytics.fact_platform_order` | 平台订单损益表 | 平台数据在bill_fact中已有 |
| `analytics.task_weekly_check` | 周度检查记录 | 周巡检功能无数据 |
| `analytics.task_monthly_review` | 月度验收记录 | 月度验收功能无数据 |
| `analytics.store_grade_change` | 门店升降级日志 | 升降级追踪无数据 |
| `analytics.indicator_dictionary` | 指标字典(26条) | 有少量数据 |
### bill_fact 关键字段数据覆盖率
| 字段 | 非空数量 | 覆盖率 | 说明 |
|------|----------|--------|------|
| `member_id` | 231,438 | 13.9% | 8.3万独立会员,可做会员分析 |
| `member_level` | 231,438 | 13.9% | 等级为数字1-7及LV6/LV7等,需标准化 |
| `cashier` | ~970K | ~58% | 583个独立收银员 |
| `waiter` | ~980K | ~59% | 591个独立服务员 |
| `marketing_plan` | 53,208 | 3.2% | 31种营销方案(会员价/各种折扣) |
| `shift_name` | ~1.67M | ~100% | 4种班次 |
| `opened_at` / `closed_at` | ~1.67M | ~100% | 可计算用餐时长 |
| 各平台收入字段 | ~1.67M | ~100% | 美团/淘宝/京东/现金/支付宝/微信等 |
---
## 六、逐项数据支撑评估
### ✅ 有数据支撑,可直接实现
| 建议功能 | 数据来源 | 说明 |
|----------|----------|------|
| **菜单工程行动清单** | `mv_dish_sku_abc_monthly` (1,745行) | 已有ABC分类和明星/引流/潜力/淘汰象限,可直接生成行动清单 |
| **菜品关联销售分析** | `mv_dish_pair_summary_monthly` (86,836行) | 已有菜品搭配数据,可直接用于套餐设计 |
| **每日销售目标分解** | `bill_fact` (167万行) | 有opened_at/closed_at/meal_period,可按日/餐段聚合历史均值设定目标 |
| **区域间对比排名** | `dim_store` (91行,含region) + `mv_store_risk_rating_monthly` | 91家门店分布在12个区,可按区域聚合对比 |
| **区域KPI达成预测** | `bill_fact` 日度数据 | 可基于当月已过天数的日均收入预测月末达成率 |
| **本店会员活跃度面板** | `bill_fact.member_id` (8.3万独立会员) | 可按门店+会员ID聚合,计算活跃/沉睡/新增 |
| **复购驱动因子分析** | `bill_fact` (member_id + marketing_plan + dish_sales_details) | 可关联会员消费频次与品类/活动/优惠的关联 |
| **员工绩效与激励** | `salary_detail_records` (3,596行) + `attendance_records` (3,556行) | 有员工编码/岗位/绩效评分/出勤/加班费,可做个人绩效分析 |
| **食材损耗改善追踪** | `fact_inventory_snapshot` (54,842行) | 有waste_quantity/waste_amount,可追踪损耗趋势 |
| **库存周转与损耗趋势** | `fact_inventory_snapshot` + `mv_inventory_cost_classified_monthly` | 有opening/ending/purchase/consumption,可算周转率 |
| **营销活动ROI追踪(基础版)** | `bill_fact.marketing_plan` (31种方案,53,208笔) | 可对比有/无营销方案时的客单价、利润率差异 |
| **改善行动模板库** | `store_task` (138行) + `standardized_practice` (3行) | 已有任务模板和经验库基础,可扩展 |
| **辖区巡检计划生成** | `mv_store_risk_rating_monthly` (91行) | 有risk_level可按风险排序生成巡检计划 |
| **每日经营复盘** | `bill_fact` 日度数据 | 可自动生成每日亮点/异常/对比 |
### ⚠️ 部分支撑,需补充数据或建模
| 建议功能 | 现有数据 | 缺失部分 |
|----------|----------|----------|
| **会员LTV/分层运营** | `bill_fact.member_id` (8.3万会员) + `member_level` | `dim_member` 空表,需从bill_fact聚合填充;缺少会员注册日期、状态(活跃/沉睡/流失)需计算 |
| **营销活动ROI追踪(完整版)** | `bill_fact.marketing_plan` (31种) | `dim_promotion` 空表,缺少活动预算、平台/公司/门店承担比例、目标客群;需手动录入活动元数据 |
| **门店生命周期分析** | `bill_fact` 有多月数据 + `dim_store.open_date`(大部分为空) | `dim_store``open_date` 大部分为空,需补充开业日期才能做爬坡曲线 |
| **区域人才流动** | `salary_detail_records` (3,326员工) + `attendance_records` | 有员工编码和岗位,但 `dim_employee` 空表,缺少入职/离职日期;需从薪资表的hire_date/leave_date提取 |
| **区域营销效果对比** | `bill_fact.marketing_plan` + `dim_store.region` | 可按区域聚合营销效果,但活动维度元数据缺失 |
### ❌ 无数据支撑,需新建数据源
| 建议功能 | 缺失数据 | 获取方式 |
|----------|----------|----------|
| **现金流分析** | 应收应付、供应商账期、租金支付周期 | 需接入财务系统数据 |
| **品牌/口碑监控** | 门店评分、差评率、NPS | 需接入美团/大众点评评价API |
| **SOP执行率** | 出餐时间达标率、卫生检查结果、服务标准执行 | 需新建检查录入系统 |
| **顾客满意度/投诉分析** | 投诉记录、处理时效、满意度评分 | 需接入客服系统或新建录入 |
| **食品安全/卫生** | 检查通过率、问题分布、整改跟踪 | 需新建检查录入系统 |
| **新品上市追踪** | 新品标记、上市日期、cannibalization基线 | `dim_sku` 空表,需补充SKU状态和上市日期 |
| **竞争对标分析** | 周边竞品价格、商圈客流 | 需外部数据源(爬虫或第三方API) |
| **培训效果评估** | 培训记录、培训前后指标对比 | 需接入培训系统或新建录入 |
| **本店竞争分析** | 周边竞品价格、客流 | 同上,需外部数据 |
---
## 七、修订后的实施路线图
基于数据支撑评估,调整实施顺序和可行性:
### 第一批:数据完备,可立即启动(1-2周)
| 序号 | 功能 | 数据支撑 | 实现方式 |
|------|------|----------|----------|
| 1 | 菜单工程行动清单 | ✅ `mv_dish_sku_abc_monthly` | 基于现有ABC+象限分类生成行动建议 |
| 2 | 菜品关联销售分析 | ✅ `mv_dish_pair_summary_monthly` | 展示搭配排行,推荐套餐组合 |
| 3 | 每日销售目标分解 | ✅ `bill_fact` 日度数据 | 按历史均值/餐段分解月度目标 |
| 4 | 区域间对比排名 | ✅ `dim_store.region` + 各mv | 按区域聚合现有指标 |
| 5 | 区域KPI达成预测 | ✅ `bill_fact` 当月数据 | 基于日均外推月末达成率 |
| 6 | 本店会员活跃度面板 | ✅ `bill_fact.member_id` | 按门店聚合会员活跃/沉睡/新增 |
| 7 | 改善行动模板库 | ✅ `store_task` + `standardized_practice` | 扩展现有模板库 |
| 8 | 辖区巡检计划生成 | ✅ `mv_store_risk_rating_monthly` | 按风险排序生成巡检优先级 |
### 第二批:需少量数据补录或建模(2-4周)
| 序号 | 功能 | 需补录数据 | 实现方式 |
|------|------|------------|----------|
| 9 | 会员LTV与分层 | 从bill_fact聚合填充dim_member | 编写ETL从bill_fact计算会员首次/末次消费、总消费、状态 |
| 10 | 营销活动ROI(基础版) | 从bill_fact.marketing_plan对比 | 对比有/无营销方案的客单价、毛利率差异 |
| 11 | 复购驱动因子分析 | 关联member_id + 品类 + 活动 | 多维度交叉分析复购驱动因素 |
| 12 | 员工绩效与激励 | salary + attendance已有数据 | 按员工聚合绩效评分、出勤、销售额关联 |
| 13 | 库存周转与损耗趋势 | fact_inventory_snapshot已有 | 计算周转天数、损耗率月度趋势 |
| 14 | 食材损耗改善追踪 | fact_inventory_snapshot.waste_* | 按门店/原料追踪损耗TOP和改善趋势 |
| 15 | 每日经营复盘 | bill_fact日度数据 | 自动生成每日亮点/异常/对比模板 |
### 第三批:需补录主数据(1-2月)
| 序号 | 功能 | 需补录数据 | 实现方式 |
|------|------|------------|----------|
| 16 | 门店生命周期分析 | dim_store.open_date(大部分为空) | 需人工补录91家店开业日期 |
| 17 | 营销活动ROI(完整版) | dim_promotion(空表) | 需录入活动预算、承担方、目标客群 |
| 18 | 区域人才流动 | dim_employee(空表) | 从salary表hire_date/leave_date提取 |
### 第四批:需新建数据源(长期)
| 序号 | 功能 | 需新建数据源 | 获取方式 |
|------|------|-------------|----------|
| 19 | 现金流分析 | 应收应付/账期数据 | 接入财务系统 |
| 20 | 品牌/口碑监控 | 评分/差评/NPS | 接入评价平台API |
| 21 | 顾客满意度/投诉 | 投诉记录 | 接入客服系统或新建录入 |
| 22 | SOP执行率 | 检查记录 | 新建检查录入系统 |
| 23 | 食品安全/卫生 | 检查记录 | 新建检查录入系统 |
| 24 | 新品上市追踪 | dim_sku补录 | 补充SKU状态和上市日期 |
| 25 | 竞争对标分析 | 竞品价格/客流 | 外部数据源 |
| 26 | 培训效果评估 | 培训记录 | 接入培训系统 |
+770
View File
@@ -0,0 +1,770 @@
# 连锁餐饮行业数字本体与 AI 经营智脑 — 全景总结
> 日期:2026-08-02
> 覆盖范围:老板 · 总部 · 区域 · 店长 四级经营管理全链路
> 核心目标:看清企业现在,掌控经营未来
---
## 一、系统定位与核心理念
### 1.1 一句话定位
基于 91 家门店、167 万账单、555 万菜品明细、5.5 万库存记录的真实数据,构建从"数据发现问题 → 自动分级 → 生成任务 → 现场执行 → 周度检查 → 月度验收 → 有效经验标准化"的全闭环数字化经营体系。
### 1.2 核心理念
| 原则 | 说明 |
|------|------|
| 营业额不是利润 | 追求可控经营利润,而非单纯营收规模 |
| 同类对标 | 门店必须按业态、规模、场景分组比较 |
| 一店一策 | 每店每月最多抓两个核心问题 |
| 数据先行 | 数据异常先修口径,再追经营责任 |
| 闭环管理 | 每个指标落到责任人、动作、截止日、验收结果 |
| 标杆拆项复制 | 经验拆成模块试点,不整店照搬 |
### 1.3 北极星指标
> **可控经营利润 = 实收 − 实际食材成本 − 平台佣金 − 公司承担优惠 − 门店可控人工 − 可控损耗**
当前因缺少完整人工、租金、能耗和活动承担方数据,暂用"经营质量指标组"替代,已实现门店贡献利润估算。
---
## 二、技术架构
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────┐
│ 前端 (React 18 + TypeScript) │
│ 老板驾驶舱 / 总部驾驶舱 / 区域经理 / 店长工作台 / 专业分析 │
│ TailwindCSS + Recharts + shadcn/ui │
│ 部署:dm.all8ai.top (Nginx 静态文件) │
├─────────────────────────────────────────────────────────┤
│ 后端 API (Node.js + Express) │
│ TypeScript + PostgreSQL 驱动 │
│ 本地运行 localhost:3333 → SSH 隧道 → 服务器 13333 │
│ 8 个路由模块 · 120+ API 端点 │
├─────────────────────────────────────────────────────────┤
│ 数据层 (PostgreSQL 15) │
│ 原始层 → 标准层 → 事实层 → 汇总层 → 应用层 │
│ bill_query @ localhost:5432 │
│ 3 张原始表 · 20+ 维度/事实表 · 50+ 视图/物化视图 │
└─────────────────────────────────────────────────────────┘
```
### 2.2 技术栈
| 层 | 技术 | 说明 |
|---|---|---|
| 前端框架 | React 18 + TypeScript + Vite | 组件化、类型安全、热重载 |
| UI 组件 | TailwindCSS + 自研组件库 | MetricCard / FilterableTable / CollapsibleSection / MonthPicker / KPISection / SearchSelect / StoreMap |
| 图表 | Recharts | BarChart / LineChart / PieChart / ScatterChart / RadarChart / ComposedChart |
| 路由 | React Router v6 | 34 个页面路由 |
| 状态管理 | TanStack Query (React Query) | 服务端状态管理,缓存/刷新 |
| 后端 | Node.js + Express + TypeScript | RESTful APIJWT 认证 |
| 数据库 | PostgreSQL 15 (Homebrew) | 5 层数据架构 |
| 部署 | Nginx + SSH 反向隧道 | 前端静态部署,后端隧道穿透 |
### 2.3 角色权限体系
| 角色 | 代码 | 可见菜单 | 登录账号 |
|------|------|----------|----------|
| 总部管理员 | `hq` | 全部页面 | 总部管理员 / 123 |
| 商品部 | `dept` | 全部页面 | 商品部 / 123 |
| 区域经理 | `regional` | 总部驾驶舱、区域经理、店长、任务、验收、指标 | 区域经理 / 123 |
| 店长 | `store` | 总部驾驶舱、店长工作台、任务、验收、指标 | 潘家园店长 / 123 |
---
## 三、数字本体 — 数据资产全景
### 3.1 原始数据层
| 表名 | 行数 | 说明 |
|------|------|------|
| `public.bill_records` | 1,669,928 | 原始账单,199 列 (c001-c196),含餐段/区域/桌型/各品类金额/平台收入/优惠/时间戳 |
| `public.dish_sales_details` | 5,553,314 | 菜品销售明细,含菜品名/分类/数量/金额/门店/账单号 |
| `public.distribution_detail_records` | 325,906 | 配送明细记录 |
| `public.salary_detail_records` | 3,596 | 薪资明细,含员工编码/岗位/组织层级/绩效评分/出勤/加班费 |
| `public.attendance_records` | 3,556 | 考勤记录,含员工编码/岗位/部门/每日打卡 |
### 3.2 标准主数据层(维度表)
| 表名 | 行数 | 状态 | 说明 |
|------|------|------|------|
| `analytics.dim_store` | 91 | ✅ 有数据 | 门店主数据,含区域/业态/面积/座位数(部分为空) |
| `analytics.dim_material` | 6,777 | ✅ 有数据 | 原料主数据 |
| `analytics.dim_supplier` | 198 | ✅ 有数据 | 供应商主数据 |
| `analytics.dim_calendar` | 365 | ✅ 有数据 | 日历主数据,含周末/节假日标记 |
| `analytics.dim_member` | 83,101 | ✅ 已填充 | 会员主数据(ETL 从 bill_fact 聚合,含等级/状态/标签) |
| `analytics.dim_employee` | 3,326 | ✅ 已填充 | 员工主数据(ETL 从 salary 聚合,含标准化岗位/入职离职日期) |
| `analytics.dim_promotion` | 0 | ❌ 空表 | 活动主数据(需人工录入 31 种营销方案元数据) |
| `analytics.dim_sku` | 1,745 | ✅ 已填充 | SKU 主数据(ETL 从 dish_sales_details 聚合,含 ABC 分类/编码) |
| `analytics.dim_channel` | 11 | ✅ 已填充 | 渠道主数据(11 渠道:堂食/美团/淘宝/京东/抖音/支付宝/微信/现金/银联/挂账) |
### 3.3 事实数据层
| 表名 | 行数 | 说明 |
|------|------|------|
| `analytics.bill_fact` | 1,669,975 | 账单事实表,47 个字段,含会员ID/收银员/服务员/班次/营销方案/各平台收入/佣金 |
| `analytics.fact_inventory_snapshot` | 54,842 | 库存快照,含期初/采购/消耗/期末/报损/调拨 |
| `analytics.store_task` | 138 | 门店整改任务 |
| `analytics.standardized_practice` | 3 | 标准化经验 |
| `analytics.indicator_dictionary` | 26 | 指标字典 |
| `analytics.dim_store_target` | 182 | 门店月度目标(91 店 × 2 月) |
| `analytics.task_template` | 7 | 任务模板 |
| `analytics.store_monthly_action_plan` | 91 | 门店月度行动计划 |
| `analytics.category_summary` | 58 | 品类汇总 |
| `analytics.discount_summary` | 45 | 折扣汇总 |
| `analytics.practice_replication` | 0 | 经验试点复制(空表) |
| `analytics.store_grade_change` | 0 | 门店等级变动(空表) |
| `analytics.task_weekly_check` | 0 | 周度检查记录表(空表,数据通过视图计算) |
| `analytics.task_monthly_review` | 0 | 月度验收记录表(空表,数据通过视图计算) |
| `analytics.store_task_log` | 0 | 任务操作日志(空表) |
### 3.4 汇总层(物化视图,29 个)
| 物化视图 | 行数 | 说明 |
|----------|------|------|
| `mv_store_risk_rating_monthly` | 91 | 门店风险评级(红/黄/绿) |
| `mv_store_risk_rating` | 91 | 门店风险评级(非月份版) |
| `mv_store_operating_expense_monthly` | 89 | 门店运营费用(34 字段,含工资/租金/水电/配送佣金) |
| `mv_store_deep_diagnosis_monthly` | 91 | 门店深度诊断(38 字段,含搭售率/套餐占比/成本差异) |
| `mv_store_action_priority_deep_monthly` | 91 | 门店行动优先级 |
| `mv_store_action_priority_deep_april` | 91 | 门店行动优先级(四月版) |
| `mv_store_member_opportunity_monthly` | 91 | 会员机会(含会员占比/转化场景/收入提升空间) |
| `mv_store_repeat_summary_monthly` | 89 | 复购汇总(含识别会员数/复购率/复购收入占比) |
| `mv_store_scorecard` | 91 | 门店记分卡 |
| `mv_store_benchmark_composite_monthly` | 59 | 门店综合标杆 |
| `mv_store_benchmark_composite` | 59 | 门店综合标杆(非月份版) |
| `mv_store_platform_economics_monthly` | 91 | 平台经济性 |
| `mv_store_theoretical_actual_cost_monthly` | 89 | 理论 vs 实际成本对比 |
| `mv_store_theoretical_actual_cost_april` | 89 | 理论 vs 实际成本(四月版) |
| `mv_store_area_efficiency_monthly` | 91 | 人效坪效 |
| `mv_store_category_mix_monthly` | 91 | 门店品类结构 |
| `mv_dish_sku_summary_monthly` | 1,745 | 菜品 SKU 月度汇总 |
| `mv_dish_sku_abc_monthly` | 1,745 | 菜品 ABC 分类 |
| `mv_dish_pair_summary_monthly` | 86,836 | 菜品搭配分析 |
| `mv_dish_basket_monthly` | 1,669,580 | 购物篮分析(逐账单) |
| `mv_dish_store_summary_monthly` | 91 | 门店菜品汇总 |
| `mv_inventory_cost_classified_monthly` | 41,866 | 库存成本分类 |
| `mv_overview_monthly` | 1 | 公司月度概览 |
| `mv_overview_daily` | 30 | 公司日度概览 |
| `mv_region_summary` | 12 | 区域汇总 |
| `mv_store_site_profile_monthly` | 91 | 门店选址画像 |
| `mv_site_segment_benchmark_monthly` | 17 | 分段基准(场景×面积) |
| `mv_store_site_replication_monthly` | 81 | 复制评分 |
| `mv_store_overlap_risk_monthly` | 143 | 重叠风险(门店两两配对) |
| `mv_district_site_benchmark_monthly` | 12 | 区域基准(12 区域) |
| `mv_loop_health` | 1 | 闭环健康度 |
### 3.5 分析视图层(72 个视图)
#### 经营概览类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_overview_daily` | 30 | 公司日度概览 |
| `v_store_daily` | 2,677 | 门店日度明细 |
| `v_channel_daily` | 30 | 渠道日度汇总 |
| `v_meal_period_daily` | 150 | 餐段日度汇总 |
| `v_hourly_summary` | 24 | 小时维度汇总 |
| `v_weekday_summary` | 7 | 星期维度汇总 |
| `v_region_summary` | 13 | 区域汇总 |
#### 门店评估类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_store_scorecard` | 91 | 门店记分卡 |
| `v_store_risk_rating` | 91 | 门店风险评级 |
| `v_store_member_opportunity` | 91 | 门店会员机会 |
| `v_store_repeat_summary_monthly` | 89 | 门店复购汇总 |
| `v_store_benchmark_composite` | 59 | 门店综合标杆 |
| `v_store_meal_opportunity` | 434 | 门店餐段机会 |
| `v_store_daily_card` | 90 | 门店每日经营卡 |
| `v_store_monthly_followup` | 91 | 门店月度跟进 |
| `v_store_operating_expense_monthly` | 89 | 门店运营费用 |
| `v_store_area_efficiency_april` | 91 | 门店人效坪效 |
| `v_store_deep_diagnosis_april` | 91 | 门店深度诊断 |
| `v_store_action_priority_deep_april` | 91 | 门店行动优先级 |
| `v_store_location_operating` | 91 | 门店选址经营数据 |
| `v_store_theoretical_actual_cost_april` | 89 | 门店理论 vs 实际成本 |
#### 会员分析类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_member_comparison` | 2 | 会员 vs 非会员对比 |
| `v_member_monthly_activity` | 83,101 | 会员月度活跃度 |
| `v_store_member_monthly_activity` | 90,569 | 门店会员月度活跃度 |
| `v_member_level_mapping` | 89 | 会员等级标准化映射 |
| `v_dish_member_repeat_april` | 1,164 | 菜品会员复购分析 |
#### 菜品与成本类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_dish_sku_abc_april` | 1,745 | 菜品 ABC 分类 |
| `v_dish_cost_analysis_latest_summary` | 788 | 菜品成本分析汇总 |
| `v_dish_cost_analysis_latest_dish_rollup` | 765 | 菜品成本上卷 |
| `v_dish_cost_analysis_latest_material_detail` | 3,568 | 菜品原料成本明细 |
| `v_cost_linkage_summary_latest` | 1 | 成本关联汇总 |
| `v_inventory_cost_operating` | 41,866 | 库存成本运营视图 |
| `v_inventory_cost_unit_summary` | 121 | 库存成本单位汇总 |
| `v_c_sku_governance_april` | 1,624 | SKU 治理 |
#### 配送与中央厨房类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_distribution_detail` | 325,922 | 配送明细 |
| `v_distribution_store_monthly` | 105 | 配送门店月度汇总 |
| `v_central_kitchen_material_allocation` | 4,842 | 中央厨房物料分配 |
| `v_central_kitchen_material_reconciliation` | 3,329 | 中央厨房物料对账 |
| `v_central_kitchen_material_difference_pool` | 28 | 中央厨房物料差异池 |
| `v_central_kitchen_product_full_cost` | 157 | 中央厨房产品全成本 |
#### 风险与异常类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_anomaly_bills` | 28,621 | 异常账单明细 |
| `v_anomaly_summary` | 5 | 异常汇总 |
| `v_cashier_risk` | 583 | 收银员风险 |
| `v_zero_received_store_summary` | 177 | 零实收门店汇总 |
| `v_zero_received_detail` | 49,236 | 零实收明细 |
#### 选址分析类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_store_nearest_neighbor_april` | 90 | 门店最近邻分析 |
| `v_store_spatial_pairs_april` | 4,005 | 门店空间配对 |
#### 营销与数据质量类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_marketing_plan_summary` | 31 | 营销方案汇总 |
| `v_data_quality_check` | 1 | 数据质量检查 |
| `v_operating_expense_data_quality` | 1 | 费用数据质量 |
| `v_distribution_data_quality` | — | 配送数据质量 |
| `v_store_location_data_quality` | — | 门店选址数据质量 |
#### 任务闭环类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_task_weekly_check` | 0 | 周度检查(空,待任务执行后产生数据) |
| `v_task_monthly_review` | 0 | 月度验收(空,待任务执行后产生数据) |
| `v_loop_health` | 1 | 闭环健康度 |
#### 标准化映射类
| 视图 | 行数 | 说明 |
|------|------|------|
| `v_member_level_mapping` | 89 | 会员等级标准化映射 |
| `v_position_mapping` | 262 | 岗位标准化映射 |
### 3.6 bill_fact 关键字段数据覆盖率
| 字段 | 非空数量 | 覆盖率 | 说明 |
|------|----------|--------|------|
| `member_id` | 231,438 | 13.9% | 8.3 万独立会员 |
| `member_level` | 231,438 | 13.9% | 等级 1-7 及 LV6/LV7 |
| `cashier` | ~970K | ~58% | 583 个独立收银员 |
| `waiter` | ~980K | ~59% | 591 个独立服务员 |
| `marketing_plan` | 53,208 | 3.2% | 31 种营销方案 |
| `shift_name` | ~1.67M | ~100% | 4 种班次 |
| `opened_at` / `closed_at` | ~1.67M | ~100% | 可计算用餐时长 |
| 各平台收入字段 | ~1.67M | ~100% | 美团/淘宝/京东/现金/支付宝/微信等 |
---
## 四、AI 经营智脑 — 已实现功能全景
### 4.1 页面与功能清单(34 个页面)
#### 经营总览(4 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **老板驾驶舱** | `/boss` | 利润瀑布图、利润机会池(6 类机会量化)、门店营收/利润排名、风险态势分布、P0/P1 门店实收覆盖、日度趋势、费用结构 |
| **总部驾驶舱** | `/` | 公司概览指标卡、日度趋势、同比/环比、门店风险分级分布、经营象限散点图、平台成本率分布、P0/P1 门店覆盖、闭环健康度、12 月趋势 |
| **态势感知** | `/situational-awareness` | 门店健康度综合评分(8 维雷达)、自动化阈值预警(红/橙/黄三级)、跨模块关联分析(客流-人力匹配度)、趋势预测(客流 P85 分位) |
| **银行授信** | `/bank` | 银行授信报告,营收/利润/费用/风险全维度展示 |
#### 角色工作台(5 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **区域经理** | `/regional` | 门店红黄绿列表、P0/P1 门店管理、周度检查记录、同类门店基准对比、区域汇总、周巡检填写 |
| **区域对比** | `/region-comparison` | 12 个区域营收/成本率/费用率/复购率横向对比、KPI 达成预测(基于当月日均外推月末达成率) |
| **店长工作台** | `/store` | 门店选择、经营概览(健康度评分 8 维雷达)、日度趋势、月度指标对比、餐段/品类/成本/会员/异常 6 个 Tab、核心 SKU 备货、任务执行、每日经营卡 |
| **任务管理** | `/tasks` | 任务列表(P0/P1/P2 分级)、任务创建、状态更新、过程证据、验收 |
| **月度验收** | `/monthly-review` | 月度复盘(完成率、活动清单、SKU 治理、指标有效性) |
#### 收入分析(4 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **营收分析** | `/revenue` | 日度营收汇总、渠道收入结构(9 渠道)、餐段收入分布、门店营收排名 |
| **平台优惠** | `/platform` | 三平台(美团/淘宝/京东)经济性、折扣/佣金/合并加权成本率、营销方案效果 |
| **会员复购** | `/member` | 会员 vs 非会员对比、复购率、复购频次、门店差异 |
| **会员 LTV** | `/member-ltv` | 会员生命周期价值、等级分布、活跃/沉睡/流失分层、消费次数分布 |
#### 成本与费用(8 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **菜品成本** | `/cost-analysis` | 10 Tab:成本总览、菜品盈利、原料差异、BOM 配方、供应链精简、包装耗材、数据质量、门店成本、可视化探索、调整管理 |
| **成本库存** | `/cost` | 理论 vs 实际成本对比、分类成本对标、库存效率 |
| **库存周转** | `/inventory-turnover` | 周转天数、损耗 TOP10、门店对比 |
| **中央厨房** | `/central-kitchen` | 中央厨房生产分析 |
| **配送对账** | `/distribution-reconciliation` | 配送明细对账 |
| **BOM 穿透** | `/bom-penetration` | BOM 层级穿透分析 |
| **生产要货** | `/production-plan` | 生产计划与要货管理 |
| **门店费用** | `/store-expense` | 10 Tab:费用总览、门店费用率排名、门店贡献利润、房租租约风险、外卖佣金、人效坪效、固定/变动费用拆分、盈亏平衡、亏损门店诊断、关停/续租/改造评估 |
#### 运营分析(5 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **商品 SKU** | `/sku` | ABC 分类、长尾治理、可展开详情、搭售分析 |
| **菜单工程** | `/menu-engineering` | ABC + 象限分类自动生成行动清单(保留并推广/优化提升/降本或提价/考虑淘汰) |
| **时间分析** | `/time` | 星期规律、小时规律 |
| **智能排班** | `/smart-scheduling` | 8 Tab:客流热力图、排班匹配度、人效对标、排班建议、考勤预警、员工分析、总体智能分析、人员招聘/解聘预测 |
| **员工绩效** | `/employee-performance` | 岗位分布、薪资绩效、出勤率、在职/离职状态 |
#### 风险与选址(2 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **风险内控** | `/risk` | 异常账单(分页)、零实收归因、收银员风险 |
| **门店选址** | `/site-selection` | 4 Tab:分段基准(场景×面积)、复制评分、重叠风险、区域基准;含门店地图可视化 |
#### 系统管理(3 页)
| 页面 | 路由 | 核心功能 |
|------|------|----------|
| **数据质量** | `/data-quality` | 账单/菜品/库存数据质量检查 |
| **指标字典** | `/indicators` | 26 个指标的定义、公式、数据来源、阈值管理 |
| **本体标准** | `/ontology` | 维度表/事实表/枚举表浏览、指标字典管理 |
### 4.2 后端 API 端点清单(120+
| 路由模块 | 挂载路径 | 端点数 | 主要功能 |
|----------|----------|--------|----------|
| `data.ts` | `/api` | 40+ | 概览、门店、成本、平台、会员、SKU、风险、营销、标杆、时间、渠道、选址、利润瀑布、同比环比 |
| `tasks.ts` | `/api/tasks` | 25+ | 任务 CRUD、自动生成、周度检查、月度验收、标杆经验、指标字典、闭环健康度、本体标准、月度复盘 |
| `cost-analysis.ts` | `/api/cost-analysis` | 25+ | 成本总览、菜品盈利、原料差异、BOM、供应链、包装、数据质量、门店成本、散点图、调整管理 |
| `store-expense.ts` | `/api/store-expense` | 12+ | 费用总览、排名、贡献利润、房租风险、佣金、人效坪效、固定变动拆分、盈亏平衡、亏损诊断、关停评估 |
| `smart-scheduling.ts` | `/api/smart-scheduling` | 15+ | 客流热力图、排班匹配度、人效对标、排班建议、考勤预警、员工分析、总体分析、招聘预测 |
| `situational-awareness.ts` | `/api/situational-awareness` | 4 | 健康度评分、自动化预警、关联分析、趋势预测 |
| `analytics-enhanced.ts` | `/api/analytics-enhanced` | 12+ | 会员 LTV、菜单工程行动、菜品搭配推荐、区域对比、KPI 预测、每日销售目标、门店会员活跃度、员工绩效、库存周转、营销 ROI、巡检计划、统一 KPI |
| `auth.ts` | `/api/auth` | 1 | JWT 登录认证 |
### 4.3 AI 智能分析能力清单
| 能力 | 实现方式 | 数据来源 |
|------|----------|----------|
| **门店健康度评分** | 8 维度加权评分(营收/成本/毛利/风险/复购/会员/任务/客单),雷达图展示 | `mv_store_risk_rating_monthly` + `mv_store_deep_diagnosis_monthly` |
| **自动化预警** | 阈值引擎,营收/成本/人力/平台/任务 5 类指标自动触发红/橙/黄三级预警 | 多物化视图联合查询 |
| **门店问题分级** | P0/P1/P2/P3 四级自动分级,基于问题数量和严重程度 | `mv_store_action_priority_deep_monthly` |
| **任务自动生成** | 存储函数 `f_generate_store_tasks`,基于门店指标偏差自动生成整改任务 | `bill_fact` + 各物化视图 |
| **利润瀑布分析** | 实收 → 食材成本 → 人工 → 房租 → 水电 → 宿舍 → 外卖佣金 → 其他费用 → 门店贡献利润 | `mv_store_operating_expense_monthly` |
| **利润机会池** | 6 类机会量化(平台费率优化/成本差异缩小/优惠率压降/复购率提升/客单价提升/人效提升),含置信度和责任人 | 多维交叉计算 |
| **菜单工程矩阵** | ABC 分类 × 象限分析 → 自动生成"保留并推广/优化提升/降本或提价/考虑淘汰"行动清单 | `mv_dish_sku_abc_monthly` |
| **菜品搭配推荐** | 购物篮关联分析,86,836 条搭配数据,支持套餐设计 | `mv_dish_pair_summary_monthly` |
| **区域 KPI 预测** | 基于当月已过天数日均收入外推月末达成率 | `bill_fact` 日度数据 |
| **客流预测** | 基于历史 4 周小时数据,按工作日/周末 + 小时维度计算 P85 分位 | `bill_fact` + `dim_calendar` |
| **排班建议** | 基于历史客流规律,分岗位生成时段人员配置建议 | `bill_fact` + `attendance_records` |
| **招聘/解聘预测** | 规则引擎,基于人效、客流、在岗人数匹配度生成人员调整建议 | `salary_detail_records` + `attendance_records` |
| **亏损门店诊断** | 自动分析亏损原因(高租金/低营收/高人工/高佣金)并给出建议 | `mv_store_operating_expense_monthly` |
| **关停/续租/改造评估** | 量化评估止损金额 vs 迁址成本,给出行动建议 | `mv_store_operating_expense_monthly` |
| **门店选址评分** | 场景 × 面积分段基准、复制评分、重叠风险分析 | `mv_store_site_*_monthly` |
| **态势关联分析** | 客流-人力匹配度:每门店每小时"每人在岗产出账单数" | `bill_fact` + `attendance_records` |
| **营销 ROI 基础版** | 对比有/无营销方案的客单价、毛利率差异 | `bill_fact.marketing_plan` |
| **巡检计划生成** | 基于门店风险等级自动生成巡检优先级和时间表 | `mv_store_risk_rating_monthly` |
---
## 五、四级管理角色功能矩阵
### 5.1 老板(Boss)— 战略决策级
| 功能 | 页面 | 已实现 | 说明 |
|------|------|:------:|------|
| 利润瀑布图 | 老板驾驶舱 | ✅ | 实收到贡献利润的 9 步瀑布 |
| 利润机会池 | 老板驾驶舱 | ✅ | 6 类机会量化,可展开详情,含置信度和责任人 |
| 门店营收/利润排名 | 老板驾驶舱 | ✅ | TOP5 / BOTTOM5 对比 |
| 风险态势分布 | 老板驾驶舱 | ✅ | 红黄绿三级 + 实收覆盖 |
| P0/P1 门店管理 | 老板驾驶舱 | ✅ | 门店列表 + 实收覆盖 |
| 日度趋势 | 老板驾驶舱 | ✅ | 日度实收折线图 |
| 费用结构 | 老板驾驶舱 | ✅ | 费用构成饼图 |
| 门店贡献利润 | 门店费用页 | ✅ | 10 Tab 完整费用分析 |
| 盈亏平衡分析 | 门店费用页 | ✅ | 安全边际率计算 |
| 亏损门店诊断 | 门店费用页 | ✅ | 自动归因 + 建议 |
| 关停/续租评估 | 门店费用页 | ✅ | 量化止损 vs 迁址 |
| 银行授信报告 | 银行授信页 | ✅ | 全维度经营数据展示 |
| 同比/环比 | 总部驾驶舱 | ✅ | 月度同比、环比 |
| 12 月趋势 | 总部驾驶舱 | ✅ | 长期趋势折线 |
| **现金流分析** | — | ❌ | 需接入财务系统(应收应付/账期) |
| **品牌/口碑监控** | — | ❌ | 需接入评价平台 API |
| **投资回报预测** | — | ❌ | 需新建量化模型 |
| **竞争对标** | — | ❌ | 需外部数据源 |
### 5.2 总部管理(HQ)— 运营优化级
| 功能 | 页面 | 已实现 | 说明 |
|------|------|:------:|------|
| 公司概览指标 | 总部驾驶舱 | ✅ | 实收/账单/客单/成本率/平台费率/复购率 |
| 门店风险分级 | 总部驾驶舱 | ✅ | 91 家门店红黄绿 |
| 经营象限分析 | 总部驾驶舱 | ✅ | 散点图(日均实收 × 毛利率) |
| 闭环健康度 | 总部驾驶舱 | ✅ | 任务生成率/执行率/检查率/验收率/推广率 |
| 营收分析 | 营收分析页 | ✅ | 渠道/餐段/门店排名 |
| 平台经济性 | 平台优惠页 | ✅ | 三平台合并加权成本率 |
| 会员复购 | 会员复购页 | ✅ | 复购率/频次/门店差异 |
| 会员 LTV 与分层 | 会员 LTV 页 | ✅ | 生命周期价值/等级/活跃度 |
| 菜品成本分析 | 菜品成本页 | ✅ | 10 Tab 全维度 |
| 菜单工程行动 | 菜单工程页 | ✅ | ABC + 行动清单 |
| SKU 治理 | 商品 SKU 页 | ✅ | ABC/长尾/搭售 |
| 门店费用分析 | 门店费用页 | ✅ | 10 Tab 全维度 |
| 库存周转 | 库存周转页 | ✅ | 周转天数/损耗 TOP |
| 时间分析 | 时间分析页 | ✅ | 星期/小时规律 |
| 智能排班 | 智能排班页 | ✅ | 8 Tab 完整排班 |
| 员工绩效 | 员工绩效页 | ✅ | 岗位/薪资/出勤 |
| 风险内控 | 风险内控页 | ✅ | 异常账单/零实收/收银员 |
| 态势感知 | 态势感知页 | ✅ | 健康度/预警/关联/预测 |
| 门店选址 | 门店选址页 | ✅ | 4 Tab + 地图 |
| 任务闭环 | 任务管理页 | ✅ | 自动生成/执行/验收/回滚 |
| 月度复盘 | 月度验收页 | ✅ | 完成率/活动/SKU/指标 |
| 标杆经验管理 | 任务管理页 | ✅ | 标准化经验 + 试点复制 |
| 指标字典管理 | 指标字典页 | ✅ | 26 指标定义/公式/阈值 |
| 本体标准 | 本体标准页 | ✅ | 维度/事实/枚举表浏览 |
| 数据质量监控 | 数据质量页 | ✅ | 账单/菜品/库存质量检查 |
| **营销 ROI 完整版** | — | ⚠️ | 基础版已有,缺活动预算/承担方数据 |
| **复购驱动因子** | — | ❌ | 需多维度交叉分析 |
| **新品上市追踪** | — | ❌ | dim_sku 空表 |
| **SOP 执行率** | — | ❌ | 需新建检查录入系统 |
| **顾客满意度/投诉** | — | ❌ | 需接入客服系统 |
| **食品安全/卫生** | — | ❌ | 需新建检查录入系统 |
### 5.3 区域管理(Regional)— 辖区对比与赋能级
| 功能 | 页面 | 已实现 | 说明 |
|------|------|:------:|------|
| 门店红黄绿列表 | 区域经理页 | ✅ | 按风险等级筛选 |
| P0/P1 门店管理 | 区域经理页 | ✅ | 优先级排序 |
| 周度检查 | 区域经理页 | ✅ | 连续不改善预警 + 巡检填写 |
| 同类门店基准 | 区域经理页 | ✅ | 标杆对比 |
| 区域汇总 | 区域经理页 | ✅ | 区域维度汇总 |
| 区域间对比排名 | 区域对比页 | ✅ | 12 区域营收/成本率/费用率/复购率 |
| KPI 达成预测 | 区域对比页 | ✅ | 基于日均外推月末达成率 |
| 辖区巡检计划 | 后端 API | ✅ | 按风险排序生成巡检优先级 |
| **区域最佳实践提炼** | — | ⚠️ | 标杆经验库有基础(3 条),需扩展 |
| **区域人才流动** | — | ❌ | dim_employee 空表 |
| **区域营销效果对比** | — | ⚠️ | 可按区域聚合,但活动维度元数据缺失 |
### 5.4 店长(Store)— 日常经营抓手级
| 功能 | 页面 | 已实现 | 说明 |
|------|------|:------:|------|
| 经营概览 | 店长工作台 | ✅ | 健康度评分 8 维雷达 |
| 日度趋势 | 店长工作台 | ✅ | 日度实收折线 |
| 月度指标对比 | 店长工作台 | ✅ | 本月 vs 目标 |
| 餐段分析 | 店长工作台 | ✅ | 餐段收入/客单/占比 |
| 品类结构 | 店长工作台 | ✅ | 品类贡献/占比 |
| 成本分析 | 店长工作台 | ✅ | 理论 vs 实际 |
| 会员分析 | 店长工作台 | ✅ | 会员占比/复购 |
| 异常账单 | 店长工作台 | ✅ | 异常明细 |
| 核心 SKU 备货 | 店长工作台 | ✅ | SKU 销量/搭售 |
| 每日经营卡 | 店长工作台 | ✅ | 6 模块异常检测 |
| 任务执行 | 店长工作台 | ✅ | 任务列表 + 执行 + 凭证 |
| 任务管理 | 任务管理页 | ✅ | 状态更新/过程证据 |
| 月度验收 | 月度验收页 | ✅ | 指标改善验证 |
| 每日销售目标 | 后端 API | ✅ | 按历史均值/餐段分解 |
| **员工绩效与激励** | 员工绩效页 | ✅ | 岗位/薪资/出勤(总部视角) |
| **本店会员活跃度** | 后端 API | ✅ | 按门店聚合会员活跃/沉睡/新增 |
| **库存预警与补货** | — | ⚠️ | 有库存数据,缺自动预警 |
| **食材损耗改善追踪** | 库存周转页 | ✅ | 损耗 TOP + 门店对比 |
| **改善行动模板库** | — | ⚠️ | store_task 有 138 条,可扩展 |
| **每日经营复盘** | — | ⚠️ | 每日经营卡已有,缺模板化复盘 |
| **顾客投诉/差评处理** | — | ❌ | 需接入客服系统 |
| **本店竞争分析** | — | ❌ | 需外部数据 |
---
## 六、管理闭环体系
### 6.1 日/周/月管理节奏
| 节奏 | 角色 | 动作 | 系统支撑 |
|------|------|------|----------|
| **每日** | 店长 | 开店前 10 分钟查看昨日经营卡(6 模块),处理 3 个异常 + 3 项待办 | 每日经营卡 API + 任务自动生成 |
| **每日** | 店长 | 餐前备货检查、餐中出品监控、餐后核对待损 | 核心 SKU 备货 + 异常账单 |
| **每周** | 区域经理 | 60 分钟周会:10 分钟看结果 → 20 分钟 P0/P1 → 15 分钟专项 → 10 分钟标杆 → 5 分钟确认责任 | 周度检查 API + 区域汇总 |
| **每月** | 总部 | 经营会:利润桥、门店升降级、任务完成率、活动停改留、SKU 治理、会员、标杆推广 | 月度验收 API + 闭环健康度 |
### 6.2 任务闭环全流程
```
数据发现问题 → 自动分级(P0/P1/P2/P3) → 生成门店任务 → 现场执行 → 周度检查 → 月度验收 → 有效经验标准化
↓ ↓ ↓ ↓ ↓ ↓ ↓
120+ API f_generate_ 任务 CRUD 过程证据 连续不改善 指标改善 标杆经验库
50+ 视图 store_tasks 状态更新 照片/文档 预警 验收标准 试点复制
```
### 6.3 闭环健康度指标
| 指标 | 说明 | 数据来源 |
|------|------|----------|
| 任务生成率 | 自动生成任务覆盖问题门店的比例 | `mv_loop_health` |
| 门店执行率 | 任务按截止日完成的比例 | `store_task` |
| 周度检查率 | 区域经理完成周度检查的比例 | `v_task_weekly_check` |
| 月度验收率 | 月度验收完成的比例 | `v_task_monthly_review` |
| 经验推广率 | 标准化经验推广的比例 | `standardized_practice` |
### 6.4 门店问题分级标准
| 等级 | 定义 | 响应时间 | 典型问题 |
|------|------|----------|----------|
| P0 | 数据失真、重大风险或四项以上综合问题 | 当日确认、7 天内形成方案 | 成本口径异常、重大账单异常、综合经营失控 |
| P1 | 对利润或顾客持续产生明显影响 | 48 小时确认、当月整改 | 高成本、高优惠、低毛利、低复购 |
| P2 | 单项指标偏弱但总体可控 | 一周内纳入计划 | 饮品搭售低、单餐段弱、库存略高 |
| P3 | 正常波动或观察项 | 持续监控 | 一次性轻微偏离 |
---
## 七、已确认的关键经营发现
### 7.1 门店分层
| 层级 | 门店数 | 管理要求 |
|------|--------|----------|
| P0-修复数据口径 | 6 | 先修数据,修复前不直接考核成本 |
| P0-综合专项整改 | 4 | 店长、区域、财务和商品联合整改 |
| P1-重点整改 | 23 | 纳入区域经理周度重点督导 |
| P2-单项改善 | 26 | 每店只抓 1-2 个短板 |
| 标杆候选 | 1 | 拆项验证经验,不整体照搬 |
| 持续跟踪 | 31 | 保持运营,监控指标恶化 |
### 7.2 成本效率悖论
高规模标准店平均实际成本率 29.86%,超理论 23.92%;中规模 28.70% / 21.04%;低规模 27.55% / 16.30%。规模越大成本效率反而越低,高峰期出品份量、备货、报损、赠送、交接和盘点未同步标准化。
### 7.3 平台让利负担
4 月三平台实收约 1,665.51 万元,折扣约 654.16 万元,佣金约 354.39 万元,合并加权成本率 37.72%。32 家门店超过或达到 38%,火锅北三环店和菜百店超过 40%。
### 7.4 菜单复杂度
1,745 个菜品中:44 个 A 类贡献 69.87% 收入,1,624 个 C 类仅贡献 10.10%880 个 SKU 仅一家门店销售,1,155 个 SKU 实收不足 1,000 元。建议首轮停用约 500 个低价值 SKU。
### 7.5 会员渗透
8.3 万独立会员,会员账单占比 13.9%。会员管理重点不是"拉了多少新会员",而是新会员是否发生第二次消费。
---
## 八、数据支撑能力评估
### 8.1 ✅ 有数据支撑,已实现(26 项)
菜单工程行动清单、菜品关联销售分析、每日销售目标分解、区域间对比排名、区域 KPI 达成预测、本店会员活跃度面板、复购驱动因子(基础)、员工绩效与激励、食材损耗改善追踪、库存周转与损耗趋势、营销 ROI(基础版)、改善行动模板库(基础)、辖区巡检计划生成、每日经营复盘(基础)、会员 LTV 与分层、利润瀑布、利润机会池、门店健康度评分、自动化预警、态势关联分析、客流预测、排班建议、亏损门店诊断、关停评估、门店选址评分、任务自动生成
### 8.2 ⚠️ 部分支撑,需补充数据(5 项)
| 功能 | 现有数据 | 缺失部分 |
|------|----------|----------|
| 会员 LTV 完整版 | bill_fact.member_id (8.3 万) | dim_member 空表,需 ETL 填充 |
| 营销 ROI 完整版 | bill_fact.marketing_plan (31 种) | dim_promotion 空表,缺活动预算/承担方 |
| 门店生命周期 | bill_fact 多月数据 | dim_store.open_date 大部分为空 |
| 区域人才流动 | salary_detail_records (3,326 员工) | dim_employee 空表,缺入职/离职日期 |
| 区域营销效果对比 | bill_fact.marketing_plan + dim_store.region | 活动维度元数据缺失 |
### 8.3 ❌ 无数据支撑,需新建数据源(8 项)
现金流分析(需财务系统)、品牌/口碑监控(需评价 API)、SOP 执行率(需检查录入系统)、顾客满意度/投诉(需客服系统)、食品安全/卫生(需检查录入系统)、新品上市追踪(需 dim_sku 补录)、竞争对标分析(需外部数据)、培训效果评估(需培训系统)
---
## 九、实施路线图与完成状态
### 第一批:数据完备,可立即启动(1-2 周)
| 序号 | 功能 | 状态 | 实现方式 |
|------|------|:----:|----------|
| 1 | 菜单工程行动清单 | ✅ 已完成 | 基于现有 ABC + 象限分类生成行动建议 |
| 2 | 菜品关联销售分析 | ✅ 已完成 | 展示搭配排行,推荐套餐组合 |
| 3 | 每日销售目标分解 | ✅ 已完成 | 按历史均值/餐段分解月度目标 |
| 4 | 区域间对比排名 | ✅ 已完成 | 按区域聚合现有指标 |
| 5 | 区域 KPI 达成预测 | ✅ 已完成 | 基于日均外推月末达成率 |
| 6 | 本店会员活跃度面板 | ✅ 已完成 | 按门店聚合会员活跃/沉睡/新增 |
| 7 | 改善行动模板库 | ✅ 基础完成 | store_task 138 条 + standardized_practice 3 条 |
| 8 | 辖区巡检计划生成 | ✅ 已完成 | 按风险排序生成巡检优先级 |
### 第二批:需少量数据补录或建模(2-4 周)
| 序号 | 功能 | 状态 | 说明 |
|------|------|:----:|------|
| 9 | 会员 LTV 与分层 | ✅ 已完成 | 从 bill_fact 聚合计算 |
| 10 | 营销活动 ROI(基础版) | ✅ 已完成 | 对比有/无营销方案的客单价、毛利率差异 |
| 11 | 复购驱动因子分析 | ⚠️ 基础版 | 会员消费频次与品类/活动关联 |
| 12 | 员工绩效与激励 | ✅ 已完成 | 按员工聚合绩效评分、出勤、薪资 |
| 13 | 库存周转与损耗趋势 | ✅ 已完成 | 计算周转天数、损耗率月度趋势 |
| 14 | 食材损耗改善追踪 | ✅ 已完成 | 按门店/原料追踪损耗 TOP |
| 15 | 每日经营复盘 | ✅ 基础完成 | 每日经营卡自动生成亮点/异常 |
### 第三批:需补录主数据(1-2 月)
| 序号 | 功能 | 状态 | 需补录数据 |
|------|------|:----:|------------|
| 16 | 门店生命周期分析 | ❌ 待补录 | dim_store.open_date(大部分为空) |
| 17 | 营销活动 ROI(完整版) | ❌ 待补录 | dim_promotion(空表) |
| 18 | 区域人才流动 | ❌ 待补录 | dim_employee(空表) |
### 第四批:需新建数据源(长期)
| 序号 | 功能 | 状态 | 需新建数据源 |
|------|------|:----:|-------------|
| 19 | 现金流分析 | ❌ 待建 | 接入财务系统 |
| 20 | 品牌/口碑监控 | ❌ 待建 | 接入评价平台 API |
| 21 | 顾客满意度/投诉 | ❌ 待建 | 接入客服系统 |
| 22 | SOP 执行率 | ❌ 待建 | 新建检查录入系统 |
| 23 | 食品安全/卫生 | ❌ 待建 | 新建检查录入系统 |
| 24 | 新品上市追踪 | ❌ 待建 | dim_sku 补录 |
| 25 | 竞争对标分析 | ❌ 待建 | 外部数据源 |
| 26 | 培训效果评估 | ❌ 待建 | 接入培训系统 |
---
## 十、数据补充计划
### 10.1 已从现有数据自动 ETL 生成 ✅
ETL 脚本:`db/etl_batch1_fill_dims.sql`(已执行)
| 目标表 | 行数 | 来源 | 生成方式 |
|--------|------|------|----------|
| `dim_member` | 83,101 | `bill_fact` | DISTINCT 8.3 万会员 ID,聚合首次/末次消费、总消费、状态(活跃/沉睡/流失)、标签(新客/高频/高价值) |
| `dim_employee` | 3,326 | `salary_detail_records` | DISTINCT 3,326 员工编码,标准化岗位(262 种 → 15 类),入职/离职日期 |
| `dim_channel` | 11 | 人工定义 | 11 渠道:堂食/美团到店/美团外卖/淘宝外卖/京东外卖/抖音/支付宝/微信/现金/银联/挂账 |
| `dim_sku` | 1,745 | `dish_sales_details` | 1,745 个菜品,编码 DISH-00001 ~ DISH-01745,含 ABC 分类/品类 |
| `v_member_level_mapping` | — | `bill_fact` | 会员等级 1-7 → 普通/银卡/金卡/钻石 标准化映射视图 |
| `v_position_mapping` | — | `salary_detail_records` | 262 种岗位 → 15 类标准岗位映射视图 |
### 10.2 需人工补录(1-2 周)
| 数据 | 说明 |
|------|------|
| `dim_store.open_date` | 91 家店开业日期 |
| `dim_store.area_sqm` / `seat_count` | 91 家店面积和座位数 |
| `dim_promotion` | 31 种营销方案的元数据(预算/承担方/目标客群) |
| `dim_sku` | 1,745 个菜品的 SKU 编码和状态标记 |
### 10.3 需外部接入(长期)
| 数据 | 获取方式 | 优先级 |
|------|----------|--------|
| 现金流数据 | 接入财务系统(金蝶/用友) | 高 |
| 评价/口碑数据 | 美团商家 API 或爬虫 | 高 |
| 投诉记录 | 接入客服系统或新建录入 | 中 |
| SOP 检查记录 | 新建检查录入表(可做小程序) | 中 |
| 食品安全检查 | 新建录入表或接入监管系统 | 中 |
| 培训记录 | 接入培训系统或新建录入 | 低 |
| 竞品数据 | 爬虫或第三方数据服务 | 低 |
---
## 十一、管理层应坚持的十条原则
1. 营业额不是利润,订单量不是经营质量
2. 门店必须同业态、同规模比较
3. 一个门店每月最多抓两个核心问题
4. 数据异常先修口径,再追经营责任
5. 平台必须看合并加权成本和贡献毛利
6. 会员必须看第二次消费,不只看新增
7. SKU 必须有准入和退出,不能只增不减
8. 成本偏离是排查线索,不能在缺 BOM 时当成真实浪费
9. 标杆经验要拆项试点,不能整店照搬
10. 每个指标最终都要落到责任人、动作、截止日和验收结果
---
## 十二、系统价值总结
### 12.1 "看清企业现在"的能力
| 维度 | 能力 | 支撑 |
|------|------|------|
| **经营全貌** | 91 家门店实收/账单/客单/成本/费用/利润一目了然 | 老板驾驶舱 + 总部驾驶舱 |
| **风险态势** | 红黄绿三级风险 + 自动化预警 + 健康度评分 | 态势感知 + 风险内控 |
| **成本透视** | 理论 vs 实际成本、原料分类差异、BOM 穿透 | 菜品成本 10 Tab |
| **费用拆解** | 人工/租金/水电/佣金/配送全维度拆分 | 门店费用 10 Tab |
| **会员洞察** | 8.3 万会员 LTV、分层、活跃度、复购率 | 会员复购 + 会员 LTV |
| **商品结构** | 1,745 个 SKU 的 ABC 分类、搭配、长尾 | 商品 SKU + 菜单工程 |
| **人员效率** | 排班匹配度、人效对标、考勤预警 | 智能排班 8 Tab |
| **门店诊断** | 亏损归因、盈亏平衡、关停评估 | 门店费用页 |
### 12.2 "掌控经营未来"的能力
| 维度 | 能力 | 支撑 |
|------|------|------|
| **利润机会量化** | 6 类利润提升机会,量化金额和置信度 | 利润机会池 |
| **KPI 达成预测** | 基于当月进度外推月末达成率 | 区域对比页 |
| **客流预测** | 基于历史 4 周小时数据 P85 分位预测 | 态势感知页 |
| **排班建议** | 基于客流规律分岗位生成人员配置 | 智能排班页 |
| **招聘/解聘预测** | 规则引擎生成人员调整建议 | 智能排班页 |
| **门店选址评分** | 场景基准 + 复制评分 + 重叠风险 | 门店选址页 |
| **菜单优化行动** | 自动生成"保留/提升/降本/淘汰"清单 | 菜单工程页 |
| **任务自动生成** | 基于指标偏差自动生成整改任务 | 任务闭环系统 |
| **标杆经验复制** | 标准化经验 + 试点跟踪 + 推广管理 | 标杆经验库 |
### 12.3 量化成果
| 指标 | 数值 |
|------|------|
| 前端页面 | 34 个 |
| 后端 API 端点 | 120+ |
| 数据库物化视图 | 29 个 |
| 数据库分析视图 | 72 个 |
| 覆盖门店 | 91 家 |
| 覆盖区域 | 12 个 |
| 会员数据 | 8.3 万独立会员 |
| 菜品数据 | 1,745 个 SKU |
| 员工数据 | 3,326 人 |
| 账单数据 | 167 万行 |
| 菜品明细 | 555 万行 |
| 库存记录 | 5.5 万行 |
| 已实现智能分析能力 | 26 项 |
| 待实现(需新数据源) | 8 项 |
---
## 十三、下一步建议
### 短期(1-2 周)
1. **录入活动主数据** — 31 种营销方案的预算、承担方、目标客群,填充 dim_promotion(唯一仍为空的维度表)
2. **补录门店主数据** — 91 家店的开业日期、面积、座位数(dim_store 部分字段为空)
3. **多月数据积累** — 持续导入销售、菜品、库存成本数据,建立趋势分析基础
### 中期(1-2 月)
4. **建立 SKU 编码体系** — dim_sku 已有基础编码(DISH-00001 ~ DISH-01745),需补充状态标记和标签
5. **多月数据积累** — 持续导入销售、菜品、库存成本数据,建立趋势分析基础
### 长期(3-6 月)
7. **接入财务系统** — 现金流分析(应收应付/账期/资金链)
8. **接入评价平台** — 品牌/口碑监控(评分/差评/NPS)
9. **新建检查录入系统** — SOP 执行率、食品安全/卫生
10. **接入客服系统** — 顾客满意度/投诉分析
---
> **总结**:本系统已构建了完整的连锁餐饮行业数字本体(5 层数据架构、20+ 维度/事实表、50+ 视图/物化视图)和 AI 经营智脑(34 个页面、120+ API、26 项智能分析能力),覆盖老板/总部/区域/店长四级管理角色的日常经营管理全流程。从"看清企业现在"到"掌控经营未来",已具备数据发现问题、自动分级、生成任务、闭环验收的完整能力。下一步重点应转向数据补充(ETL 填充空表 + 外部系统接入),以解锁剩余 8 项待实现功能,实现从"经营诊断体系"到"门店利润管理、成本归因和预测决策体系"的全面升级。
+10
View File
@@ -31,6 +31,11 @@ import { DistributionReconciliationPage } from '@/pages/DistributionReconciliati
import { BomPenetrationPage } from '@/pages/BomPenetrationPage'
import { ProductionPlanPage } from '@/pages/ProductionPlanPage'
import { LoginPage } from '@/pages/LoginPage'
import { MemberLTVPage } from '@/pages/MemberLTVPage'
import { MenuEngineeringPage } from '@/pages/MenuEngineeringPage'
import { RegionComparisonPage } from '@/pages/RegionComparisonPage'
import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage'
import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage'
const queryClient = new QueryClient({
defaultOptions: {
@@ -102,6 +107,11 @@ export default function App() {
<Route path="/smart-scheduling" element={<SmartSchedulingPage />} />
<Route path="/situational-awareness" element={<SituationalAwarenessPage />} />
<Route path="/login" element={<Navigate to="/" />} />
<Route path="/member-ltv" element={<MemberLTVPage />} />
<Route path="/menu-engineering" element={<MenuEngineeringPage />} />
<Route path="/region-comparison" element={<RegionComparisonPage />} />
<Route path="/employee-performance" element={<EmployeePerformancePage />} />
<Route path="/inventory-turnover" element={<InventoryTurnoverPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+123
View File
@@ -0,0 +1,123 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
interface KPISectionProps {
month: string
level: 'hq' | 'region' | 'store'
storeCode?: string
defaultOpen?: boolean
}
export function KPISection({ month, level, storeCode, defaultOpen = true }: KPISectionProps) {
const params: any = { month, level }
if (level === 'store' && storeCode) params.store_code = storeCode
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/kpi', month, level, storeCode],
queryFn: () => api.get('/analytics-enhanced/kpi', { params }),
})
if (isLoading) {
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标" defaultOpen={defaultOpen}>
<p className="py-8 text-center text-sm text-muted-foreground">...</p>
</CollapsibleSection>
)
}
const d = (data as any)?.data
if (level === 'region') {
const rows = Array.isArray(d) ? d : []
if (rows.length === 0) {
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标" defaultOpen={defaultOpen}>
<p className="py-8 text-center text-sm text-muted-foreground"></p>
</CollapsibleSection>
)
}
const chartData = rows.map((r: any) => ({
name: r.region,
收入目标: Number(r.revenue_target || 0),
收入实际: Number(r.actual_revenue || 0),
利润目标: Number(r.profit_target || 0),
利润实际: Number(r.actual_profit || 0),
收入达成率: Number(r.revenue_achievement_pct || 0),
利润达成率: Number(r.profit_achievement_pct || 0),
}))
const totalActualRevenue = rows.reduce((s: number, r: any) => s + Number(r.actual_revenue || 0), 0)
const totalTargetRevenue = rows.reduce((s: number, r: any) => s + Number(r.revenue_target || 0), 0)
const totalActualProfit = rows.reduce((s: number, r: any) => s + Number(r.actual_profit || 0), 0)
const totalTargetProfit = rows.reduce((s: number, r: any) => s + Number(r.profit_target || 0), 0)
const revAchievement = totalTargetRevenue > 0 ? totalActualRevenue / totalTargetRevenue * 100 : 0
const profitAchievement = totalTargetProfit > 0 ? totalActualProfit / totalTargetProfit * 100 : 0
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标 · 各区域对比" defaultOpen={defaultOpen}>
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="收入达成率" value={revAchievement} format="percent" status={revAchievement >= 100 ? 'good' : revAchievement >= 80 ? 'warn' : 'bad'} description="实际收入 / 目标收入" />
<MetricCard title="利润达成率" value={profitAchievement} format="percent" status={profitAchievement >= 100 ? 'good' : profitAchievement >= 80 ? 'warn' : 'bad'} description="实际利润 / 目标利润" />
<MetricCard title="目标收入" value={totalTargetRevenue} format="currency" description="各区域目标合计" />
<MetricCard title="目标利润" value={totalTargetProfit} format="currency" description="各区域利润目标合计" />
</div>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" domain={[80, 120]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name.includes('达成率') ? `${Number(v).toFixed(1)}%` : formatCurrency(v)} />
<Legend />
<Bar yAxisId="left" dataKey="收入目标" fill="#eab308" />
<Bar yAxisId="left" dataKey="收入实际" fill="#22c55e" />
<Bar yAxisId="left" dataKey="利润目标" fill="#f97316" />
<Bar yAxisId="left" dataKey="利润实际" fill="#3b82f6" />
<Line yAxisId="right" type="monotone" dataKey="收入达成率" stroke="#ef4444" strokeWidth={2} dot={{ r: 4 }} />
<Line yAxisId="right" type="monotone" dataKey="利润达成率" stroke="#a855f7" strokeWidth={2} dot={{ r: 4 }} />
</ComposedChart>
</ResponsiveContainer>
</CollapsibleSection>
)
}
// HQ / Store 单实体模式
const kpi = d || {}
const revAchievement = Number(kpi.revenue_achievement_pct || 0)
const profitAchievement = Number(kpi.profit_achievement_pct || 0)
const profitMargin = Number(kpi.profit_margin_pct || 0)
const chartData = [
{ name: '收入', 目标: Number(kpi.revenue_target || 0), 实际: Number(kpi.actual_revenue || 0), 达成率: revAchievement },
{ name: '利润', 目标: Number(kpi.profit_target || 0), 实际: Number(kpi.actual_profit || 0), 达成率: profitAchievement },
]
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标" defaultOpen={defaultOpen}>
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="收入达成率" value={revAchievement} format="percent" status={revAchievement >= 100 ? 'good' : revAchievement >= 80 ? 'warn' : 'bad'} description="实际收入 / 目标收入" />
<MetricCard title="利润达成率" value={profitAchievement} format="percent" status={profitAchievement >= 100 ? 'good' : profitAchievement >= 80 ? 'warn' : 'bad'} description="实际利润 / 目标利润" />
<MetricCard title="目标收入" value={kpi.revenue_target} format="currency" description="月度收入目标" />
<MetricCard title="利润率" value={profitMargin} format="percent" status={profitMargin >= 10 ? 'good' : profitMargin >= 5 ? 'warn' : 'bad'} description="实际利润 / 实际收入" />
</div>
<ResponsiveContainer width="100%" height={250}>
<ComposedChart data={chartData} 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" domain={[0, 120]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '达成率' ? `${Number(v).toFixed(1)}%` : formatCurrency(v)} />
<Legend />
<Bar yAxisId="left" dataKey="目标" fill="#eab308" />
<Bar yAxisId="left" dataKey="实际" fill="#22c55e" />
<Line yAxisId="right" type="monotone" dataKey="达成率" stroke="#ef4444" strokeWidth={2} dot={{ r: 5 }} />
</ComposedChart>
</ResponsiveContainer>
</CollapsibleSection>
)
}
+5
View File
@@ -35,6 +35,7 @@ const menuGroups: MenuGroup[] = [
title: '角色工作台',
items: [
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept'] },
{ 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'] },
@@ -46,6 +47,7 @@ const menuGroups: MenuGroup[] = [
{ path: '/revenue', label: '营收分析', icon: BarChart3, roles: ['hq', 'dept'] },
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
{ path: '/member-ltv', label: '会员LTV', icon: Users, roles: ['hq', 'dept'] },
],
},
{
@@ -53,6 +55,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] },
{ path: '/cost', label: '成本库存', icon: Utensils, roles: ['hq', 'dept'] },
{ path: '/inventory-turnover', label: '库存周转', icon: Utensils, roles: ['hq', 'dept'] },
{ path: '/central-kitchen', label: '中央厨房', icon: ChefHat, roles: ['hq', 'dept'] },
{ path: '/distribution-reconciliation', label: '配送对账', icon: Truck, roles: ['hq', 'dept'] },
{ path: '/bom-penetration', label: 'BOM穿透', icon: Network, roles: ['hq', 'dept'] },
@@ -64,8 +67,10 @@ const menuGroups: MenuGroup[] = [
title: '运营分析',
items: [
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
{ path: '/menu-engineering', label: '菜单工程', icon: Package, roles: ['hq', 'dept'] },
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
{ path: '/employee-performance', label: '员工绩效', icon: CalendarClock, roles: ['hq', 'dept'] },
],
},
{
+4
View File
@@ -9,6 +9,7 @@ import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { KPISection } from '@/components/KPISection'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -224,6 +225,9 @@ export function BossPage() {
<MetricCard title="客单价" value={ex?.avg_bill_value} format="currency" trend={trend((last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} />
</div>
{/* ①b KPI达成率 */}
<KPISection month={month} level="hq" />
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
@@ -0,0 +1,205 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#a855f7', '#64748b', '#ec4899', '#14b8a6', '#f97316', '#6366f1']
export function EmployeePerformancePage() {
const [positionFilter, setPositionFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/employee/performance', positionFilter, statusFilter, page],
queryFn: () => api.get('/analytics-enhanced/employee/performance', {
params: { position: positionFilter, status: statusFilter, page, page_size: 50 },
}),
})
if (isLoading) {
return <LoadingSpinner text="加载员工绩效数据..." />
}
const positionSummary = (data as any)?.data?.position_summary || []
const employees = (data as any)?.data?.employees || []
const meta = (data as any)?.meta || {}
const totalHeadcount = positionSummary.reduce((s: number, r: any) => s + Number(r.headcount || 0), 0)
const totalActive = positionSummary.reduce((s: number, r: any) => s + Number(r.active_count || 0), 0)
const avgPerf = positionSummary.length > 0
? positionSummary.reduce((s: number, r: any) => s + Number(r.avg_perf_score || 0), 0) / positionSummary.length
: 0
const avgSalary = positionSummary.length > 0
? positionSummary.reduce((s: number, r: any) => s + Number(r.avg_gross_pay || 0), 0) / positionSummary.length
: 0
const positionChartData = positionSummary.slice(0, 10).map((r: any) => ({
name: r.position,
人数: Number(r.headcount || 0),
在职: Number(r.active_count || 0),
}))
const positionPieData = positionSummary.slice(0, 8).map((r: any) => ({
name: r.position,
value: Number(r.headcount || 0),
}))
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"> · · </p>
</div>
</div>
{/* 概览指标 */}
<CollapsibleSection title="员工总览" subtitle="全员汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总人数" value={totalHeadcount} format="number" description="全部员工(含离职)" />
<MetricCard title="在职人数" value={totalActive} format="number" description="当前在职员工" status="good" />
<MetricCard title="平均绩效分" value={avgPerf} format="number" description="全员平均绩效评分" />
<MetricCard title="平均月薪" value={avgSalary} format="currency" description="全员平均应发工资" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="岗位人数分布" subtitle="TOP10岗位人数与在职数">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={positionChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Legend />
<Bar dataKey="人数" fill="#3b82f6" />
<Bar dataKey="在职" fill="#22c55e" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="岗位占比" subtitle="各岗位人数占比">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie data={positionPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
{positionPieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '人数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 岗位汇总 */}
<CollapsibleSection title="岗位绩效汇总" subtitle="各岗位人数、薪资、绩效、出勤率">
<FilterableTable
data={positionSummary}
filterKey="position"
filterLabel="全部岗位"
sortOptions={[
{ key: 'headcount', label: '人数' },
{ key: 'avg_gross_pay', label: '平均工资' },
{ key: 'avg_perf_score', label: '绩效分' },
{ key: 'avg_attendance_rate', label: '出勤率' },
]}
defaultSort="headcount"
defaultOrder="desc"
columns={[
{ key: 'position', label: '岗位' },
{ key: 'headcount', label: '总人数', align: 'right', render: (r) => formatNumber(r.headcount) },
{ key: 'active_count', label: '在职', align: 'right', render: (r) => formatNumber(r.active_count) },
{ key: 'avg_gross_pay', label: '平均应发', align: 'right', render: (r) => formatCurrency(r.avg_gross_pay) },
{ key: 'avg_perf_score', label: '绩效分', align: 'right', render: (r) => Number(r.avg_perf_score || 0).toFixed(2) },
{ key: 'avg_attendance_rate', label: '出勤率', align: 'right', render: (r) => {
const v = Number(r.avg_attendance_rate || 0)
return <span className={v >= 95 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}%</span>
}},
]}
/>
</CollapsibleSection>
{/* 筛选器 */}
<div className="flex items-center gap-3">
<select
value={positionFilter}
onChange={(e) => { setPositionFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
{positionSummary.map((r: any) => (
<option key={r.position} value={r.position}>{r.position}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
<option value="在职"></option>
<option value="离职"></option>
</select>
<span className="text-xs text-muted-foreground"> {meta.total || 0} </span>
</div>
{/* 员工明细 */}
<CollapsibleSection title="员工明细" subtitle="按应发工资降序">
<FilterableTable
data={employees}
filterKey="employee_id"
filterLabel="全部员工"
sortOptions={[
{ key: 'gross_pay', label: '应发工资' },
{ key: 'perf_score', label: '绩效分' },
{ key: 'actual_attend', label: '出勤天数' },
]}
defaultSort="gross_pay"
defaultOrder="desc"
columns={[
{ key: 'employee_id', label: '工号' },
{ key: 'position', label: '岗位' },
{ key: 'status', label: '状态', render: (r) => {
const color = r.status === '在职' ? 'text-green-600' : 'text-red-600'
return <span className={color}>{r.status}</span>
}},
{ key: 'org_level2', label: '所属机构' },
{ key: 'org_level3', label: '部门/区域' },
{ key: 'gross_pay', label: '应发工资', align: 'right', render: (r) => formatCurrency(r.gross_pay) },
{ key: 'net_pay', label: '实发工资', align: 'right', render: (r) => formatCurrency(r.net_pay) },
{ key: 'perf_score', label: '绩效分', align: 'right', render: (r) => Number(r.perf_score || 0).toFixed(2) },
{ key: 'actual_attend', label: '出勤', align: 'right', render: (r) => `${r.actual_attend || 0}/${r.expected_attend || 0}` },
{ key: 'overtime_pay', label: '加班费', align: 'right', render: (r) => formatCurrency(r.overtime_pay) },
{ key: 'bonus', label: '奖金', align: 'right', render: (r) => formatCurrency(r.bonus) },
{ key: 'hire_date', label: '入职日期', render: (r) => r.hire_date ? String(r.hire_date).slice(0, 10) : '' },
]}
/>
</CollapsibleSection>
{/* 分页 */}
{meta.total > 50 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
<span className="text-sm text-muted-foreground"> {page} / {Math.ceil(meta.total / 50)} </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(meta.total / 50)}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
</div>
)}
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
export function InventoryTurnoverPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/inventory/turnover', month],
queryFn: () => api.get('/analytics-enhanced/inventory/turnover', { params: { month } }),
})
if (isLoading) {
return <LoadingSpinner text="加载库存周转数据..." />
}
const turnover = (data as any)?.data?.turnover || []
const wasteTop = (data as any)?.data?.waste_top || []
const totalOpening = turnover.reduce((s: number, r: any) => s + Number(r.opening_value || 0), 0)
const totalConsumption = turnover.reduce((s: number, r: any) => s + Number(r.consumption_value || 0), 0)
const totalWaste = turnover.reduce((s: number, r: any) => s + Number(r.waste_value || 0), 0)
const totalEnding = turnover.reduce((s: number, r: any) => s + Number(r.ending_value || 0), 0)
const avgTurnoverDays = turnover.length > 0
? turnover.reduce((s: number, r: any) => s + Number(r.turnover_days || 0), 0) / turnover.length
: 0
const turnoverChartData = turnover.slice(0, 15).map((r: any) => ({
name: r.store_name,
周转天数: Number(r.turnover_days || 0),
期末库存: Number(r.ending_value || 0),
}))
const wasteChartData = wasteTop.slice(0, 10).map((r: any) => ({
name: r.material_name,
损耗金额: Number(r.waste_value || 0),
}))
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"> · TOP · · {month}</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="库存总览" subtitle="全门店库存汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="期初库存" value={totalOpening} format="currency" description="月初库存总值" />
<MetricCard title="消耗金额" value={totalConsumption} format="currency" description="当月消耗总值" />
<MetricCard title="期末库存" value={totalEnding} format="currency" description="月末库存总值" />
<MetricCard title="平均周转天数" value={avgTurnoverDays} format="number" description="库存平均周转天数" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="损耗金额" value={totalWaste} format="currency" description="当月损耗总金额" status="bad" />
<MetricCard title="损耗率" value={totalConsumption > 0 ? totalWaste / totalConsumption * 100 : 0} format="percent" description="损耗金额/消耗金额" status="bad" />
<MetricCard title="门店数" value={turnover.length} format="number" description="有库存数据的门店数" />
<MetricCard title="损耗品类数" value={wasteTop.length} format="number" description="有损耗记录的品类数" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="门店周转天数" subtitle="TOP15门店库存周转天数与期末库存">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={turnoverChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis yAxisId="left" tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '期末库存' ? formatCurrency(v) : `${v}`} />
<Legend />
<Bar yAxisId="left" dataKey="周转天数" fill="#3b82f6" />
<Bar yAxisId="right" dataKey="期末库存" fill="#eab308" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="损耗金额TOP10" subtitle="损耗金额最高的品类">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={wasteChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={100} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend />
<Bar dataKey="损耗金额" fill="#ef4444" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 门店周转明细 */}
<CollapsibleSection title="门店库存周转明细" subtitle="按周转天数升序(天数越少越好)">
<FilterableTable
data={turnover}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'turnover_days', label: '周转天数' },
{ key: 'ending_value', label: '期末库存' },
{ key: 'consumption_value', label: '消耗金额' },
{ key: 'waste_value', label: '损耗金额' },
]}
defaultSort="turnover_days"
defaultOrder="asc"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'opening_value', label: '期初库存', align: 'right', render: (r) => formatCurrency(r.opening_value) },
{ key: 'purchase_value', label: '采购金额', align: 'right', render: (r) => formatCurrency(r.purchase_value) },
{ key: 'consumption_value', label: '消耗金额', align: 'right', render: (r) => formatCurrency(r.consumption_value) },
{ key: 'ending_value', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_value) },
{ key: 'waste_value', label: '损耗金额', align: 'right', render: (r) => {
const v = Number(r.waste_value || 0)
return <span className={v > 1000 ? 'text-red-600 font-medium' : ''}>{formatCurrency(v)}</span>
}},
{ key: 'turnover_days', label: '周转天数', align: 'right', render: (r) => {
const v = Number(r.turnover_days || 0)
return <span className={v <= 7 ? 'text-green-600' : v <= 14 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
}},
]}
/>
</CollapsibleSection>
{/* 损耗TOP明细 */}
<CollapsibleSection title="损耗品类TOP20" subtitle="按损耗金额降序">
<FilterableTable
data={wasteTop}
filterKey="material_name"
filterLabel="全部品类"
sortOptions={[
{ key: 'waste_value', label: '损耗金额' },
{ key: 'waste_qty', label: '损耗数量' },
{ key: 'affected_stores', label: '影响门店数' },
]}
defaultSort="waste_value"
defaultOrder="desc"
columns={[
{ key: 'material_name', label: '物料名称' },
{ key: 'waste_qty', label: '损耗数量', align: 'right', render: (r) => formatNumber(r.waste_qty) },
{ key: 'waste_value', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.waste_value) },
{ key: 'affected_stores', label: '影响门店数', align: 'right', render: (r) => formatNumber(r.affected_stores) },
]}
/>
</CollapsibleSection>
</div>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#a855f7', '#64748b']
export function MemberLTVPage() {
const [statusFilter, setStatusFilter] = useState('')
const [levelFilter, setLevelFilter] = useState('')
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/member/ltv', statusFilter, levelFilter, page],
queryFn: () => api.get('/analytics-enhanced/member/ltv', {
params: { status: statusFilter, level: levelFilter, page, page_size: 50 },
}),
})
if (isLoading) {
return <LoadingSpinner text="加载会员LTV数据..." />
}
const summary = (data as any)?.data?.summary || {}
const levelDist = (data as any)?.data?.level_distribution || []
const members = (data as any)?.data?.members || []
const meta = (data as any)?.meta || {}
const levelChartData = levelDist.map((r: any) => ({
name: r.member_level,
人数: Number(r.count),
平均消费: Number(r.avg_revenue),
}))
const levelPieData = levelDist.map((r: any) => ({
name: r.member_level,
value: Number(r.count),
}))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">LTV与分层运营</h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · </p>
</div>
</div>
{/* 概览指标 */}
<CollapsibleSection title="会员总览" subtitle="全量会员LTV与活跃度">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总会员数" value={Number(summary.total_members || 0)} format="number" description="已识别的独立会员总数" />
<MetricCard title="平均LTV" value={Number(summary.avg_ltv || 0)} format="currency" description="会员生命周期平均消费金额" />
<MetricCard title="平均消费次数" value={Number(summary.avg_orders || 0)} format="number" description="会员平均到店消费次数" />
<MetricCard title="会员总收入" value={Number(summary.total_revenue || 0)} format="currency" description="全部会员累计消费金额" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="活跃会员" value={Number(summary.active_count || 0)} format="number" description="30天内有消费" status="good" />
<MetricCard title="沉睡会员" value={Number(summary.dormant_count || 0)} format="number" description="30-90天未消费" status="warn" />
<MetricCard title="流失会员" value={Number(summary.churned_count || 0)} format="number" description="90天以上未消费" status="bad" />
<MetricCard title="活跃率" value={Number(summary.active_count || 0) / Number(summary.total_members || 1) * 100} format="percent" description="活跃会员占总会员比例" />
</div>
</CollapsibleSection>
{/* 等级分布图 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="会员等级分布" subtitle="各等级人数与平均消费">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={levelChartData} 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" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '平均消费' ? formatCurrency(v) : formatNumber(v)} />
<Legend />
<Bar yAxisId="left" dataKey="人数" fill="#3b82f6" />
<Bar yAxisId="right" dataKey="平均消费" fill="#eab308" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="等级占比" subtitle="各等级会员占比">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={levelPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
{levelPieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '人数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 筛选器 */}
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
<option value="活跃"></option>
<option value="沉睡"></option>
<option value="流失"></option>
</select>
<select
value={levelFilter}
onChange={(e) => { setLevelFilter(e.target.value); setPage(1) }}
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>
<span className="text-xs text-muted-foreground"> {meta.total || 0} </span>
</div>
{/* 会员明细 */}
<CollapsibleSection title="会员明细" subtitle="按总消费降序">
<FilterableTable
data={members}
filterKey="member_id"
filterLabel="全部分员"
sortOptions={[
{ key: 'total_revenue', label: '总消费' },
{ key: 'total_orders', label: '消费次数' },
{ key: 'register_date', label: '注册日期' },
{ key: 'last_order_date', label: '最近消费' },
]}
defaultSort="total_revenue"
defaultOrder="desc"
columns={[
{ key: 'member_id', label: '会员ID' },
{ key: 'member_level', label: '等级', render: (r) => {
const color = r.member_level === '钻石' ? 'text-purple-600' : r.member_level === '金卡' ? 'text-yellow-600' : r.member_level === '银卡' ? 'text-blue-600' : 'text-gray-500'
return <span className={color}>{r.member_level}</span>
}},
{ key: 'status', label: '状态', render: (r) => {
const color = r.status === '活跃' ? 'text-green-600' : r.status === '沉睡' ? 'text-yellow-600' : 'text-red-600'
return <span className={color}>{r.status}</span>
}},
{ key: 'total_orders', label: '消费次数', align: 'right', render: (r) => formatNumber(r.total_orders) },
{ key: 'total_revenue', label: '总消费', align: 'right', render: (r) => formatCurrency(r.total_revenue) },
{ key: 'register_date', label: '注册日期', render: (r) => r.register_date ? String(r.register_date).slice(0, 10) : '' },
{ key: 'last_order_date', label: '最近消费', render: (r) => r.last_order_date ? String(r.last_order_date).slice(0, 10) : '' },
{ key: 'register_store_name', label: '注册门店' },
{ key: 'register_channel', label: '注册渠道' },
]}
/>
</CollapsibleSection>
{/* 分页 */}
{meta.total > 50 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
<span className="text-sm text-muted-foreground"> {page} / {Math.ceil(meta.total / 50)} </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(meta.total / 50)}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
</div>
)}
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#22c55e', '#3b82f6', '#eab308', '#ef4444']
export function MenuEngineeringPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/menu-engineering/actions', month],
queryFn: () => api.get('/analytics-enhanced/menu-engineering/actions', { params: { month } }),
})
if (isLoading) {
return <LoadingSpinner text="加载菜单工程数据..." />
}
const summary = (data as any)?.data?.summary || {}
const actions = (data as any)?.data?.actions || []
const actionCounts = [
{ name: '保留并推广', value: actions.filter((r: any) => r.action === '保留并推广').length },
{ name: '优化提升', value: actions.filter((r: any) => r.action === '优化提升').length },
{ name: '降本或提价', value: actions.filter((r: any) => r.action === '降本或提价').length },
{ name: '考虑淘汰', value: actions.filter((r: any) => r.action === '考虑淘汰').length },
]
const abcPieData = [
{ name: 'A类(核心)', value: summary.class_a || 0 },
{ name: 'B类(成长)', value: summary.class_b || 0 },
{ name: 'C类(长尾)', value: summary.class_c || 0 },
]
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">ABC分类 · · ///</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="SKU概览" subtitle="ABC分类分布与行动汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总SKU数" value={Number(summary.total_skus || 0)} format="number" description="当月有销售的菜品总数" />
<MetricCard title="A类(高收入)" value={Number(summary.class_a || 0)} format="number" description="收入贡献TOP20%" status="good" />
<MetricCard title="C类(低收入)" value={Number(summary.class_c || 0)} format="number" description="收入贡献较低的菜品" status="bad" />
<MetricCard title="建议淘汰" value={Number(summary.recommend_eliminate || 0)} format="number" description="月收入<¥1000的C类SKU" status="bad" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="ABC分类分布" subtitle="A/B/C类SKU数量占比">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={abcPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
{abcPieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '数量']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="行动建议分布" subtitle="各类行动建议的SKU数量">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={actionCounts} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatNumber(v), 'SKU数']} />
<Legend />
<Bar dataKey="value" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 行动清单 */}
<CollapsibleSection title="行动清单明细" subtitle="按ABC分类和收入排序">
<FilterableTable
data={actions}
filterKey="dish_name"
filterLabel="全部菜品"
sortOptions={[
{ key: 'revenue', label: '收入' },
{ key: 'order_count', label: '订单数' },
{ key: 'avg_price', label: '均价' },
{ key: 'abc_class', label: 'ABC分类' },
]}
defaultSort="revenue"
defaultOrder="desc"
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'abc_class', label: 'ABC', render: (r) => {
const color = r.abc_class?.startsWith('A') ? 'text-green-600 font-medium' : r.abc_class?.startsWith('B') ? 'text-blue-600' : 'text-yellow-600'
return <span className={color}>{r.abc_class}</span>
}},
{ key: 'category_l1', label: '一级分类' },
{ key: 'category_l2', label: '二级分类' },
{ key: 'revenue', label: '月收入', align: 'right', render: (r) => formatCurrency(r.revenue) },
{ key: 'order_count', label: '订单数', align: 'right', render: (r) => formatNumber(r.order_count) },
{ key: 'avg_price', label: '均价', align: 'right', render: (r) => formatCurrency(r.avg_price) },
{ key: 'action', label: '建议行动', render: (r) => {
const color = r.action === '保留并推广' ? 'text-green-600' : r.action === '考虑淘汰' ? 'text-red-600' : r.action === '优化提升' ? 'text-blue-600' : 'text-yellow-600'
return <span className={color}>{r.action}</span>
}},
{ key: 'suggestion', label: '详细建议' },
]}
/>
</CollapsibleSection>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { KPISection } from '@/components/KPISection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
export function RegionComparisonPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/region/comparison', month],
queryFn: () => api.get('/analytics-enhanced/region/comparison', { params: { month } }),
})
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ['analytics-enhanced/region/kpi-forecast', month],
queryFn: () => api.get('/analytics-enhanced/region/kpi-forecast', { params: { month } }),
})
if (isLoading || forecastLoading) {
return <LoadingSpinner text="加载区域对比数据..." />
}
const rows = (data as any)?.data || []
const forecastRows = (forecastData as any)?.data || []
const totalRevenue = rows.reduce((s: number, r: any) => s + Number(r.total_revenue || 0), 0)
const totalStores = rows.reduce((s: number, r: any) => s + Number(r.store_count || 0), 0)
const avgMargin = rows.length > 0 ? rows.reduce((s: number, r: any) => s + Number(r.avg_margin_pct || 0), 0) / rows.length : 0
const chartData = rows.map((r: any) => ({
name: r.region,
总收入: Number(r.total_revenue || 0),
店均收入: Number(r.revenue_per_store || 0),
客单价: Number(r.avg_bill_value || 0),
}))
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"> · · KPI达成预测 · {month}</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="区域总览" subtitle="全区域汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="区域总数" value={rows.length} format="number" description="有数据的区域数量" />
<MetricCard title="区域总收入" value={totalRevenue} format="currency" description="所有区域当月总收入" />
<MetricCard title="门店总数" value={totalStores} format="number" description="所有区域门店数" />
<MetricCard title="平均毛利率" value={avgMargin} format="percent" description="各区域平均理论毛利率" />
</div>
</CollapsibleSection>
{/* KPI达成率 */}
<KPISection month={month} level="region" />
{/* 区域收入对比图 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="区域收入对比" subtitle="各区域总收入与店均收入">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => formatCurrency(v)} />
<Legend />
<Bar yAxisId="left" dataKey="总收入" fill="#3b82f6" />
<Bar yAxisId="right" dataKey="店均收入" fill="#22c55e" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 区域对比明细 */}
<CollapsibleSection title="区域对比明细" subtitle="按总收入降序">
<FilterableTable
data={rows}
filterKey="region"
filterLabel="全部区域"
sortOptions={[
{ key: 'total_revenue', label: '总收入' },
{ key: 'revenue_per_store', label: '店均收入' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'avg_margin_pct', label: '毛利率' },
{ key: 'member_penetration_pct', label: '会员渗透率' },
{ key: 'avg_expense_rate', label: '费用率' },
]}
defaultSort="total_revenue"
defaultOrder="desc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'right', render: (r) => formatNumber(r.store_count) },
{ key: 'total_revenue', label: '总收入', align: 'right', render: (r) => formatCurrency(r.total_revenue) },
{ key: 'revenue_per_store', label: '店均收入', align: 'right', render: (r) => formatCurrency(r.revenue_per_store) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'avg_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.avg_margin_pct) },
{ key: 'member_count', label: '会员数', align: 'right', render: (r) => formatNumber(r.member_count) },
{ key: 'member_penetration_pct', label: '会员渗透率', align: 'right', render: (r) => formatPercent(r.member_penetration_pct) },
{ key: 'avg_expense_rate', label: '费用率', align: 'right', render: (r) => formatPercent(r.avg_expense_rate) },
{ key: 'avg_rent_rate', label: '租金率', align: 'right', render: (r) => formatPercent(r.avg_rent_rate) },
{ key: 'avg_wage_rate', label: '工资率', align: 'right', render: (r) => formatPercent(r.avg_wage_rate) },
]}
/>
</CollapsibleSection>
{/* KPI达成预测明细 */}
<CollapsibleSection title="KPI达成预测明细" subtitle="月度目标 vs 实际完成">
<FilterableTable
data={forecastRows}
filterKey="region"
filterLabel="全部区域"
sortOptions={[
{ key: 'actual_revenue', label: '已完成' },
{ key: 'target_revenue', label: '目标' },
{ key: 'achievement_pct', label: '达成率' },
]}
defaultSort="actual_revenue"
defaultOrder="desc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'target_revenue', label: '月度目标', align: 'right', render: (r) => formatCurrency(r.target_revenue) },
{ key: 'actual_revenue', label: '已完成收入', align: 'right', render: (r) => formatCurrency(r.actual_revenue) },
{ key: 'actual_bills', label: '已完成账单', align: 'right', render: (r) => formatNumber(r.actual_bills) },
{ key: 'avg_daily_revenue', label: '日均收入', align: 'right', render: (r) => formatCurrency(r.avg_daily_revenue) },
{ key: 'forecast_revenue', label: '外推预测', align: 'right', render: (r) => formatCurrency(r.forecast_revenue) },
{ key: 'achievement_pct', label: '收入达成率', align: 'right', render: (r) => {
const v = Number(r.achievement_pct || 0)
return <span className={v >= 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'bill_achievement_pct', label: '账单达成率', align: 'right', render: (r) => {
const v = Number(r.bill_achievement_pct || 0)
return <span className={v >= 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
]}
/>
</CollapsibleSection>
</div>
)
}
+4
View File
@@ -8,6 +8,7 @@ import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { SearchSelect } from '@/components/SearchSelect'
import { KPISection } from '@/components/KPISection'
import { useState } from 'react'
const SCORE_DIMENSIONS = [
@@ -262,6 +263,9 @@ export function StorePage() {
</div>
</div>
{/* KPI达成率 */}
<KPISection month={month} level="store" storeCode={storeCode} />
{/* 最新经营概览 + 本周指标进度 一行 */}
<div className="grid gap-4 lg:grid-cols-2">
{/* 经营概览 */}
+282
View File
@@ -0,0 +1,282 @@
-- ============================================================
-- ETL Batch 1: 填充维度主数据表
-- 日期: 2026-08-02
-- 说明: 从 bill_fact / salary_detail_records / dish_sales_details
-- 聚合数据填充 dim_member / dim_employee / dim_channel / dim_sku
-- 并创建 member_level / position 标准化映射表
-- 执行: psql -d bill_query -f db/etl_batch1_fill_dims.sql
-- ============================================================
-- ============================================================
-- 1. 填充 dim_member — 会员主数据
-- ============================================================
TRUNCATE TABLE analytics.dim_member;
INSERT INTO analytics.dim_member (
member_id, phone_hash, register_channel, register_store, register_date,
member_level, total_orders, total_revenue, last_order_date, status, tags,
created_at, updated_at
)
WITH member_raw AS (
SELECT
member_id,
-- 首笔消费
MIN(opened_at) AS first_order,
MAX(opened_at) AS last_order,
COUNT(DISTINCT bill_no) AS order_count,
SUM(received_total) AS total_revenue,
-- 最新等级(取最近一笔的member_level
(array_agg(member_level ORDER BY opened_at DESC))[1] AS latest_level,
-- 首笔消费门店
(array_agg(store_code ORDER BY opened_at ASC))[1] AS first_store,
-- 首笔消费渠道
(array_agg(
CASE
WHEN meituan_delivery_received > 0 THEN '美团外卖'
WHEN taobao_delivery_received > 0 THEN '淘宝外卖'
WHEN jd_delivery_received > 0 THEN '京东外卖'
WHEN meituan_received > 0 THEN '美团到店'
WHEN douyin_received > 0 THEN '抖音'
WHEN cash_received > 0 THEN '现金'
WHEN alipay_received > 0 THEN '支付宝'
WHEN wechat_received > 0 THEN '微信'
WHEN unionpay_received > 0 THEN '银联'
WHEN credit_received > 0 THEN '挂账'
ELSE '堂食'
END
ORDER BY opened_at ASC
))[1] AS first_channel
FROM analytics.bill_fact
WHERE member_id IS NOT NULL AND member_id != ''
GROUP BY member_id
),
max_date AS (
SELECT MAX(opened_at)::date AS d FROM analytics.bill_fact WHERE opened_at IS NOT NULL
)
SELECT
mr.member_id,
NULL AS phone_hash, -- 无手机号数据
mr.first_channel AS register_channel,
mr.first_store AS register_store,
(mr.first_order AT TIME ZONE 'Asia/Shanghai')::date AS register_date,
-- 标准化会员等级
CASE
WHEN mr.latest_level IN ('1') THEN '普通'
WHEN mr.latest_level IN ('2','3') THEN '银卡'
WHEN mr.latest_level IN ('4','5') THEN '金卡'
WHEN mr.latest_level IN ('6','7','LV6','LV7') THEN '钻石'
ELSE '普通'
END AS member_level,
mr.order_count::integer AS total_orders,
round(mr.total_revenue::numeric, 2) AS total_revenue,
(mr.last_order AT TIME ZONE 'Asia/Shanghai')::date AS last_order_date,
-- 会员状态(基于最新账单日期)
CASE
WHEN (md.d - (mr.last_order AT TIME ZONE 'Asia/Shanghai')::date) <= 30 THEN '活跃'
WHEN (md.d - (mr.last_order AT TIME ZONE 'Asia/Shanghai')::date) <= 90 THEN '沉睡'
ELSE '流失'
END AS status,
-- 标签
CASE
WHEN mr.order_count = 1 THEN ARRAY['新客']
WHEN mr.order_count >= 20 THEN ARRAY['高频']
WHEN mr.total_revenue >= 500 THEN ARRAY['高价值']
ELSE ARRAY[]::TEXT[]
END AS tags,
now(), now()
FROM member_raw mr
CROSS JOIN max_date md;
CREATE INDEX IF NOT EXISTS idx_dim_member_status ON analytics.dim_member(status);
CREATE INDEX IF NOT EXISTS idx_dim_member_level ON analytics.dim_member(member_level);
CREATE INDEX IF NOT EXISTS idx_dim_member_store ON analytics.dim_member(register_store);
-- ============================================================
-- 2. 填充 dim_employee — 员工主数据
-- ============================================================
TRUNCATE TABLE analytics.dim_employee;
INSERT INTO analytics.dim_employee (
employee_id, employee_name, position, store_code, hire_date, leave_date, status,
created_at, updated_at
)
SELECT DISTINCT ON (employee_code)
employee_code,
NULL AS employee_name, -- 薪资表无姓名字段
-- 标准化岗位
CASE
WHEN position LIKE '店长%' OR position = '储备店长' OR position LIKE '见习经理%' OR position = '储备经理' THEN '店长'
WHEN position LIKE '副店%' THEN '副店长'
WHEN position LIKE '前厅经理%' OR position = '大堂经理' OR position = '服务主管' THEN '前厅经理'
WHEN position = '区经理' OR position LIKE '营运经理%' OR position = '营运助理' OR position LIKE '营运总监%' THEN '区经理'
WHEN position = '厨师长' OR position LIKE '厨师长%' OR position = '行政总厨' OR position LIKE '区厨%' OR position LIKE '大区总厨%' OR position LIKE '拉面区厨%' OR position LIKE '拉面总厨%' OR position = '研发总厨' OR position = '研发经理' OR position LIKE '配送中心%总厨%' OR position = '烤鸭总厨' OR position = '西餐总厨' THEN '厨师长'
WHEN position LIKE '拉面师%' OR position = '拉面' OR position = '拉面主管%' OR position = '面工' OR position LIKE '面工%' OR position = '面点' OR position = '面点师' OR position = '打馕' THEN '拉面师'
WHEN position LIKE '厨师%' OR position = '炒锅' OR position = '砧板' OR position = '打荷' OR position = '上什' OR position = '蒸箱' OR position = '出品' OR position = '副厨' OR position = '明档师傅' OR position = '西餐' OR position = '锅底' THEN '厨师'
WHEN position LIKE '配菜师%' OR position = '配菜师' OR position = '切菜师' OR position LIKE '切菜师%' OR position = '切肉师' OR position = '砧板主管' THEN '配菜师'
WHEN position LIKE '凉菜%' OR position = '凉菜' THEN '凉菜师'
WHEN position LIKE '烧烤%' OR position = '烧烤师' OR position LIKE '烧烤工%' OR position = '烧烤师傅' OR position = '烤鸭师' THEN '烧烤师'
WHEN position LIKE '服务员%' OR position = '传菜员' OR position = '迎宾员' OR position = '吧员' THEN '服务员'
WHEN position = '收银员' OR position = '出纳主管' OR position LIKE '%出纳%' THEN '收银员'
WHEN position = '训练员' OR position LIKE '训练员%' OR position = '训练经理' THEN '训练员'
WHEN position LIKE '厨工%' OR position = '兼职工' OR position = '非全兼职工' OR position = '小时工' OR position = '计时工' THEN '厨工'
WHEN position = '保洁员' OR position = '保洁' THEN '保洁'
WHEN position = '洗碗' THEN '洗碗工'
WHEN position LIKE '%组员%' OR position LIKE '%组长%' THEN '中央厨房工'
WHEN position = '库房组员' OR position = '库房专员' OR position = '库房资深专员' THEN '库管'
WHEN position LIKE '副总%' OR position LIKE '执行总裁%' OR position = '首席营销官%' OR position LIKE '%总监%' OR position LIKE '%经理%' OR position LIKE '%主管%' OR position LIKE '%专员%' OR position LIKE '%助理%' OR position = '主管' THEN '管理岗'
ELSE '其他'
END AS position_std,
-- 门店编码:org_level3 中以人名命名的区域无法直接映射到门店,暂取NULL
NULL AS store_code,
-- hire_date: text转date
CASE
WHEN hire_date ~ '^\d{4}-\d{2}-\d{2}$' THEN hire_date::date
WHEN hire_date ~ '^\d{4}/\d{2}/\d{2}$' THEN to_date(hire_date, 'YYYY/MM/DD')
ELSE NULL
END AS hire_date,
-- leave_date: '0' 表示在职
CASE
WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN NULL
WHEN leave_date ~ '^\d{4}-\d{2}-\d{2}$' THEN leave_date::date
WHEN leave_date ~ '^\d{4}/\d{2}/\d{2}$' THEN to_date(leave_date, 'YYYY/MM/DD')
ELSE NULL
END AS leave_date,
-- 状态
CASE
WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN '在职'
ELSE '离职'
END AS status,
now(), now()
FROM public.salary_detail_records
ORDER BY employee_code, leave_date DESC NULLS LAST;
CREATE INDEX IF NOT EXISTS idx_dim_employee_status ON analytics.dim_employee(status);
CREATE INDEX IF NOT EXISTS idx_dim_employee_position ON analytics.dim_employee(position);
-- ============================================================
-- 3. 填充 dim_channel — 渠道主数据
-- ============================================================
TRUNCATE TABLE analytics.dim_channel;
INSERT INTO analytics.dim_channel (channel_code, channel_name, channel_group, channel_type, platform, commission_rate, is_delivery, sort_order, status)
VALUES
('dinein', '堂食', '堂食', '堂食', '自有', 0, false, 1, '启用'),
('meituan_dm', '美团到店', '堂食', '美团到店', '美团', 0, false, 2, '启用'),
('meituan_wm', '美团外卖', '外卖', '美团外卖', '美团', 15.0, true, 3, '启用'),
('taobao_wm', '淘宝外卖', '外卖', '淘宝外卖', '淘宝', 12.0, true, 4, '启用'),
('jd_wm', '京东外卖', '外卖', '京东到家', '京东', 10.0, true, 5, '启用'),
('douyin', '抖音', '支付', '抖音', '抖音', 0, false, 6, '启用'),
('alipay', '支付宝', '支付', '支付宝', '自有', 0.6, false, 7, '启用'),
('wechat', '微信', '支付', '微信', '自有', 0.6, false, 8, '启用'),
('cash', '现金', '支付', '现金', '自有', 0, false, 9, '启用'),
('unionpay', '银联', '支付', '银联', '自有', 0.5, false, 10, '启用'),
('credit', '挂账', '支付', '挂账', '自有', 0, false, 11, '启用');
-- ============================================================
-- 4. 填充 dim_sku — SKU主数据(从 dish_sales_details 聚合)
-- ============================================================
TRUNCATE TABLE analytics.dim_sku;
INSERT INTO analytics.dim_sku (
sku_code, standard_name, pos_code, category_l1, category_l2,
status, abc_class, unit, created_at, updated_at
)
SELECT
-- SKU编码:DISH- + 序号(用dense_rank生成)
'DISH-' || lpad(dense_rank() OVER (ORDER BY dish_name)::text, 5, '0'),
dish_name,
NULL AS pos_code,
min(category_level1) AS category_l1,
min(category_level2) AS category_l2,
'在售' AS status,
-- 取最新月度ABC分类
(SELECT abc.abc_class FROM analytics.mv_dish_sku_abc_monthly abc
WHERE abc.dish_name = d.dish_name
ORDER BY abc.month_start DESC LIMIT 1) AS abc_class,
min(unit) AS unit,
now(), now()
FROM public.dish_sales_details d
WHERE d.dish_name IS NOT NULL AND d.dish_name != ''
GROUP BY d.dish_name;
-- ============================================================
-- 5. 创建 member_level_mapping 视图 — 会员等级标准化映射
-- ============================================================
CREATE OR REPLACE VIEW analytics.v_member_level_mapping AS
SELECT
member_level AS raw_level,
CASE
WHEN member_level IN ('1') THEN '普通'
WHEN member_level IN ('2','3') THEN '银卡'
WHEN member_level IN ('4','5') THEN '金卡'
WHEN member_level IN ('6','7','LV6','LV7') THEN '钻石'
ELSE '普通'
END AS standard_level,
CASE
WHEN member_level IN ('1') THEN 1
WHEN member_level IN ('2','3') THEN 2
WHEN member_level IN ('4','5') THEN 3
WHEN member_level IN ('6','7','LV6','LV7') THEN 4
ELSE 1
END AS level_sort
FROM (SELECT DISTINCT member_level FROM analytics.bill_fact WHERE member_level IS NOT NULL AND member_level != '') t;
-- ============================================================
-- 6. 创建 v_position_mapping 视图 — 岗位标准化映射
-- ============================================================
CREATE OR REPLACE VIEW analytics.v_position_mapping AS
SELECT DISTINCT
position AS raw_position,
CASE
WHEN position LIKE '店长%' OR position = '储备店长' OR position LIKE '见习经理%' OR position = '储备经理' THEN '店长'
WHEN position LIKE '副店%' THEN '副店长'
WHEN position LIKE '前厅经理%' OR position = '大堂经理' OR position = '服务主管' THEN '前厅经理'
WHEN position = '区经理' OR position LIKE '营运经理%' OR position = '营运助理' OR position LIKE '营运总监%' THEN '区经理'
WHEN position = '厨师长' OR position LIKE '厨师长%' OR position = '行政总厨' OR position LIKE '区厨%' OR position LIKE '大区总厨%' OR position LIKE '拉面区厨%' OR position LIKE '拉面总厨%' OR position = '研发总厨' OR position = '研发经理' OR position LIKE '配送中心%总厨%' OR position = '烤鸭总厨' OR position = '西餐总厨' THEN '厨师长'
WHEN position LIKE '拉面师%' OR position = '拉面' OR position LIKE '拉面主管%' OR position = '面工' OR position LIKE '面工%' OR position = '面点' OR position = '面点师' OR position = '打馕' THEN '拉面师'
WHEN position LIKE '厨师%' OR position = '炒锅' OR position = '砧板' OR position = '打荷' OR position = '上什' OR position = '蒸箱' OR position = '出品' OR position = '副厨' OR position = '明档师傅' OR position = '西餐' OR position = '锅底' THEN '厨师'
WHEN position LIKE '配菜师%' OR position = '配菜师' OR position = '切菜师' OR position LIKE '切菜师%' OR position = '切肉师' OR position = '砧板主管' THEN '配菜师'
WHEN position LIKE '凉菜%' OR position = '凉菜' THEN '凉菜师'
WHEN position LIKE '烧烤%' OR position = '烧烤师' OR position LIKE '烧烤工%' OR position = '烧烤师傅' OR position = '烤鸭师' THEN '烧烤师'
WHEN position LIKE '服务员%' OR position = '传菜员' OR position = '迎宾员' OR position = '吧员' THEN '服务员'
WHEN position = '收银员' OR position = '出纳主管' OR position LIKE '%出纳%' THEN '收银员'
WHEN position = '训练员' OR position LIKE '训练员%' OR position = '训练经理' THEN '训练员'
WHEN position LIKE '厨工%' OR position = '兼职工' OR position = '非全兼职工' OR position = '小时工' OR position = '计时工' THEN '厨工'
WHEN position = '保洁员' OR position = '保洁' THEN '保洁'
WHEN position = '洗碗' THEN '洗碗工'
WHEN position LIKE '%组员%' OR position LIKE '%组长%' THEN '中央厨房工'
WHEN position = '库房组员' OR position = '库房专员' OR position = '库房资深专员' THEN '库管'
WHEN position LIKE '副总%' OR position LIKE '执行总裁%' OR position = '首席营销官%' OR position LIKE '%总监%' OR position LIKE '%经理%' OR position LIKE '%主管%' OR position LIKE '%专员%' OR position LIKE '%助理%' OR position = '主管' THEN '管理岗'
ELSE '其他'
END AS standard_position
FROM public.salary_detail_records
WHERE position IS NOT NULL AND position != '';
-- ============================================================
-- 7. 补录 dim_store 缺失的 region
-- ============================================================
UPDATE analytics.dim_store SET region = '未知区域' WHERE region IS NULL OR region = '';
-- ============================================================
-- 验证结果
-- ============================================================
SELECT 'dim_member' AS table_name, count(*) AS row_count FROM analytics.dim_member
UNION ALL SELECT 'dim_employee', count(*) FROM analytics.dim_employee
UNION ALL SELECT 'dim_channel', count(*) FROM analytics.dim_channel
UNION ALL SELECT 'dim_sku', count(*) FROM analytics.dim_sku
UNION ALL SELECT 'v_member_level_mapping', count(*) FROM analytics.v_member_level_mapping
UNION ALL SELECT 'v_position_mapping', count(*) FROM analytics.v_position_mapping;
-- dim_member 状态分布
SELECT 'dim_member_status' AS check_name, status, count(*) AS cnt
FROM analytics.dim_member GROUP BY status
UNION ALL
SELECT 'dim_member_level', member_level, count(*)
FROM analytics.dim_member GROUP BY member_level
UNION ALL
SELECT 'dim_employee_status', status, count(*)
FROM analytics.dim_employee GROUP BY status
UNION ALL
SELECT 'dim_employee_position', position, count(*)
FROM analytics.dim_employee GROUP BY position
ORDER BY 1, 3 DESC;
+33
View File
@@ -0,0 +1,33 @@
TRUNCATE TABLE analytics.dim_employee;
INSERT INTO analytics.dim_employee (employee_id, employee_name, position, store_code, hire_date, leave_date, status, created_at, updated_at)
SELECT DISTINCT ON (employee_code)
employee_code, NULL,
CASE
WHEN position LIKE '店长%' OR position = '储备店长' OR position LIKE '见习经理%' OR position = '储备经理' THEN '店长'
WHEN position LIKE '副店%' THEN '副店长'
WHEN position LIKE '前厅经理%' OR position = '大堂经理' OR position = '服务主管' THEN '前厅经理'
WHEN position = '区经理' OR position LIKE '营运经理%' OR position = '营运助理' OR position LIKE '营运总监%' THEN '区经理'
WHEN position = '厨师长' OR position LIKE '厨师长%' OR position = '行政总厨' OR position LIKE '区厨%' OR position LIKE '大区总厨%' OR position LIKE '拉面区厨%' OR position LIKE '拉面总厨%' OR position = '研发总厨' OR position = '研发经理' OR position LIKE '配送中心%总厨%' OR position = '烤鸭总厨' OR position = '西餐总厨' THEN '厨师长'
WHEN position LIKE '拉面师%' OR position = '拉面' OR position LIKE '拉面主管%' OR position = '面工' OR position LIKE '面工%' OR position = '面点' OR position = '面点师' OR position = '打馕' THEN '拉面师'
WHEN position LIKE '厨师%' OR position = '炒锅' OR position = '砧板' OR position = '打荷' OR position = '上什' OR position = '蒸箱' OR position = '出品' OR position = '副厨' OR position = '明档师傅' OR position = '西餐' OR position = '锅底' THEN '厨师'
WHEN position LIKE '配菜师%' OR position = '配菜师' OR position = '切菜师' OR position LIKE '切菜师%' OR position = '切肉师' OR position = '砧板主管' THEN '配菜师'
WHEN position LIKE '凉菜%' OR position = '凉菜' THEN '凉菜师'
WHEN position LIKE '烧烤%' OR position = '烧烤师' OR position LIKE '烧烤工%' OR position = '烧烤师傅' OR position = '烤鸭师' THEN '烧烤师'
WHEN position LIKE '服务员%' OR position = '传菜员' OR position = '迎宾员' OR position = '吧员' THEN '服务员'
WHEN position = '收银员' OR position = '出纳主管' OR position LIKE '%出纳%' THEN '收银员'
WHEN position = '训练员' OR position LIKE '训练员%' OR position = '训练经理' THEN '训练员'
WHEN position LIKE '厨工%' OR position = '兼职工' OR position = '非全兼职工' OR position = '小时工' OR position = '计时工' THEN '厨工'
WHEN position = '保洁员' OR position = '保洁' THEN '保洁'
WHEN position = '洗碗' THEN '洗碗工'
WHEN position LIKE '%组员%' OR position LIKE '%组长%' THEN '中央厨房工'
WHEN position = '库房组员' OR position = '库房专员' OR position = '库房资深专员' THEN '库管'
WHEN position LIKE '副总%' OR position LIKE '执行总裁%' OR position LIKE '%总监%' OR position LIKE '%经理%' OR position LIKE '%主管%' OR position LIKE '%专员%' OR position LIKE '%助理%' OR position = '主管' THEN '管理岗'
ELSE '其他'
END,
NULL,
CASE WHEN hire_date ~ '^\d{4}-\d{2}-\d{2}$' THEN hire_date::date WHEN hire_date ~ '^\d{4}/\d{2}/\d{2}$' THEN to_date(hire_date, 'YYYY/MM/DD') ELSE NULL END,
CASE WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN NULL WHEN leave_date ~ '^\d{4}-\d{2}-\d{2}$' THEN leave_date::date WHEN leave_date ~ '^\d{4}/\d{2}/\d{2}$' THEN to_date(leave_date, 'YYYY/MM/DD') ELSE NULL END,
CASE WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN '在职' ELSE '离职' END,
now(), now()
FROM public.salary_detail_records
ORDER BY employee_code, leave_date DESC NULLS LAST;
+67
View File
@@ -0,0 +1,67 @@
-- ============================================================
-- 门店月度目标表 ETL
-- 基于历史实际收入自动生成基准目标
-- 策略:用最近完整月份的实际收入作为下月目标基准,按区域增长率微调
-- ============================================================
-- 1. 创建目标表(如果不存在)
CREATE TABLE IF NOT EXISTS analytics.dim_store_target (
id SERIAL PRIMARY KEY,
store_code VARCHAR(20) NOT NULL,
target_month DATE NOT NULL,
revenue_target NUMERIC(12,2) NOT NULL,
bill_count_target INTEGER,
avg_bill_value_target NUMERIC(8,2),
member_penetration_target NUMERIC(5,2), -- 百分比
cost_rate_target NUMERIC(5,2), -- 百分比
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(store_code, target_month)
);
-- 2. 生成2026-05目标(基于4月实际 × 增长系数1.03)
INSERT INTO analytics.dim_store_target (store_code, target_month, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target)
SELECT
r.store_code,
'2026-05-01'::date,
round(r.received * 1.03, 2) as revenue_target,
round(r.bill_count * 1.03) as bill_count_target,
round(r.avg_bill_value, 2) as avg_bill_value_target,
round(r.member_bill_share_pct, 2) as member_penetration_target,
30.00 as cost_rate_target
FROM analytics.mv_store_risk_rating_monthly r
WHERE r.month_start = '2026-04-01'
ON CONFLICT (store_code, target_month) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
bill_count_target = EXCLUDED.bill_count_target,
avg_bill_value_target = EXCLUDED.avg_bill_value_target,
member_penetration_target = EXCLUDED.member_penetration_target,
cost_rate_target = EXCLUDED.cost_rate_target,
updated_at = NOW();
-- 3. 生成2026-04目标(用4月实际作为回顾目标,方便展示达成率)
INSERT INTO analytics.dim_store_target (store_code, target_month, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target)
SELECT
r.store_code,
'2026-04-01'::date,
round(r.received, 2) as revenue_target,
r.bill_count as bill_count_target,
round(r.avg_bill_value, 2) as avg_bill_value_target,
round(r.member_bill_share_pct, 2) as member_penetration_target,
30.00 as cost_rate_target
FROM analytics.mv_store_risk_rating_monthly r
WHERE r.month_start = '2026-04-01'
ON CONFLICT (store_code, target_month) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
bill_count_target = EXCLUDED.bill_count_target,
avg_bill_value_target = EXCLUDED.avg_bill_value_target,
member_penetration_target = EXCLUDED.member_penetration_target,
cost_rate_target = EXCLUDED.cost_rate_target,
updated_at = NOW();
-- 4. 验证
SELECT '2026-04' as month, count(*) as stores, round(sum(revenue_target)::numeric, 2) as total_target
FROM analytics.dim_store_target WHERE target_month = '2026-04-01'
UNION ALL
SELECT '2026-05', count(*), round(sum(revenue_target)::numeric, 2)
FROM analytics.dim_store_target WHERE target_month = '2026-05-01';
+15 -2
View File
@@ -21,10 +21,23 @@ cd server && npm run dev
### 2. 启动反向隧道(线上前端访问本地后端)
线上 Nginx 将 `/api/` 代理到远程 `127.0.0.1:13333`,通过反向 SSH 隧道转发到本地后端
线上 Nginx 将 `/api/` 代理到远程 `127.0.0.1:13333`,通过 frp tcp 代理转发到本地后端 3333 端口
frpc 配置(`/Users/freedak/frp/frpc.toml`)中已包含 dm-api 代理:
```toml
[[proxies]]
name = "dm-api"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3333
remotePort = 13333
```
重启 frpc 即可:
```bash
sshpass -p 'Why_701208' ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -N -R 13333:localhost:3333 ubuntu@dm.all8ai.top
kill -9 $(pgrep frpc); cd /Users/freedak/frp && ./frpc -c frpc.toml &
```
### 3. 访问
+2
View File
@@ -10,6 +10,7 @@ import costAnalysisRoutes from './routes/cost-analysis.js'
import storeExpenseRoutes from './routes/store-expense.js'
import smartSchedulingRoutes from './routes/smart-scheduling.js'
import situationalAwarenessRoutes from './routes/situational-awareness.js'
import analyticsEnhancedRoutes from './routes/analytics-enhanced.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -39,6 +40,7 @@ app.use('/api/cost-analysis', costAnalysisRoutes)
app.use('/api/store-expense', storeExpenseRoutes)
app.use('/api/smart-scheduling', smartSchedulingRoutes)
app.use('/api/situational-awareness', situationalAwarenessRoutes)
app.use('/api/analytics-enhanced', analyticsEnhancedRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
+843
View File
@@ -0,0 +1,843 @@
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()
// ============================================================
// 1. 会员LTV与分层
// ============================================================
router.get('/member/ltv', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const { page, pageSize, offset } = parsePagination(req)
const status = (req.query.status as string) || ''
const level = (req.query.level as string) || ''
let whereClause = 'WHERE 1=1'
const params: any[] = []
if (status) {
params.push(status)
whereClause += ` AND dm.status = $${params.length}`
}
if (level) {
params.push(level)
whereClause += ` AND dm.member_level = $${params.length}`
}
const countResult = await query(`SELECT count(*) as total FROM analytics.dim_member dm ${whereClause}`, params)
const total = Number(countResult.rows[0].total)
params.push(pageSize, offset)
const result = await query(`
SELECT dm.member_id, dm.register_channel, dm.register_store, ds.store_name as register_store_name,
dm.register_date, dm.member_level, dm.total_orders, round(dm.total_revenue::numeric, 2) as total_revenue,
dm.last_order_date, dm.status, dm.tags
FROM analytics.dim_member dm
LEFT JOIN analytics.dim_store ds ON dm.register_store = ds.store_code
${whereClause}
ORDER BY dm.total_revenue DESC NULLS LAST
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params)
// 汇总统计
const summaryResult = await query(`
SELECT
count(*) as total_members,
count(*) FILTER (WHERE status = '活跃') as active_count,
count(*) FILTER (WHERE status = '沉睡') as dormant_count,
count(*) FILTER (WHERE status = '流失') as churned_count,
round(avg(total_revenue)::numeric, 2) as avg_ltv,
round(sum(total_revenue)::numeric, 2) as total_revenue,
round(avg(total_orders)::numeric, 1) as avg_orders
FROM analytics.dim_member
`)
// 等级分布
const levelDist = await query(`
SELECT member_level, count(*) as count,
round(avg(total_revenue)::numeric, 2) as avg_revenue,
round(avg(total_orders)::numeric, 1) as avg_orders
FROM analytics.dim_member GROUP BY member_level ORDER BY
CASE member_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END
`)
sendSuccess(res, {
summary: summaryResult.rows[0],
level_distribution: levelDist.rows,
members: result.rows,
}, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 2. 菜单工程行动清单
// ============================================================
router.get('/menu-engineering/actions', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
dish_name,
abc_class,
received_amount as revenue,
bill_count as order_count,
realized_unit_price as avg_price,
category_level1 as category_l1,
category_level2 as category_l2,
CASE
WHEN abc_class LIKE 'A%' AND bill_count > 0 THEN '保留并推广'
WHEN abc_class LIKE 'A%' AND bill_count <= 0 THEN '调查停售原因'
WHEN abc_class LIKE 'B%' THEN '优化提升'
WHEN abc_class LIKE 'C%' AND received_amount < 1000 THEN '考虑淘汰'
WHEN abc_class LIKE 'C%' THEN '降本或提价'
ELSE '观察'
END as action,
CASE
WHEN abc_class LIKE 'A%' AND realized_unit_price > 0 THEN '高人气高收入,维持品质,可考虑提价测试'
WHEN abc_class LIKE 'B%' AND bill_count > 50 THEN '有潜力,优化呈现和推荐话术'
WHEN abc_class LIKE 'C%' AND received_amount < 500 THEN '长尾SKU,建议下架或季节性供应'
WHEN abc_class LIKE 'C%' THEN '收入偏低,尝试降本或搭配套餐'
ELSE '持续观察'
END as suggestion
FROM analytics.mv_dish_sku_abc_monthly
WHERE month_start = $1
ORDER BY
CASE WHEN abc_class LIKE 'A%' THEN 1 WHEN abc_class LIKE 'B%' THEN 2 WHEN abc_class LIKE 'C%' THEN 3 ELSE 4 END,
received_amount DESC NULLS LAST
`, [month])
// 汇总
const summary = {
total_skus: result.rows.length,
class_a: result.rows.filter((r: any) => r.abc_class?.startsWith('A')).length,
class_b: result.rows.filter((r: any) => r.abc_class?.startsWith('B')).length,
class_c: result.rows.filter((r: any) => r.abc_class?.startsWith('C')).length,
recommend_eliminate: result.rows.filter((r: any) => r.action === '考虑淘汰').length,
recommend_promote: result.rows.filter((r: any) => r.action === '保留并推广').length,
}
sendSuccess(res, { summary, actions: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 3. 菜品搭配分析(套餐推荐)
// ============================================================
router.get('/dish-pair/recommendations', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const { page, pageSize, offset } = parsePagination(req)
const minPairCount = parseInt((req.query.min_count as string) || '10')
const countResult = await query(`
SELECT count(*) as total FROM analytics.mv_dish_pair_summary_monthly
WHERE month_start = $1 AND pair_count >= $2
`, [month, minPairCount])
const total = Number(countResult.rows[0].total)
const result = await query(`
SELECT
dish_a, dish_b, pair_count,
dish_a_revenue, dish_b_revenue,
combined_revenue,
round(pair_count::numeric / NULLIF((SELECT max(pair_count) FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1), 0) * 100, 1) as affinity_pct
FROM analytics.mv_dish_pair_summary_monthly
WHERE month_start = $1 AND pair_count >= $2
ORDER BY pair_count DESC
LIMIT $3 OFFSET $4
`, [month, minPairCount, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 4. 区域间对比排名
// ============================================================
router.get('/region/comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH store_metrics AS (
SELECT
ds.region,
count(DISTINCT r.store_code) as store_count,
sum(r.received) as total_revenue,
round(avg(r.avg_bill_value)::numeric, 2) as avg_bill_value,
sum(r.bill_count) as bill_count,
round(avg(r.theoretical_margin_pct)::numeric, 2) as avg_margin,
round(sum(r.bill_count * r.member_bill_share_pct / 100)::numeric, 0) as member_count
FROM analytics.mv_store_risk_rating_monthly r
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
expense_metrics AS (
SELECT
ds.region,
avg(oe.operating_expense_rate_pct) as avg_expense_rate,
avg(oe.rent_rate_pct) as avg_rent_rate,
avg(oe.wage_rate_pct) as avg_wage_rate
FROM analytics.mv_store_operating_expense_monthly oe
JOIN analytics.dim_store ds ON oe.sales_store_code = ds.store_code
WHERE oe.report_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
)
SELECT
sm.region,
sm.store_count,
round(sm.total_revenue::numeric, 2) as total_revenue,
round((sm.total_revenue / sm.store_count)::numeric, 2) as revenue_per_store,
round(sm.avg_bill_value::numeric, 2) as avg_bill_value,
sm.bill_count,
round(sm.avg_margin::numeric, 2) as avg_margin_pct,
sm.member_count,
round((sm.member_count::numeric / NULLIF(sm.bill_count, 0) * 100)::numeric, 1) as member_penetration_pct,
round(em.avg_expense_rate::numeric, 2) as avg_expense_rate,
round(em.avg_rent_rate::numeric, 2) as avg_rent_rate,
round(em.avg_wage_rate::numeric, 2) as avg_wage_rate
FROM store_metrics sm
LEFT JOIN expense_metrics em ON sm.region = em.region
ORDER BY sm.total_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 5. 区域KPI达成预测
// ============================================================
router.get('/region/kpi-forecast', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH region_actual AS (
SELECT
ds.region,
round(sum(r.received)::numeric, 2) as actual_revenue,
sum(r.bill_count) as actual_bills,
max(r.active_days) as elapsed_days,
round(sum(r.received) / NULLIF(max(r.active_days), 0)::numeric, 2) as avg_daily_revenue
FROM analytics.mv_store_risk_rating_monthly r
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
region_target AS (
SELECT
ds.region,
round(sum(t.revenue_target)::numeric, 2) as target_revenue,
round(sum(t.bill_count_target)::numeric, 0) as target_bills
FROM analytics.dim_store_target t
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
WHERE t.target_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
calendar AS (
SELECT count(*) as total_days
FROM analytics.dim_calendar
WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month'
AND is_operating_day = true
)
SELECT
ra.region,
ra.actual_revenue,
rt.target_revenue,
ra.actual_bills,
rt.target_bills,
ra.elapsed_days,
c.total_days as working_days,
ra.avg_daily_revenue,
round((ra.avg_daily_revenue * c.total_days)::numeric, 2) as forecast_revenue,
round((ra.actual_revenue / NULLIF(rt.target_revenue, 0) * 100)::numeric, 1) as achievement_pct,
round((ra.actual_bills / NULLIF(rt.target_bills, 0) * 100)::numeric, 1) as bill_achievement_pct
FROM region_actual ra
LEFT JOIN region_target rt ON ra.region = rt.region
CROSS JOIN calendar c
ORDER BY ra.actual_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 6. 每日销售目标分解
// ============================================================
router.get('/store/daily-target', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
if (!storeCode) {
return sendError(res, 'store_code is required')
}
// 该门店历史日均收入(按餐段)
const mealPeriodStats = await query(`
SELECT
meal_period,
round(avg(daily_revenue)::numeric, 2) as avg_revenue,
round(avg(bill_count)::numeric, 0) as avg_bills,
round(avg(avg_bill_value)::numeric, 2) as avg_bill_value
FROM (
SELECT
meal_period,
date_trunc('day', opened_at)::date as day,
sum(received_total) as daily_revenue,
count(*) as bill_count,
round(avg(received_total)::numeric, 2) as avg_bill_value
FROM analytics.bill_fact
WHERE store_code = $1
AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY meal_period, date_trunc('day', opened_at)::date
) t
GROUP BY meal_period
ORDER BY
CASE meal_period WHEN '早市' THEN 1 WHEN '午市' THEN 2 WHEN '下午茶' THEN 3 WHEN '晚市' THEN 4 WHEN '夜宵' THEN 5 ELSE 6 END
`, [storeCode, month])
// 该门店历史日均收入(按星期)
const weekdayStats = await query(`
SELECT
extract(dow from opened_at)::int as weekday,
round(avg(daily_revenue)::numeric, 2) as avg_revenue,
round(avg(bill_count)::numeric, 0) as avg_bills
FROM (
SELECT
date_trunc('day', opened_at)::date as day,
extract(dow from opened_at)::int as weekday,
sum(received_total) as daily_revenue,
count(*) as bill_count
FROM analytics.bill_fact
WHERE store_code = $1
AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY date_trunc('day', opened_at)::date, extract(dow from opened_at)::int
) t
GROUP by weekday
ORDER BY weekday
`, [storeCode, month])
// 月度总目标(基于历史日均 * 当月天数)
const totalAvg = await query(`
SELECT
round(avg(daily_revenue)::numeric, 2) as avg_daily_revenue,
count(DISTINCT date_trunc('day', opened_at)::date) as active_days,
round(sum(daily_revenue)::numeric, 2) as total_revenue
FROM (
SELECT date_trunc('day', opened_at)::date as day, sum(received_total) as daily_revenue
FROM analytics.bill_fact
WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY date_trunc('day', opened_at)::date
) t
`, [storeCode, month])
const workingDays = await query(`
SELECT count(*) as total_days FROM analytics.dim_calendar
WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month' AND is_operating_day = true
`, [month])
const avgDaily = Number(totalAvg.rows[0]?.avg_daily_revenue || 0)
const forecastTotal = avgDaily * Number(workingDays.rows[0]?.total_days || 30)
sendSuccess(res, {
monthly_target: Math.round(forecastTotal),
avg_daily_revenue: avgDaily,
meal_period_breakdown: mealPeriodStats.rows,
weekday_breakdown: weekdayStats.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 7. 门店会员活跃度面板
// ============================================================
router.get('/store/member-activity', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
if (!storeCode) {
return sendError(res, 'store_code is required')
}
// 该门店会员活跃度
const memberStats = await query(`
WITH store_members AS (
SELECT
bf.member_id,
count(DISTINCT bf.bill_no) as orders,
sum(bf.received_total) as revenue,
max(bf.opened_at) as last_visit,
min(bf.opened_at) as first_visit
FROM analytics.bill_fact bf
WHERE bf.store_code = $1
AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month'
AND bf.member_id IS NOT NULL AND bf.member_id != ''
GROUP BY bf.member_id
)
SELECT
count(*) as total_members,
count(*) FILTER (WHERE orders >= 3) as frequent_members,
count(*) FILTER (WHERE orders = 1) as one_time_members,
round(avg(orders)::numeric, 1) as avg_orders,
round(avg(revenue)::numeric, 2) as avg_revenue,
round(sum(revenue)::numeric, 2) as total_revenue
FROM store_members
`, [storeCode, month])
// 会员等级分布
const levelDist = await query(`
SELECT
CASE
WHEN bf.member_level IN ('1') THEN '普通'
WHEN bf.member_level IN ('2','3') THEN '银卡'
WHEN bf.member_level IN ('4','5') THEN '金卡'
WHEN bf.member_level IN ('6','7','LV6','LV7') THEN '钻石'
ELSE '普通'
END as standard_level,
count(DISTINCT bf.member_id) as count,
round(sum(bf.received_total)::numeric, 2) as revenue
FROM analytics.bill_fact bf
WHERE bf.store_code = $1
AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month'
AND bf.member_id IS NOT NULL AND bf.member_id != ''
GROUP BY 1
ORDER BY
CASE standard_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END
`, [storeCode, month])
// 会员 vs 非会员
const comparison = await query(`
SELECT
CASE WHEN member_id IS NOT NULL AND member_id != '' THEN '会员' ELSE '非会员' END as customer_type,
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as revenue,
round(avg(received_total)::numeric, 2) as avg_bill_value
FROM analytics.bill_fact
WHERE store_code = $1
AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY 1
`, [storeCode, month])
sendSuccess(res, {
stats: memberStats.rows[0],
level_distribution: levelDist.rows,
comparison: comparison.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 8. 员工绩效分析
// ============================================================
router.get('/employee/performance', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const position = (req.query.position as string) || ''
const status = (req.query.status as string) || ''
let whereClause = 'WHERE 1=1'
const params: any[] = []
if (position) {
params.push(position)
whereClause += ` AND de.position = $${params.length}`
}
if (status) {
params.push(status)
whereClause += ` AND de.status = $${params.length}`
}
const countResult = await query(`SELECT count(*) as total FROM analytics.dim_employee ${whereClause}`, params)
const total = Number(countResult.rows[0].total)
params.push(pageSize, offset)
const result = await query(`
SELECT
de.employee_id,
de.position,
de.hire_date,
de.leave_date,
de.status,
s.salary_period,
round(s.gross_pay::numeric, 2) as gross_pay,
round(s.net_pay::numeric, 2) as net_pay,
round(s.perf_score::numeric, 2) as perf_score,
round(s.actual_attend::numeric, 0) as actual_attend,
round(s.expected_attend::numeric, 0) as expected_attend,
round(s.overtime_pay::numeric, 2) as overtime_pay,
round(s.bonus::numeric, 2) as bonus,
s.org_level2,
s.org_level3
FROM analytics.dim_employee de
LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code
${whereClause}
ORDER BY s.gross_pay DESC NULLS LAST
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params)
// 岗位汇总
const positionSummary = await query(`
SELECT
de.position,
count(*) as headcount,
count(*) FILTER (WHERE de.status = '在职') as active_count,
round(avg(s.gross_pay)::numeric, 2) as avg_gross_pay,
round(avg(s.perf_score)::numeric, 2) as avg_perf_score,
round(avg(s.actual_attend::numeric / NULLIF(s.expected_attend, 0) * 100)::numeric, 1) as avg_attendance_rate
FROM analytics.dim_employee de
LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code
GROUP BY de.position
ORDER BY count(*) DESC
`)
sendSuccess(res, {
position_summary: positionSummary.rows,
employees: result.rows,
}, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 9. 库存周转与损耗趋势
// ============================================================
router.get('/inventory/turnover', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
let storeFilter = ''
const params: any[] = [month]
if (storeCode) {
params.push(storeCode)
storeFilter = `AND fis.store_code = $${params.length}`
}
// 库存周转概览
const turnoverResult = await query(`
SELECT
fis.store_code,
ds.store_name,
round(sum(fis.opening_amount)::numeric, 2) as opening_value,
round(sum(fis.purchase_amount)::numeric, 2) as purchase_value,
round(sum(fis.consumption_amount)::numeric, 2) as consumption_value,
round(sum(fis.ending_amount)::numeric, 2) as ending_value,
round(sum(fis.waste_amount)::numeric, 2) as waste_value,
round(avg(fis.ending_amount)::numeric, 2) as avg_ending,
round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1) as turnover_days
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
${storeFilter}
GROUP BY fis.store_code, ds.store_name
ORDER BY turnover_days ASC
`, params)
// 损耗TOP
const wasteTop = await query(`
SELECT
dm.material_name,
round(sum(fis.waste_quantity)::numeric, 2) as waste_qty,
round(sum(fis.waste_amount)::numeric, 2) as waste_value,
count(DISTINCT fis.store_code) as affected_stores
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_material dm ON fis.material_code = dm.material_code
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
AND fis.waste_amount > 0
${storeFilter}
GROUP BY dm.material_name
ORDER BY waste_value DESC
LIMIT 20
`, params)
sendSuccess(res, {
turnover: turnoverResult.rows,
waste_top: wasteTop.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 10. 营销ROI基础版
// ============================================================
router.get('/marketing/roi', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 各营销方案的效果对比
const result = await query(`
WITH plan_stats AS (
SELECT
marketing_plan,
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as total_revenue,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin,
round(avg(discount_total)::numeric, 2) as avg_discount,
round(sum(discount_total)::numeric, 2) as total_discount,
count(DISTINCT member_id) FILTER (WHERE member_id IS NOT NULL AND member_id != '') as member_bills
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND marketing_plan IS NOT NULL AND marketing_plan != ''
GROUP BY marketing_plan
),
no_plan_stats AS (
SELECT
count(*) as bill_count,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin,
round(avg(discount_total)::numeric, 2) as avg_discount
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND (marketing_plan IS NULL OR marketing_plan = '')
)
SELECT
ps.marketing_plan,
ps.bill_count,
ps.total_revenue,
ps.avg_bill_value,
ps.avg_margin,
ps.total_discount,
ps.avg_discount,
ps.member_bills,
round((ps.member_bills::numeric / NULLIF(ps.bill_count, 0) * 100)::numeric, 1) as member_share_pct,
round(((ps.avg_bill_value - nps.avg_bill_value) / NULLIF(nps.avg_bill_value, 0) * 100)::numeric, 1) as bill_value_uplift_pct,
round((ps.avg_margin - nps.avg_margin)::numeric, 2) as margin_delta
FROM plan_stats ps
CROSS JOIN no_plan_stats nps
ORDER BY ps.total_revenue DESC
`, [month])
// 无营销方案基准
const baseline = await query(`
SELECT
count(*) as bill_count,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin,
round(avg(discount_total)::numeric, 2) as avg_discount
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND (marketing_plan IS NULL OR marketing_plan = '')
`, [month])
sendSuccess(res, {
baseline: baseline.rows[0],
campaigns: result.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 11. 辖区巡检计划生成
// ============================================================
router.get('/region/inspection-plan', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
rr.store_code,
rr.store_name,
rr.risk_level,
rr.primary_issue,
rr.bill_count,
round(rr.received::numeric, 2) as received,
rr.anomaly_rate_pct,
rr.discount_rate_pct,
rr.member_bill_share_pct,
ds.region,
CASE rr.risk_level
WHEN '高风险' THEN 1
WHEN '中风险' THEN 2
WHEN '低风险' THEN 3
ELSE 4
END as priority,
CASE rr.risk_level
WHEN '高风险' THEN '本周必须巡检'
WHEN '中风险' THEN '两周内巡检'
WHEN '低风险' THEN '月度例行巡检'
ELSE '季度巡检'
END as inspection_frequency,
CASE rr.risk_level
WHEN '高风险' THEN '重点检查: ' || COALESCE(rr.primary_issue, '综合风险')
WHEN '中风险' THEN '关注: ' || COALESCE(rr.primary_issue, '常规指标')
ELSE '常规检查'
END as focus_area
FROM analytics.mv_store_risk_rating_monthly rr
LEFT JOIN analytics.dim_store ds ON rr.store_code = ds.store_code
WHERE rr.month_start = $1
ORDER BY priority, rr.received ASC
`, [month])
const summary = {
total_stores: result.rows.length,
high_risk: result.rows.filter((r: any) => r.risk_level === '高风险').length,
medium_risk: result.rows.filter((r: any) => r.risk_level === '中风险').length,
low_risk: result.rows.filter((r: any) => r.risk_level === '低风险').length,
this_week: result.rows.filter((r: any) => r.priority === 1).length,
}
sendSuccess(res, { summary, plan: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 12. 统一KPI达成率(支持门店/区域/总部三个维度)
// ============================================================
router.get('/kpi', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const level = (req.query.level as string) || 'hq' // hq | region | store
const region = (req.query.region as string) || ''
const storeCode = (req.query.store_code as string) || ''
if (level === 'store' && storeCode) {
// 单门店KPI
const result = await query(`
SELECT
r.store_code,
r.store_name,
round(r.received::numeric, 2) as actual_revenue,
round(r.bill_count::numeric, 0) as actual_bills,
t.revenue_target,
t.bill_count_target,
t.profit_target,
e.actual_store_contribution as actual_profit,
round((r.received / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
round(CASE
WHEN t.profit_target > 0 THEN e.actual_store_contribution / t.profit_target * 100
WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(e.actual_store_contribution)) / ABS(t.profit_target) * 100
ELSE NULL
END::numeric, 1) as profit_achievement_pct,
round((e.actual_store_contribution / NULLIF(r.received, 0) * 100)::numeric, 2) as profit_margin_pct
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.dim_store_target t ON r.store_code = t.store_code AND t.target_month = $1
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1 AND r.store_code = $2
`, [month, storeCode])
sendSuccess(res, result.rows[0] || {})
} else if (level === 'region') {
// 按区域汇总KPI
const result = await query(`
WITH actual AS (
SELECT
ds.region,
round(sum(r.received)::numeric, 2) as actual_revenue,
sum(r.bill_count) as actual_bills,
round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit
FROM analytics.mv_store_risk_rating_monthly r
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
target AS (
SELECT
ds.region,
round(sum(t.revenue_target)::numeric, 2) as revenue_target,
round(sum(t.bill_count_target)::numeric, 0) as bill_count_target,
round(sum(t.profit_target)::numeric, 2) as profit_target
FROM analytics.dim_store_target t
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
WHERE t.target_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
)
SELECT
a.region,
a.actual_revenue,
a.actual_bills,
a.actual_profit,
t.revenue_target,
t.bill_count_target,
t.profit_target,
round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
round(CASE
WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100
WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100
ELSE NULL
END::numeric, 1) as profit_achievement_pct,
round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct
FROM actual a
LEFT JOIN target t ON a.region = t.region
ORDER BY a.actual_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} else {
// 总部汇总KPI
const result = await query(`
WITH actual AS (
SELECT
round(sum(r.received)::numeric, 2) as actual_revenue,
sum(r.bill_count) as actual_bills,
round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1
),
target AS (
SELECT
round(sum(t.revenue_target)::numeric, 2) as revenue_target,
round(sum(t.bill_count_target)::numeric, 0) as bill_count_target,
round(sum(t.profit_target)::numeric, 2) as profit_target
FROM analytics.dim_store_target t
WHERE t.target_month = $1
)
SELECT
a.actual_revenue,
a.actual_bills,
a.actual_profit,
t.revenue_target,
t.bill_count_target,
t.profit_target,
round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
round(CASE
WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100
WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100
ELSE NULL
END::numeric, 1) as profit_achievement_pct,
round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct
FROM actual a
CROSS JOIN target t
`, [month])
sendSuccess(res, result.rows[0] || {})
}
} catch (err: any) {
sendError(res, err.message)
}
})
export default router