diff --git a/20260802-优化-数据.md b/20260802-优化-数据.md new file mode 100644 index 0000000..4e5e6ab --- /dev/null +++ b/20260802-优化-数据.md @@ -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/食安分析 diff --git a/20260802-优化.md b/20260802-优化.md new file mode 100644 index 0000000..5ca167f --- /dev/null +++ b/20260802-优化.md @@ -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 | 培训效果评估 | 培训记录 | 接入培训系统 | diff --git a/20260802-总结.md b/20260802-总结.md new file mode 100644 index 0000000..942bf3d --- /dev/null +++ b/20260802-总结.md @@ -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 API,JWT 认证 | +| 数据库 | 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 项待实现功能,实现从"经营诊断体系"到"门店利润管理、成本归因和预测决策体系"的全面升级。 diff --git a/client/src/App.tsx b/client/src/App.tsx index d71932c..cf43655 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/client/src/components/KPISection.tsx b/client/src/components/KPISection.tsx new file mode 100644 index 0000000..2d609c9 --- /dev/null +++ b/client/src/components/KPISection.tsx @@ -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 ( + +

加载中...

+
+ ) + } + + const d = (data as any)?.data + + if (level === 'region') { + const rows = Array.isArray(d) ? d : [] + if (rows.length === 0) { + return ( + +

暂无数据

+
+ ) + } + + 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 ( + +
+ = 100 ? 'good' : revAchievement >= 80 ? 'warn' : 'bad'} description="实际收入 / 目标收入" /> + = 100 ? 'good' : profitAchievement >= 80 ? 'warn' : 'bad'} description="实际利润 / 目标利润" /> + + +
+ + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + `${v}%`} tick={{ fontSize: 10 }} /> + name.includes('达成率') ? `${Number(v).toFixed(1)}%` : formatCurrency(v)} /> + + + + + + + + + +
+ ) + } + + // 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 ( + +
+ = 100 ? 'good' : revAchievement >= 80 ? 'warn' : 'bad'} description="实际收入 / 目标收入" /> + = 100 ? 'good' : profitAchievement >= 80 ? 'warn' : 'bad'} description="实际利润 / 目标利润" /> + + = 10 ? 'good' : profitMargin >= 5 ? 'warn' : 'bad'} description="实际利润 / 实际收入" /> +
+ + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + `${v}%`} tick={{ fontSize: 10 }} /> + name === '达成率' ? `${Number(v).toFixed(1)}%` : formatCurrency(v)} /> + + + + + + +
+ ) +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 381712c..59e1ea5 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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'] }, ], }, { diff --git a/client/src/pages/BossPage.tsx b/client/src/pages/BossPage.tsx index dc1e5a1..baa930c 100644 --- a/client/src/pages/BossPage.tsx +++ b/client/src/pages/BossPage.tsx @@ -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 = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } @@ -224,6 +225,9 @@ export function BossPage() { 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)} 笔 · 公式:实收 ÷ 账单数`} /> + {/* ①b KPI达成率 */} + + {/* ② 利润瀑布 */} {wf && ( diff --git a/client/src/pages/EmployeePerformancePage.tsx b/client/src/pages/EmployeePerformancePage.tsx new file mode 100644 index 0000000..43ba774 --- /dev/null +++ b/client/src/pages/EmployeePerformancePage.tsx @@ -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 + } + + 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 ( +
+
+
+

员工绩效分析

+

岗位分布 · 薪资绩效 · 出勤率

+
+
+ + {/* 概览指标 */} + +
+ + + + +
+
+ + {/* 图表 */} +
+ + + + + + + + + + + + + + + + + + {`${name}: ${formatNumber(value)}`}}> + {positionPieData.map((_: any, i: number) => )} + + [formatNumber(v), '人数']} /> + + + + +
+ + {/* 岗位汇总 */} + + 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 = 95 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}% + }}, + ]} + /> + + + {/* 筛选器 */} +
+ + + 共 {meta.total || 0} 条 +
+ + {/* 员工明细 */} + + { + const color = r.status === '在职' ? 'text-green-600' : 'text-red-600' + return {r.status} + }}, + { 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) : '' }, + ]} + /> + + + {/* 分页 */} + {meta.total > 50 && ( +
+ + 第 {page} 页 / 共 {Math.ceil(meta.total / 50)} 页 + +
+ )} +
+ ) +} diff --git a/client/src/pages/InventoryTurnoverPage.tsx b/client/src/pages/InventoryTurnoverPage.tsx new file mode 100644 index 0000000..e47567a --- /dev/null +++ b/client/src/pages/InventoryTurnoverPage.tsx @@ -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 + } + + 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 ( +
+
+
+

库存周转与损耗

+

周转天数 · 损耗TOP · 门店对比 · {month}

+
+ +
+ + {/* 概览指标 */} + +
+ + + + +
+
+ + 0 ? totalWaste / totalConsumption * 100 : 0} format="percent" description="损耗金额/消耗金额" status="bad" /> + + +
+
+ + {/* 图表 */} +
+ + + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + name === '期末库存' ? formatCurrency(v) : `${v}天`} /> + + + + + + + + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + + formatCurrency(v)} /> + + + + + +
+ + {/* 门店周转明细 */} + + 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 1000 ? 'text-red-600 font-medium' : ''}>{formatCurrency(v)} + }}, + { key: 'turnover_days', label: '周转天数', align: 'right', render: (r) => { + const v = Number(r.turnover_days || 0) + return {v.toFixed(1)}天 + }}, + ]} + /> + + + {/* 损耗TOP明细 */} + + 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) }, + ]} + /> + +
+ ) +} diff --git a/client/src/pages/MemberLTVPage.tsx b/client/src/pages/MemberLTVPage.tsx new file mode 100644 index 0000000..6ab4487 --- /dev/null +++ b/client/src/pages/MemberLTVPage.tsx @@ -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 + } + + 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 ( +
+
+
+

会员LTV与分层运营

+

生命周期价值 · 等级分布 · 活跃度分析

+
+
+ + {/* 概览指标 */} + +
+ + + + +
+
+ + + + +
+
+ + {/* 等级分布图 */} +
+ + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + name === '平均消费' ? formatCurrency(v) : formatNumber(v)} /> + + + + + + + + + + + {`${name}: ${formatNumber(value)}`}}> + {levelPieData.map((_: any, i: number) => )} + + [formatNumber(v), '人数']} /> + + + + +
+ + {/* 筛选器 */} +
+ + + 共 {meta.total || 0} 条 +
+ + {/* 会员明细 */} + + { + const color = r.member_level === '钻石' ? 'text-purple-600' : r.member_level === '金卡' ? 'text-yellow-600' : r.member_level === '银卡' ? 'text-blue-600' : 'text-gray-500' + return {r.member_level} + }}, + { key: 'status', label: '状态', render: (r) => { + const color = r.status === '活跃' ? 'text-green-600' : r.status === '沉睡' ? 'text-yellow-600' : 'text-red-600' + return {r.status} + }}, + { 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: '注册渠道' }, + ]} + /> + + + {/* 分页 */} + {meta.total > 50 && ( +
+ + 第 {page} 页 / 共 {Math.ceil(meta.total / 50)} 页 + +
+ )} +
+ ) +} diff --git a/client/src/pages/MenuEngineeringPage.tsx b/client/src/pages/MenuEngineeringPage.tsx new file mode 100644 index 0000000..9a9b48f --- /dev/null +++ b/client/src/pages/MenuEngineeringPage.tsx @@ -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 + } + + 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 ( +
+
+
+

菜单工程行动清单

+

ABC分类 · 行动建议 · 淘汰/保留/提价/降本

+
+ +
+ + {/* 概览指标 */} + +
+ + + + +
+
+ + {/* 图表 */} +
+ + + + {`${name}: ${formatNumber(value)}`}}> + {abcPieData.map((_: any, i: number) => )} + + [formatNumber(v), '数量']} /> + + + + + + + + + + + + [formatNumber(v), 'SKU数']} /> + + + + + +
+ + {/* 行动清单 */} + + { + const color = r.abc_class?.startsWith('A') ? 'text-green-600 font-medium' : r.abc_class?.startsWith('B') ? 'text-blue-600' : 'text-yellow-600' + return {r.abc_class} + }}, + { 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 {r.action} + }}, + { key: 'suggestion', label: '详细建议' }, + ]} + /> + +
+ ) +} diff --git a/client/src/pages/RegionComparisonPage.tsx b/client/src/pages/RegionComparisonPage.tsx new file mode 100644 index 0000000..96e3efa --- /dev/null +++ b/client/src/pages/RegionComparisonPage.tsx @@ -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 + } + + 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 ( +
+
+
+

区域对比排名

+

区域营收 · 费用率 · KPI达成预测 · {month}

+
+ +
+ + {/* 概览指标 */} + +
+ + + + +
+
+ + {/* KPI达成率 */} + + + {/* 区域收入对比图 */} +
+ + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + formatCurrency(v)} /> + + + + + + +
+ + {/* 区域对比明细 */} + + 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) }, + ]} + /> + + + {/* KPI达成预测明细 */} + + 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 = 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}% + }}, + { key: 'bill_achievement_pct', label: '账单达成率', align: 'right', render: (r) => { + const v = Number(r.bill_achievement_pct || 0) + return = 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}% + }}, + ]} + /> + +
+ ) +} diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index 7db9f36..ec01ec1 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -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() { + {/* KPI达成率 */} + + {/* 最新经营概览 + 本周指标进度 一行 */}
{/* 经营概览 */} diff --git a/db/etl_batch1_fill_dims.sql b/db/etl_batch1_fill_dims.sql new file mode 100644 index 0000000..82fc2f8 --- /dev/null +++ b/db/etl_batch1_fill_dims.sql @@ -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; diff --git a/db/etl_dim_employee.sql b/db/etl_dim_employee.sql new file mode 100644 index 0000000..cc27440 --- /dev/null +++ b/db/etl_dim_employee.sql @@ -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; diff --git a/db/etl_store_target.sql b/db/etl_store_target.sql new file mode 100644 index 0000000..89a0e3a --- /dev/null +++ b/db/etl_store_target.sql @@ -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'; diff --git a/run.md b/run.md index ebc7491..1dbeb0f 100644 --- a/run.md +++ b/run.md @@ -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. 访问 diff --git a/server/src/index.ts b/server/src/index.ts index 3de85b5..de2e146 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) diff --git a/server/src/routes/analytics-enhanced.ts b/server/src/routes/analytics-enhanced.ts new file mode 100644 index 0000000..0e0c24b --- /dev/null +++ b/server/src/routes/analytics-enhanced.ts @@ -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