7ea6a2515f
- 归档根目录散落文档到 docs/20260812/ 和 docs/20260812-1/ - 新增 16-战略外脑实施方法论.md:七项能力、十二步工作法、AI与决策治理、成熟度和验收 - 新增 17-SBrainCO战略外脑建设对照表.md:方法论映射为领域模型、产品模块、API和分阶段待办 - 新增 调查表/战略外脑能力验收Checklist.csv:35项能力验收检查项 - 更新 01-总体框架与八步工作法.md:新增战略外脑扩展章节 - 更新 README.md:新增战略外脑体系导航 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1067 lines
48 KiB
Markdown
1067 lines
48 KiB
Markdown
# 系统升级方案:从"4月单月分析"到"逐月+时间段汇总+同比环比"
|
||
|
||
> 文档日期:2026-08-01
|
||
> 状态:方案设计
|
||
> 关联文档:`docs/2026年4月运营分析指标与展现审查及利润提升整改方案.md`
|
||
|
||
---
|
||
|
||
## 一、升级目标
|
||
|
||
| 能力 | 当前状态 | 目标状态 |
|
||
|---|---|---|
|
||
| 月份选择 | 固定2026年4月 | 任意月份可选,默认最新有数据月份 |
|
||
| 时间段汇总 | 不支持 | 季度/半年/全年/自定义区间汇总 |
|
||
| 同比(YoY) | 不支持 | 任意月份 vs 去年同月,展示变化率 |
|
||
| 环比(MoM) | 不支持 | 任意月份 vs 上月,展示变化率 |
|
||
| 趋势分析 | 不支持 | 月度趋势线、移动平均、同比叠加 |
|
||
| 前端选择器 | 无 | 全局月份选择器 + 时间范围切换 |
|
||
| 数据管道 | 硬编码4月 | 参数化导入,支持多月数据 |
|
||
|
||
---
|
||
|
||
## 二、现状诊断
|
||
|
||
### 1. 硬编码 `_april` 的数据库对象(32个)
|
||
|
||
这些视图/表的 SQL 内部硬编码了 `WHERE report_month = DATE '2026-04-01'` 或直接从4月数据源取数,**无法切换月份**。
|
||
|
||
> **核实方法:** `pg_matviews` 查物化视图9个 + `information_schema.tables` 查基表6个和普通视图17个 = 合计32个。
|
||
|
||
#### 1.1 物化视图(9个,`pg_matviews`)
|
||
|
||
| 对象名 | 用途 | 依赖月份字段 |
|
||
|---|---|---|
|
||
| `mv_store_theoretical_actual_cost_april` | 门店理论/实际成本对比 | `report_month` |
|
||
| `mv_store_action_priority_deep_april` | 门店行动优先级深度分析 | `report_month` |
|
||
| `dish_store_summary_april` | 菜品门店汇总 | `report_month` |
|
||
| `dish_store_sku_april` | 菜品门店SKU汇总 | `report_month` |
|
||
| `dish_basket_april` | 菜品购物篮分析 | `report_month` |
|
||
| `dish_category_summary_april` | 菜品品类汇总 | `report_month` |
|
||
| `dish_member_sku_april` | 菜品会员SKU分析 | `report_month` |
|
||
| `dish_sales_april` | 菜品销售汇总 | `report_month` |
|
||
| `dish_sku_summary_april` | 菜品SKU汇总 | `report_month` |
|
||
|
||
#### 1.2 普通视图(17个,`information_schema.tables` WHERE table_type='VIEW')
|
||
|
||
| 对象名 | 用途 | 依赖月份字段 |
|
||
|---|---|---|
|
||
| `v_dish_sku_abc_april` | SKU ABC分类分析 | `report_month` |
|
||
| `v_inventory_cost_classified_april` | 库存成本分类 | `report_month` |
|
||
| `v_inventory_abnormal_items_april` | 库存异常货品 | `report_month` |
|
||
| `v_inventory_finance_category_april` | 库存财务分类 | `report_month` |
|
||
| `v_c_sku_governance_april` | SKU治理分析 | `report_month` |
|
||
| `v_dish_member_repeat_april` | 菜品会员复购 | `report_month` |
|
||
| `v_store_action_priority_deep_april` | 门店行动优先级 | `report_month` |
|
||
| `v_store_area_efficiency_april` | 门店面积效率 | `report_month` |
|
||
| `v_store_deep_diagnosis_april` | 门店深度诊断 | `report_month` |
|
||
| `v_store_site_profile_april` | 门店选址画像 | `report_month` |
|
||
| `v_store_site_replication_score_april` | 选址复制评分 | `report_month` |
|
||
| `v_store_location_overlap_risk_april` | 选址重叠风险 | `report_month` |
|
||
| `v_store_nearest_neighbor_april` | 最近邻分析 | `report_month` |
|
||
| `v_store_spatial_pairs_april` | 门店空间对分析 | `report_month` |
|
||
| `v_store_theoretical_actual_cost_april` | 门店理论/实际成本(视图版) | `report_month` |
|
||
| `v_district_site_benchmark_april` | 区域选址基准(视图版) | `report_month` |
|
||
| `v_site_segment_benchmark_april` | 分段基准(视图版) | `report_month` |
|
||
|
||
#### 1.3 基表(6个,`information_schema.tables` WHERE table_type='BASE TABLE',不含物化视图)
|
||
|
||
| 对象名 | 用途 |
|
||
|---|---|
|
||
| `dish_pair_summary_april` | 菜品搭售分析 |
|
||
| `mv_district_site_benchmark_april` | 区域选址基准(表) |
|
||
| `mv_site_segment_benchmark_april` | 分段基准(表) |
|
||
| `mv_store_location_overlap_risk_april` | 选址重叠风险(表) |
|
||
| `mv_store_site_profile_april` | 门店选址画像(表) |
|
||
| `mv_store_site_replication_score_april` | 选址复制评分(表) |
|
||
|
||
> **注意:** `v_store_category_cost_benchmark_april` 和 `v_store_inventory_efficiency_april` 在 `data.ts` 中被引用,但数据库中不存在这两个对象(可能已删除或从未创建),需在改造时排查。
|
||
> **注意:** 部分对象有同名 `v_`(视图)和 `mv_`(基表)两个版本,如 `v_store_site_profile_april` 和 `mv_store_site_profile_april`,改造时需统一处理。
|
||
|
||
### 2. 硬编码 `DATE '2026-04-01'` 或 `_april` 表名的 API 接口(45处)
|
||
|
||
> **核实方法:** `grep -rn "DATE '2026-04-01'\|_april" server/src/routes/*.ts` 逐行统计。
|
||
|
||
#### 2.1 `server/src/routes/store-expense.ts`(11处 `DATE '2026-04-01'`)
|
||
|
||
| 行号 | 接口 | 硬编码内容 |
|
||
|---|---|---|
|
||
| 36 | `/store-expense/overview` | `e.report_month = DATE '2026-04-01'` |
|
||
| 88 | `/store-expense/expense-structure` | `report_month = DATE '2026-04-01'` |
|
||
| 107 | `/store-expense/store-ranking` | `WHERE report_month = DATE '2026-04-01'` |
|
||
| 161 | `/store-expense/store-contribution` | `WHERE report_month = DATE '2026-04-01'` |
|
||
| 211 | `/store-expense/rent-risk` | `WHERE report_month = DATE '2026-04-01' AND rent_expense IS NOT NULL` |
|
||
| 276 | `/store-expense/delivery-commission` | `WHERE report_month = DATE '2026-04-01' AND delivery_received > 0` |
|
||
| 330 | `/store-expense/efficiency` | `WHERE report_month = DATE '2026-04-01' AND received > 0 AND area_sqm IS NOT NULL` |
|
||
| 386 | `/store-expense/fixed-variable` | `WHERE report_month = DATE '2026-04-01' AND received > 0` |
|
||
| 436 | `/store-expense/break-even` | `WHERE report_month = DATE '2026-04-01' AND received > 0` |
|
||
| 499 | `/store-expense/loss-diagnosis` | `WHERE report_month = DATE '2026-04-01' AND actual_store_contribution <= 0 AND received > 0` |
|
||
| 725 | `/store-expense/store-evaluation` | `WHERE report_month = DATE '2026-04-01' AND received > 0` |
|
||
|
||
#### 2.2 `server/src/routes/data.ts`(26处:9处 `DATE '2026-04-01'` + 17处 `_april` 表名)
|
||
|
||
| 行号 | 接口 | 硬编码内容 |
|
||
|---|---|---|
|
||
| 530 | `/overview/profit-waterfall` | `e.report_month = DATE '2026-04-01'` |
|
||
| 568 | `/overview/store-profit-ranking` | `e.report_month = DATE '2026-04-01'` |
|
||
| 589 | `/overview/profit-opportunity` | `e.report_month = DATE '2026-04-01'` |
|
||
| 661 | `/overview/profit-opportunity` | `report_month = DATE '2026-04-01'`(platform CTE) |
|
||
| 668 | `/overview/profit-opportunity` | `e.report_month = DATE '2026-04-01'`(platform_stores CTE) |
|
||
| 890 | `/bank/report` | `month = DATE '2026-04-01'`(overview) |
|
||
| 891 | `/bank/report` | `month = DATE '2026-04-01'`(daily) |
|
||
| 898 | `/bank/report` | `e.report_month = DATE '2026-04-01'`(waterfall) |
|
||
| 909 | `/bank/report` | `business_date >= DATE '2026-04-01' AND < DATE '2026-05-01'`(channel) |
|
||
| 117 | `/stores/:code` | `mv_store_action_priority_deep_april`(表名硬编码) |
|
||
| 176 | `/cost/comparison` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
| 185 | `/cost/category-benchmark` | `v_store_category_cost_benchmark_april`(表名硬编码) |
|
||
| 194 | `/cost/inventory` | `v_store_inventory_efficiency_april`(表名硬编码) |
|
||
| 230 | `/sku/abc` | `v_dish_sku_abc_april`(表名硬编码) |
|
||
| 248 | `/sku/attach` | `dish_pair_summary_april`(表名硬编码) |
|
||
| 364 | `/data-quality` | `v_inventory_cost_classified_april`(表名硬编码) |
|
||
| 406 | `/stores/:code/cost` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
| 412 | `/stores/:code/cost` | `v_inventory_cost_classified_april`(表名硬编码) |
|
||
| 420 | `/stores/:code/cost` | `v_inventory_cost_classified_april`(表名硬编码) |
|
||
| 474 | `/site-selection/profile` | `mv_store_site_profile_april`(表名硬编码) |
|
||
| 482 | `/site-selection/segment-benchmark` | `mv_site_segment_benchmark_april`(表名硬编码) |
|
||
| 490 | `/site-selection/replication` | `mv_store_site_replication_score_april`(表名硬编码) |
|
||
| 498 | `/site-selection/overlap-risk` | `mv_store_location_overlap_risk_april`(表名硬编码) |
|
||
| 506 | `/site-selection/district-benchmark` | `mv_district_site_benchmark_april`(表名硬编码) |
|
||
| 676 | `/overview/profit-opportunity` | `v_dish_sku_abc_april`(表名硬编码,sku CTE) |
|
||
| 680 | `/overview/profit-opportunity` | `v_dish_sku_abc_april`(表名硬编码,sku_samples CTE) |
|
||
|
||
#### 2.3 `server/src/routes/cost-analysis.ts`(4处)
|
||
|
||
| 行号 | 接口 | 硬编码内容 |
|
||
|---|---|---|
|
||
| 736 | `/cost-analysis/store-overview` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
| 753 | `/cost-analysis/store-map` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
| 780 | `/cost-analysis/store-ranking` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
| 791 | `/cost-analysis/store-ranking` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
|
||
#### 2.4 `server/src/routes/situational-awareness.ts`(2处)
|
||
|
||
| 行号 | 接口 | 硬编码内容 |
|
||
|---|---|---|
|
||
| 23 | `/situational-awareness/health-score` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
| 296 | `/situational-awareness/correlation` | `mv_store_theoretical_actual_cost_april`(表名硬编码) |
|
||
|
||
#### 2.5 `server/src/routes/tasks.ts`(2处)
|
||
|
||
| 行号 | 接口 | 硬编码内容 |
|
||
|---|---|---|
|
||
| 622 | `/tasks/monthly-review/sku-governance` | `v_dish_sku_abc_april`(表名硬编码) |
|
||
| 629 | `/tasks/monthly-review/sku-governance` | `v_dish_sku_abc_april`(表名硬编码) |
|
||
|
||
### 3. 已支持月度参数的接口(少数,可作参考模式)
|
||
|
||
| 接口 | 文件 | 参数化方式 |
|
||
|---|---|---|
|
||
| `/overview` | `data.ts:9-17` | `parseMonth(req)` → `mv_overview_monthly WHERE month = $1` |
|
||
| `/overview/daily` | `data.ts:19-27` | `parseMonth(req)` → `mv_overview_daily WHERE month = $1` |
|
||
| `/revenue/channel` | `data.ts:789-813` | `parseMonth(req)` → `business_date >= $1 AND < $1 + INTERVAL '1 month'` |
|
||
| `/revenue/meal-period` | `data.ts:816-834` | `parseMonth(req)` → 同上 |
|
||
| `/revenue/store-ranking` | `data.ts:837-860` | `parseMonth(req)` → 同上 |
|
||
| `/revenue/daily-summary` | `data.ts:863-884` | `parseMonth(req)` → 同上 |
|
||
|
||
### 4. 底层数据结构
|
||
|
||
| 数据对象 | 类型 | 日期字段 | 支持多月 | 说明 |
|
||
|---|---|---|:---:|---|
|
||
| `bill_fact` | 物化视图 | `closed_at`(timestamptz) | ✅ | 账单事实表,通过 `closed_at::date` 派生 `business_date` |
|
||
| `v_store_daily` | 视图 | `business_date`(派生) | ✅ | 从 `bill_fact.closed_at::date` 派生 |
|
||
| `v_channel_daily` | 视图 | `business_date` | ✅ | 渠道日度数据 |
|
||
| `mv_overview_monthly` | 物化视图 | `month` | ✅ | 月度总览,已有 `month` 字段 |
|
||
| `mv_overview_daily` | 物化视图 | `month` + `business_date` | ✅ | 日度总览 |
|
||
| `mv_store_operating_expense_monthly` | 物化视图 | `report_month` | ✅ | 门店费用月度,已有 `report_month` 字段 |
|
||
| `mv_store_risk_rating` | 物化视图 | 无显式月份 | ❌ | 门店风险评级,当前只反映4月 |
|
||
| `v_operating_expense_account_monthly` | 视图 | `report_month` | ✅ | 费用科目月度 |
|
||
| 32个 `_april` 对象 | 混合 | 硬编码4月 | ❌ | 需改造(9物化视图 + 17普通视图 + 6基表) |
|
||
|
||
### 5. 数据导入脚本硬编码
|
||
|
||
| 文件 | 硬编码内容 |
|
||
|---|---|
|
||
| `db/import_salary_attendance.py:57` | `report_month = '2026-04-01'` |
|
||
| `db/import_salary_attendance.py:159` | `report_month = '2026-04-01'` |
|
||
| `sql/etl_fact_inventory.sql:32` | `WHERE i.report_month = DATE '2026-04-01'` |
|
||
| `db/ontology_standard.sql:758-772` | 15处 `'2026-04-01'::date` 作为指标 `effective_date`(元数据,非查询过滤器) |
|
||
|
||
### 6. 前端硬编码月份参数(10处)
|
||
|
||
> **核实方法:** `grep -rn "2026-0[3-5]" client/src/pages/*.tsx` 逐行统计。
|
||
|
||
| 文件 | 行号 | 硬编码内容 |
|
||
|---|---|---|
|
||
| `BomPenetrationPage.tsx` | 23 | `useState('2026-04')` |
|
||
| `CentralKitchenPage.tsx` | 25 | `useState('2026-04')` |
|
||
| `DistributionReconciliationPage.tsx` | 28 | `useState('2026-04')` |
|
||
| `MonthlyReviewPage.tsx` | 27 | `useState('2026-05')` |
|
||
| `ProductionPlanPage.tsx` | 26 | `useState('2026-04')` |
|
||
| `RegionalPage.tsx` | 32 | `month: '2026-05'`(API调用参数) |
|
||
| `StoreDetailPage.tsx` | 45 | `month: '2026-05'`(API调用参数) |
|
||
| `StorePage.tsx` | 33 | `month: '2026-05'`(API调用参数) |
|
||
| `StorePage.tsx` | 38 | `month: '2026-04'`(API调用参数) |
|
||
| `TasksPage.tsx` | 14 | `useState('2026-05')` |
|
||
|
||
> **注意:** 这些前端硬编码月份需要替换为全局 `MonthPicker` 组件的选择值,通过 React Query 的 `queryKey` 联动。
|
||
|
||
### 7. `parseMonth` 函数现状
|
||
|
||
```typescript
|
||
// server/src/middleware/error.ts:33-36
|
||
export function parseMonth(req: Request): string {
|
||
const month = (req.query.month as string) || '2026-04' // 默认4月
|
||
return month.length === 7 ? `${month}-01` : month
|
||
}
|
||
```
|
||
|
||
已有参数解析基础设施,但默认值硬编码为4月,且大部分接口未使用。
|
||
|
||
---
|
||
|
||
## 三、改造方案
|
||
|
||
### 第1层:数据库 — 去硬编码,参数化视图
|
||
|
||
#### 方案选择:参数化函数 + 通用视图
|
||
|
||
将32个 `_april` 视图/表改为接受 `p_month DATE` 参数的函数,同时创建同名通用视图供过渡期使用。
|
||
|
||
#### 1.1 核心利润链改造(第1批,9个对象)
|
||
|
||
**示例:`mv_store_theoretical_actual_cost_april` → 函数**
|
||
|
||
```sql
|
||
-- 创建参数化函数
|
||
CREATE OR REPLACE FUNCTION analytics.fn_store_cost_comparison(
|
||
p_month DATE DEFAULT '2026-04-01'
|
||
) RETURNS TABLE (
|
||
store_code TEXT,
|
||
store_name TEXT,
|
||
theoretical_cost NUMERIC,
|
||
actual_food_cost NUMERIC,
|
||
food_cost_variance NUMERIC,
|
||
theoretical_cost_rate_pct NUMERIC,
|
||
actual_food_cost_rate_pct NUMERIC,
|
||
variance_to_theoretical_pct NUMERIC,
|
||
comparison_status TEXT,
|
||
variance_level TEXT,
|
||
negative_item_lines INT,
|
||
estimated_inventory_days NUMERIC,
|
||
ending_inventory_amount NUMERIC,
|
||
abnormal_item_lines INT
|
||
) AS $$
|
||
SELECT ...
|
||
FROM analytics.v_inventory_cost_classified(p_month) -- 下游视图也需参数化
|
||
WHERE report_month = p_month
|
||
GROUP BY store_code, store_name
|
||
$$ LANGUAGE SQL STABLE;
|
||
|
||
-- 创建通用视图(过渡期兼容)
|
||
CREATE OR REPLACE VIEW analytics.v_store_cost_comparison AS
|
||
SELECT * FROM analytics.fn_store_cost_comparison('2026-04-01');
|
||
```
|
||
|
||
**需改造的第1批对象:**
|
||
|
||
| 原始对象名 | 新函数名 | 新通用视图名 | 依赖链 |
|
||
|---|---|---|---|
|
||
| `mv_store_theoretical_actual_cost_april` | `fn_store_cost_comparison(p_month)` | `v_store_cost_comparison` | 依赖 `v_inventory_cost_classified` |
|
||
| `v_inventory_cost_classified_april` | `fn_inventory_cost_classified(p_month)` | `v_inventory_cost_classified` | 依赖 `v_inventory_finance_category` |
|
||
| `v_inventory_finance_category_april` | `fn_inventory_finance_category(p_month)` | `v_inventory_finance_category` | 依赖 `inventory_cost_records` |
|
||
| `v_inventory_abnormal_items_april` | `fn_inventory_abnormal_items(p_month)` | `v_inventory_abnormal_items` | 依赖 `inventory_cost_records` |
|
||
| `v_store_action_priority_deep_april` | `fn_store_action_priority(p_month)` | `v_store_action_priority` | 依赖 `mv_store_risk_rating` |
|
||
| `mv_store_action_priority_deep_april` | 与 `v_` 版本统一为 `fn_store_action_priority` | 同上 | 物化视图,与v_版本共享函数 |
|
||
| `v_store_area_efficiency_april` | `fn_store_area_efficiency(p_month)` | `v_store_area_efficiency` | 依赖 `mv_store_operating_expense_monthly` |
|
||
| `v_store_deep_diagnosis_april` | `fn_store_deep_diagnosis(p_month)` | `v_store_deep_diagnosis` | 依赖多个视图 |
|
||
| `v_c_sku_governance_april` | `fn_sku_governance(p_month)` | `v_sku_governance` | 依赖 `v_dish_sku_abc` |
|
||
|
||
#### 1.2 SKU/菜品分析改造(第2批,9个对象)
|
||
|
||
| 原始对象名 | 新函数名 | 新通用视图名 |
|
||
|---|---|---|
|
||
| `v_dish_sku_abc_april` | `fn_dish_sku_abc(p_month)` | `v_dish_sku_abc` |
|
||
| `dish_store_summary_april` | `fn_dish_store_summary(p_month)` | `v_dish_store_summary` |
|
||
| `dish_basket_april` | `fn_dish_basket(p_month)` | `v_dish_basket` |
|
||
| `dish_category_summary_april` | `fn_dish_category_summary(p_month)` | `v_dish_category_summary` |
|
||
| `dish_member_sku_april` | `fn_dish_member_sku(p_month)` | `v_dish_member_sku` |
|
||
| `dish_pair_summary_april` | `fn_dish_pair_summary(p_month)` | `v_dish_pair_summary` |
|
||
| `dish_store_sku_april` | `fn_dish_store_sku(p_month)` | `v_dish_store_sku` |
|
||
| `dish_sales_april` | `fn_dish_sales(p_month)` | `v_dish_sales` |
|
||
| `dish_sku_summary_april` | `fn_dish_sku_summary(p_month)` | `v_dish_sku_summary` |
|
||
|
||
#### 1.3 选址/空间分析改造(第3批,14个对象,含v_/mv_双版本)
|
||
|
||
| 原始对象名 | 新函数名 | 新通用视图名 |
|
||
|---|---|---|
|
||
| `v_store_site_profile_april` | `fn_store_site_profile(p_month)` | `v_store_site_profile` |
|
||
| `mv_store_site_profile_april` | 物化视图按月刷新 | `v_store_site_profile` |
|
||
| `v_store_site_replication_score_april` | `fn_store_site_replication(p_month)` | `v_store_site_replication` |
|
||
| `mv_store_site_replication_score_april` | 物化视图按月刷新 | 同上 |
|
||
| `v_store_location_overlap_risk_april` | `fn_store_overlap_risk(p_month)` | `v_store_overlap_risk` |
|
||
| `mv_store_location_overlap_risk_april` | 物化视图按月刷新 | 同上 |
|
||
| `v_store_nearest_neighbor_april` | `fn_store_nearest_neighbor(p_month)` | `v_store_nearest_neighbor` |
|
||
| `v_store_spatial_pairs_april` | `fn_store_spatial_pairs(p_month)` | `v_store_spatial_pairs` |
|
||
| `v_dish_member_repeat_april` | `fn_dish_member_repeat(p_month)` | `v_dish_member_repeat` |
|
||
| `v_store_theoretical_actual_cost_april` | 与 `mv_` 版本统一为 `fn_store_cost_comparison` | 同上 |
|
||
| `v_district_site_benchmark_april` | `fn_district_site_benchmark(p_month)` | `v_district_site_benchmark` |
|
||
| `v_site_segment_benchmark_april` | `fn_site_segment_benchmark(p_month)` | `v_site_segment_benchmark` |
|
||
| `mv_site_segment_benchmark_april` | 与 `v_` 版本统一 | 同上 |
|
||
| `mv_district_site_benchmark_april` | 与 `v_` 版本统一为 `fn_district_site_benchmark` | 同上 |
|
||
|
||
#### 1.4 `mv_store_risk_rating` 改造
|
||
|
||
当前 `mv_store_risk_rating` 无月份字段,只反映4月数据。需改为按月生成:
|
||
|
||
```sql
|
||
-- 方案:改为参数化函数
|
||
CREATE OR REPLACE FUNCTION analytics.fn_store_risk_rating(
|
||
p_month DATE DEFAULT '2026-04-01'
|
||
) RETURNS TABLE (...) AS $$
|
||
SELECT s.store_code, s.store_name, ...
|
||
FROM analytics.v_store_daily s
|
||
WHERE s.business_date >= p_month AND s.business_date < (p_month + INTERVAL '1 month')
|
||
GROUP BY s.store_code, s.store_name
|
||
$$ LANGUAGE SQL STABLE;
|
||
|
||
-- 保留物化视图用于4月(兼容期)
|
||
-- 新建通用视图
|
||
CREATE OR REPLACE VIEW analytics.v_store_risk_rating AS
|
||
SELECT * FROM analytics.fn_store_risk_rating('2026-04-01');
|
||
```
|
||
|
||
### 第2层:后端 API — 统一月度参数
|
||
|
||
#### 2.1 改造 `parseMonth` 函数
|
||
|
||
```typescript
|
||
// server/src/middleware/error.ts
|
||
export function parseMonth(req: Request): string {
|
||
const month = (req.query.month as string) || '2026-04'
|
||
return month.length === 7 ? `${month}-01` : month
|
||
}
|
||
|
||
// 新增:解析日期范围
|
||
export interface DateRange {
|
||
start: string // ISO date 'YYYY-MM-DD'
|
||
end: string // ISO date 'YYYY-MM-DD'(半开区间,不含end)
|
||
mode: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
|
||
label: string // 显示用标签
|
||
}
|
||
|
||
export function parseDateRange(req: Request): DateRange {
|
||
const mode = (req.query.range_mode as string) || 'month'
|
||
const month = parseMonth(req)
|
||
const startDate = new Date(month)
|
||
|
||
const addMonths = (d: Date, n: number): string => {
|
||
const r = new Date(d)
|
||
r.setMonth(r.getMonth() + n)
|
||
return r.toISOString().slice(0, 10)
|
||
}
|
||
|
||
switch (mode) {
|
||
case 'quarter':
|
||
return { start: month, end: addMonths(startDate, 3), mode, label: '季度' }
|
||
case 'halfyear':
|
||
return { start: month, end: addMonths(startDate, 6), mode, label: '半年' }
|
||
case 'year':
|
||
return { start: month, end: addMonths(startDate, 12), mode, label: '全年' }
|
||
case 'custom': {
|
||
const start = (req.query.start_date as string) || month
|
||
const end = (req.query.end_date as string) || addMonths(new Date(start), 1)
|
||
return { start, end, mode, label: '自定义' }
|
||
}
|
||
default:
|
||
return { start: month, end: addMonths(startDate, 1), mode, label: '月度' }
|
||
}
|
||
}
|
||
|
||
// 新增:计算同比月份
|
||
export function prevYearMonth(month: string): string {
|
||
const d = new Date(month)
|
||
d.setFullYear(d.getFullYear() - 1)
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
// 新增:计算环比月份
|
||
export function prevMonth(month: string): string {
|
||
const d = new Date(month)
|
||
d.setMonth(d.getMonth() - 1)
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
```
|
||
|
||
#### 2.2 改造现有接口(去硬编码 `DATE '2026-04-01'`)
|
||
|
||
**模式:将所有 `DATE '2026-04-01'` 替换为 `$N::date` 参数**
|
||
|
||
```typescript
|
||
// Before
|
||
router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
|
||
const result = await query(`
|
||
...WHERE e.report_month = DATE '2026-04-01'
|
||
`)
|
||
})
|
||
|
||
// After
|
||
router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
|
||
const month = parseMonth(req)
|
||
const result = await query(`
|
||
...WHERE e.report_month = $1::date
|
||
`, [month])
|
||
})
|
||
```
|
||
|
||
**改造清单(45处):**
|
||
|
||
| 文件 | 接口 | 改造方式 |
|
||
|---|---|---|
|
||
| `store-expense.ts:36` | `/store-expense/overview` | `e.report_month = $1::date` |
|
||
| `store-expense.ts:88` | `/store-expense/expense-structure` | `report_month = $1::date` |
|
||
| `store-expense.ts:107` | `/store-expense/store-ranking` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:161` | `/store-expense/store-contribution` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:211` | `/store-expense/rent-risk` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:276` | `/store-expense/delivery-commission` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:330` | `/store-expense/efficiency` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:386` | `/store-expense/fixed-variable` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:436` | `/store-expense/break-even` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:499` | `/store-expense/loss-diagnosis` | `WHERE report_month = $1::date` |
|
||
| `store-expense.ts:725` | `/store-expense/store-evaluation` | `WHERE report_month = $1::date` |
|
||
| `data.ts:530` | `/overview/profit-waterfall` | `e.report_month = $1::date` |
|
||
| `data.ts:568` | `/overview/store-profit-ranking` | `e.report_month = $1::date` |
|
||
| `data.ts:589` | `/overview/profit-opportunity` | `e.report_month = $1::date`(3处 DATE) + 2处 `v_dish_sku_abc_april` 表名 |
|
||
| `data.ts:890-909` | `/bank/report` | 4处 `DATE '2026-04-01'` + 1处 `DATE '2026-05-01'` → `$N::date` |
|
||
| `data.ts:117` | `/stores/:code` | 表名 → `fn_store_action_priority($1)` |
|
||
| `data.ts:176` | `/cost/comparison` | 表名 → `fn_store_cost_comparison($1)` |
|
||
| `data.ts:185` | `/cost/category-benchmark` | 表名 → `fn_store_category_cost_benchmark($1)` |
|
||
| `data.ts:194` | `/cost/inventory` | 表名 → `fn_store_inventory_efficiency($1)` |
|
||
| `data.ts:230` | `/sku/abc` | 表名 → `fn_dish_sku_abc($1)` |
|
||
| `data.ts:248` | `/sku/attach` | 表名 → `fn_dish_pair_summary($1)` |
|
||
| `data.ts:364` | `/data-quality` | 表名 → `fn_inventory_cost_classified($1)` |
|
||
| `data.ts:406-420` | `/stores/:code/cost` | 表名 → `fn_store_cost_comparison($1)` + `fn_inventory_cost_classified($1)`(3处) |
|
||
| `data.ts:474-506` | `/site-selection/*` | 5个表名 → 对应函数(含 `district-benchmark`) |
|
||
| `cost-analysis.ts:736-791` | `/cost-analysis/store-*` | 4处表名 → `fn_store_cost_comparison($1)` |
|
||
| `situational-awareness.ts:23,296` | `/situational-awareness/health-score`, `/correlation` | 2处表名 → `fn_store_cost_comparison($1)` |
|
||
| `tasks.ts:622,629` | `/tasks/monthly-review/sku-governance` | 2处表名 → `fn_dish_sku_abc($1)` |
|
||
|
||
#### 2.3 新增同比/环比接口
|
||
|
||
```typescript
|
||
// 同比:当前月 vs 去年同月
|
||
router.get('/overview/yoy', async (req: AuthRequest, res) => {
|
||
try {
|
||
const month = parseMonth(req)
|
||
const prevYear = prevYearMonth(month)
|
||
const [current, previous] = await Promise.all([
|
||
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [month]),
|
||
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [prevYear])
|
||
])
|
||
const c = current.rows[0] as any
|
||
const p = previous.rows[0] as any
|
||
if (!c) { sendSuccess(res, null); return }
|
||
const calcChange = (cur: number, prev: number | null) => {
|
||
if (prev === null || prev === 0) return null
|
||
return round(((cur - prev) / Math.abs(prev)) * 100, 2)
|
||
}
|
||
sendSuccess(res, {
|
||
current: c,
|
||
previous: p,
|
||
yoy: p ? {
|
||
received_change_pct: calcChange(parseFloat(c.received), parseFloat(p.received)),
|
||
bill_count_change_pct: calcChange(parseInt(c.bill_count), parseInt(p.bill_count)),
|
||
avg_bill_value_change_pct: calcChange(parseFloat(c.avg_bill_value), parseFloat(p.avg_bill_value)),
|
||
discount_rate_change: p ? round(parseFloat(c.discount_rate_pct) - parseFloat(p.discount_rate_pct), 2) : null,
|
||
} : null
|
||
})
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
|
||
// 环比:当前月 vs 上月
|
||
router.get('/overview/mom', async (req: AuthRequest, res) => {
|
||
try {
|
||
const month = parseMonth(req)
|
||
const prevMo = prevMonth(month)
|
||
// 同上结构,prevYear → prevMo
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
|
||
// 月度趋势
|
||
router.get('/overview/trend', async (req: AuthRequest, res) => {
|
||
try {
|
||
const endMonth = parseMonth(req)
|
||
const months = parseInt(req.query.months as string) || 12
|
||
const startDate = new Date(endMonth)
|
||
startDate.setMonth(startDate.getMonth() - months + 1)
|
||
const startMonth = startDate.toISOString().slice(0, 10)
|
||
|
||
const result = await query(`
|
||
SELECT month, bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct
|
||
FROM analytics.mv_overview_monthly
|
||
WHERE month >= $1::date AND month <= $2::date
|
||
ORDER BY month
|
||
`, [startMonth, endMonth])
|
||
sendSuccess(res, result.rows)
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
|
||
// 利润趋势(多月瀑布汇总)
|
||
router.get('/overview/profit-trend', async (req: AuthRequest, res) => {
|
||
try {
|
||
const endMonth = parseMonth(req)
|
||
const months = parseInt(req.query.months as string) || 6
|
||
const startDate = new Date(endMonth)
|
||
startDate.setMonth(startDate.getMonth() - months + 1)
|
||
const startMonth = startDate.toISOString().slice(0, 10)
|
||
|
||
// 逐月查询利润瀑布数据
|
||
const result = await query(`
|
||
WITH monthly AS (
|
||
SELECT e.report_month,
|
||
round(sum(r.received)::numeric, 2) AS received,
|
||
round(sum(e.actual_food_cost)::numeric, 2) AS food_cost,
|
||
round(sum(e.wage_expense)::numeric, 2) AS wage,
|
||
round(sum(e.rent_expense)::numeric, 2) AS rent,
|
||
round(sum(e.utility_expense)::numeric, 2) AS utility,
|
||
round(sum(e.operating_expense)::numeric, 2) AS total_expense,
|
||
round(sum(r.received) - sum(e.actual_food_cost) - sum(e.operating_expense), 2) AS store_contribution
|
||
FROM analytics.mv_store_risk_rating r
|
||
JOIN analytics.mv_store_operating_expense_monthly e
|
||
ON r.store_code = e.sales_store_code
|
||
WHERE e.report_month >= $1::date AND e.report_month <= $2::date
|
||
AND e.operating_expense IS NOT NULL AND r.received > 0
|
||
GROUP BY e.report_month
|
||
ORDER BY e.report_month
|
||
)
|
||
SELECT *,
|
||
round(store_contribution / nullif(received, 0) * 100, 2) AS contribution_margin_pct
|
||
FROM monthly
|
||
`, [startMonth, endMonth])
|
||
sendSuccess(res, result.rows)
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
|
||
// 时间段汇总
|
||
router.get('/overview/period', async (req: AuthRequest, res) => {
|
||
try {
|
||
const { start, end, mode, label } = parseDateRange(req)
|
||
const result = await query(`
|
||
SELECT
|
||
sum(bill_count)::bigint AS bill_count,
|
||
round(sum(received)::numeric, 2) AS received,
|
||
round(sum(discounts)::numeric, 2) AS discounts,
|
||
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
|
||
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
|
||
round(sum(guests)::numeric, 0) AS guests
|
||
FROM analytics.v_store_daily
|
||
WHERE business_date >= $1::date AND business_date < $2::date
|
||
`, [start, end])
|
||
sendSuccess(res, { ...result.rows[0], range_mode: mode, range_label: label, start_date: start, end_date: end })
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
```
|
||
|
||
#### 2.4 利润瀑布/利润机会池支持时间段汇总
|
||
|
||
```typescript
|
||
// 利润瀑布 — 支持月度和时间段两种模式
|
||
router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
|
||
try {
|
||
const rangeMode = (req.query.range_mode as string) || 'month'
|
||
if (rangeMode === 'month') {
|
||
// 原有逻辑,参数化
|
||
const month = parseMonth(req)
|
||
const result = await query(`
|
||
WITH full_scope AS (
|
||
SELECT r.received, e.actual_food_cost AS food_cost, ...
|
||
FROM analytics.fn_store_risk_rating($1::date) r
|
||
LEFT JOIN analytics.mv_store_operating_expense_monthly e
|
||
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
|
||
WHERE r.received IS NOT NULL AND e.operating_expense IS NOT NULL
|
||
)
|
||
SELECT round(sum(received)::numeric,2) AS received, ...
|
||
FROM full_scope
|
||
`, [month])
|
||
sendSuccess(res, result.rows[0])
|
||
} else {
|
||
// 时间段汇总:多月费用累加
|
||
const { start, end } = parseDateRange(req)
|
||
const result = await query(`
|
||
WITH full_scope AS (
|
||
SELECT e.report_month, r.received, e.actual_food_cost AS food_cost, ...
|
||
FROM analytics.v_store_daily r
|
||
JOIN analytics.mv_store_operating_expense_monthly e
|
||
ON r.store_code = e.sales_store_code
|
||
WHERE e.report_month >= $1::date AND e.report_month < $2::date
|
||
AND e.operating_expense IS NOT NULL
|
||
GROUP BY e.report_month, r.store_code
|
||
)
|
||
SELECT round(sum(received)::numeric,2) AS received, ...
|
||
FROM full_scope
|
||
`, [start, end])
|
||
sendSuccess(res, result.rows[0])
|
||
}
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
```
|
||
|
||
### 第3层:前端 — 月份选择器 + 时间范围切换
|
||
|
||
#### 3.1 全局月份选择器组件
|
||
|
||
```tsx
|
||
// client/src/components/MonthPicker.tsx
|
||
import { useState, useMemo } from 'react'
|
||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react'
|
||
|
||
interface MonthPickerProps {
|
||
value: string // 'YYYY-MM' 格式
|
||
onChange: (month: string) => void
|
||
minMonth?: string // 数据最早月份 'YYYY-MM'
|
||
maxMonth?: string // 数据最晚月份 'YYYY-MM'
|
||
}
|
||
|
||
export function MonthPicker({ value, onChange, minMonth = '2026-04', maxMonth }: MonthPickerProps) {
|
||
const [open, setOpen] = useState(false)
|
||
const [year, setYear] = useState(parseInt(value.slice(0, 4)))
|
||
|
||
const max = maxMonth || new Date().toISOString().slice(0, 7)
|
||
const months = useMemo(() => {
|
||
const arr: { label: string; value: string; disabled: boolean }[] = []
|
||
for (let m = 1; m <= 12; m++) {
|
||
const v = `${year}-${String(m).padStart(2, '0')}`
|
||
arr.push({
|
||
label: `${m}月`,
|
||
value: v,
|
||
disabled: v < minMonth || v > max
|
||
})
|
||
}
|
||
return arr
|
||
}, [year, minMonth, max])
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setOpen(!open)}
|
||
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm"
|
||
>
|
||
<Calendar className="h-4 w-4" />
|
||
<span>{value.slice(0, 4)}年{parseInt(value.slice(5, 7))}月</span>
|
||
<ChevronRight className="h-3 w-3" />
|
||
</button>
|
||
{open && (
|
||
<div className="absolute z-50 mt-1 rounded-lg border bg-background p-3 shadow-lg">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<button onClick={() => setYear(year - 1)}><ChevronLeft className="h-4 w-4" /></button>
|
||
<span className="font-medium">{year}年</span>
|
||
<button onClick={() => setYear(year + 1)}><ChevronRight className="h-4 w-4" /></button>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1">
|
||
{months.map(m => (
|
||
<button
|
||
key={m.value}
|
||
disabled={m.disabled}
|
||
onClick={() => { onChange(m.value); setOpen(false) }}
|
||
className={`rounded px-2 py-1 text-sm ${
|
||
m.value === value ? 'bg-primary text-primary-foreground' :
|
||
m.disabled ? 'text-muted-foreground/50 cursor-not-allowed' :
|
||
'hover:bg-accent'
|
||
}`}
|
||
>
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 3.2 时间范围切换器
|
||
|
||
```tsx
|
||
// client/src/components/RangeModeSwitch.tsx
|
||
type RangeMode = 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
|
||
|
||
interface RangeModeSwitchProps {
|
||
value: RangeMode
|
||
onChange: (mode: RangeMode) => void
|
||
}
|
||
|
||
export function RangeModeSwitch({ value, onChange }: RangeModeSwitchProps) {
|
||
const modes: { key: RangeMode; label: string }[] = [
|
||
{ key: 'month', label: '月度' },
|
||
{ key: 'quarter', label: '季度' },
|
||
{ key: 'halfyear', label: '半年' },
|
||
{ key: 'year', label: '全年' },
|
||
]
|
||
return (
|
||
<div className="flex rounded-lg border p-0.5">
|
||
{modes.map(m => (
|
||
<button
|
||
key={m.key}
|
||
onClick={() => onChange(m.key)}
|
||
className={`rounded px-3 py-1 text-xs font-medium ${
|
||
value === m.key ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
|
||
}`}
|
||
>
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 3.3 同比/环比展示组件
|
||
|
||
```tsx
|
||
// client/src/components/ComparisonBadge.tsx
|
||
interface ComparisonBadgeProps {
|
||
changePct: number | null
|
||
label: string // '同比' or '环比'
|
||
invertColors?: boolean // true: 下降为好(如成本率)
|
||
}
|
||
|
||
export function ComparisonBadge({ changePct, label, invertColors = false }: ComparisonBadgeProps) {
|
||
if (changePct === null) return <span className="text-xs text-muted-foreground">{label}: —</span>
|
||
const isUp = changePct > 0
|
||
const isGood = invertColors ? !isUp : isUp
|
||
return (
|
||
<span className={`text-xs ${isGood ? 'text-green-600' : 'text-red-600'}`}>
|
||
{label} {isUp ? '↑' : '↓'} {Math.abs(changePct).toFixed(1)}%
|
||
</span>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 3.4 指标卡集成同比环比
|
||
|
||
```tsx
|
||
// 在 MetricCard 中增加 yoy/mom 展示
|
||
interface MetricCardProps {
|
||
title: string
|
||
value: number | string
|
||
...
|
||
yoyChange?: number | null // 同比变化率
|
||
momChange?: number | null // 环比变化率
|
||
invertColors?: boolean
|
||
}
|
||
|
||
// 渲染时在值下方显示
|
||
{yoyChange !== undefined && <ComparisonBadge changePct={yoyChange} label="同比" invertColors={invertColors} />}
|
||
{momChange !== undefined && <ComparisonBadge changePct={momChange} label="环比" invertColors={invertColors} />}
|
||
```
|
||
|
||
#### 3.5 趋势图组件
|
||
|
||
```tsx
|
||
// client/src/components/MonthlyTrendChart.tsx
|
||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||
|
||
interface MonthlyTrendChartProps {
|
||
data: Array<{ month: string; received: number; store_contribution: number; contribution_margin_pct: number }>
|
||
}
|
||
|
||
export function MonthlyTrendChart({ data }: MonthlyTrendChartProps) {
|
||
return (
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<LineChart data={data}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="month" tickFormatter={m => m.slice(5, 7) + '月'} />
|
||
<YAxis yAxisId="left" />
|
||
<YAxis yAxisId="right" orientation="right" unit="%" />
|
||
<Tooltip labelFormatter={m => m} />
|
||
<Legend />
|
||
<Line yAxisId="left" dataKey="received" name="实收" stroke="#3b82f6" />
|
||
<Line yAxisId="left" dataKey="store_contribution" name="门店贡献利润" stroke="#22c55e" />
|
||
<Line yAxisId="right" dataKey="contribution_margin_pct" name="贡献率" stroke="#a855f7" unit="%" />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 3.6 React Query 参数联动
|
||
|
||
```tsx
|
||
// 在每个页面中,将 month 和 rangeMode 加入 queryKey
|
||
const { data } = useQuery({
|
||
queryKey: ['overview', month, rangeMode],
|
||
queryFn: () => api.get('/overview', { params: { month, range_mode: rangeMode } }),
|
||
enabled: !!month,
|
||
})
|
||
|
||
// 利润瀑布
|
||
const { data: waterfall } = useQuery({
|
||
queryKey: ['profit-waterfall', month, rangeMode],
|
||
queryFn: () => api.get('/overview/profit-waterfall', { params: { month, range_mode: rangeMode } }),
|
||
})
|
||
|
||
// 同比环比
|
||
const { data: yoy } = useQuery({
|
||
queryKey: ['overview-yoy', month],
|
||
queryFn: () => api.get('/overview/yoy', { params: { month } }),
|
||
})
|
||
|
||
// 趋势
|
||
const { data: trend } = useQuery({
|
||
queryKey: ['overview-trend', month, trendMonths],
|
||
queryFn: () => api.get('/overview/trend', { params: { month, months: trendMonths } }),
|
||
})
|
||
```
|
||
|
||
#### 3.7 页面布局调整
|
||
|
||
在以下页面顶部统一添加:
|
||
|
||
| 页面 | 调用的硬编码API |
|
||
|---|---|
|
||
| BossPage | `/overview/profit-waterfall`, `/overview/profit-opportunity`, `/store-expense/overview`, `/cost/comparison` |
|
||
| RevenuePage | `/revenue/*`(已参数化,需加选择器统一体验) |
|
||
| CostPage | `/cost/comparison`(`mv_store_theoretical_actual_cost_april`) |
|
||
| StoreExpensePage | `/store-expense/*`(11处 `DATE '2026-04-01'`,含 `/overview`, `/expense-structure`, `/store-ranking`, `/store-contribution`, `/rent-risk`, `/delivery-commission`, `/efficiency`, `/fixed-variable`, `/break-even`, `/loss-diagnosis`, `/store-evaluation`) |
|
||
| DataQualityPage | `/data-quality`(`v_inventory_cost_classified_april`) |
|
||
| CostAnalysisPage | `/cost-analysis/store-overview`, `/cost-analysis/store-ranking`(`mv_store_theoretical_actual_cost_april`) |
|
||
| SiteSelectionPage | `/site-selection/*`(5个 `_april` 表名) |
|
||
| SituationalAwarenessPage | `/situational-awareness/health-score`, `/correlation`(`mv_store_theoretical_actual_cost_april`) |
|
||
| BankPage | `/bank/report`(5处 `DATE '2026-04-01'`) |
|
||
| DashboardPage | `/store-expense/overview`(`DATE '2026-04-01'`) |
|
||
| SKUPage | `/sku/abc`(`v_dish_sku_abc_april`) |
|
||
| StoreDetailPage | `/stores/:code`, `/stores/:code/cost`(多个 `_april` 表名)+ 前端硬编码 `month: '2026-05'` |
|
||
| StorePage | `/stores/:code/daily`(前端硬编码 `month: '2026-04'`)+ `/tasks`(前端硬编码 `month: '2026-05'`) |
|
||
| MonthlyReviewPage | `/tasks/monthly-review/sku-governance`(`v_dish_sku_abc_april`)+ 前端硬编码 `month: '2026-05'` |
|
||
| RegionalPage | `/stores/risk`(`mv_store_risk_rating` 无月份字段)+ 前端硬编码 `month: '2026-05'` |
|
||
| TasksPage | 前端硬编码 `month: '2026-05'` |
|
||
| BomPenetrationPage | 前端硬编码 `month: '2026-04'` |
|
||
| CentralKitchenPage | 前端硬编码 `month: '2026-04'` |
|
||
| DistributionReconciliationPage | 前端硬编码 `month: '2026-04'` |
|
||
| ProductionPlanPage | 前端硬编码 `month: '2026-04'` |
|
||
|
||
```tsx
|
||
// 页面顶部控制栏
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xl font-bold">总部驾驶舱</h2>
|
||
<div className="flex items-center gap-3">
|
||
<MonthPicker value={month} onChange={setMonth} maxMonth={maxMonth} />
|
||
<RangeModeSwitch value={rangeMode} onChange={setRangeMode} />
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### 第4层:数据管道 — 多月数据导入
|
||
|
||
#### 4.1 数据导入脚本参数化
|
||
|
||
```python
|
||
# db/import_salary_attendance.py
|
||
# Before
|
||
report_month = '2026-04-01'
|
||
|
||
# After
|
||
import sys
|
||
report_month = sys.argv[1] if len(sys.argv) > 1 else '2026-04-01'
|
||
# 或从文件名自动提取月份
|
||
```
|
||
|
||
```sql
|
||
-- sql/etl_fact_inventory.sql
|
||
-- Before
|
||
WHERE i.report_month = DATE '2026-04-01'
|
||
|
||
-- After(使用 psql 变量)
|
||
WHERE i.report_month = :'report_month'::date
|
||
-- 执行:psql -v report_month='2026-04-01' -f etl_fact_inventory.sql
|
||
```
|
||
|
||
#### 4.2 物化视图按月刷新策略
|
||
|
||
```sql
|
||
-- 刷新指定月份的物化视图
|
||
-- 方案1:物化视图不按月分区,REFRESH 全量刷新
|
||
REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.mv_store_operating_expense_monthly;
|
||
|
||
-- 方案2(推荐):将物化视图改为按月分区表
|
||
-- 每月导入后只刷新该月分区
|
||
```
|
||
|
||
#### 4.3 可用月份查询接口
|
||
|
||
```typescript
|
||
// 新增:返回系统中有数据的月份列表
|
||
router.get('/meta/available-months', async (req: AuthRequest, res) => {
|
||
try {
|
||
const result = await query(`
|
||
SELECT DISTINCT report_month AS month
|
||
FROM analytics.mv_store_operating_expense_monthly
|
||
WHERE operating_expense IS NOT NULL
|
||
UNION
|
||
SELECT DISTINCT date_trunc('month', closed_at)::date
|
||
FROM analytics.bill_fact
|
||
WHERE closed_at IS NOT NULL
|
||
ORDER BY month DESC
|
||
`)
|
||
sendSuccess(res, result.rows.map(r => r.month))
|
||
} catch (err: any) { sendError(res, err.message) }
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 四、实施路线图
|
||
|
||
| 阶段 | 时间 | 内容 | 优先级 | 涉及文件 |
|
||
|---|---|---|:---:|---|
|
||
| **Phase 1** | 1-2天 | 后端 API 去硬编码 `DATE '2026-04-01'`,统一用 `parseMonth()` | P0 | `store-expense.ts`, `data.ts`, `cost-analysis.ts`, `situational-awareness.ts`, `tasks.ts` |
|
||
| **Phase 2** | 2-3天 | 数据库视图去 `_april` 后缀,改为参数化函数(第1批:核心利润链9个) | P0 | 新建 SQL 迁移脚本 |
|
||
| **Phase 3** | 1-2天 | 新增同比/环比/趋势/时间段汇总接口 | P1 | `data.ts` 新增接口 |
|
||
| **Phase 4** | 2-3天 | 前端月份选择器 + 范围切换 + 同比环比展示 + 趋势图 | P1 | 新建组件 + 改造各页面 |
|
||
| **Phase 5** | 1-2天 | 数据库视图参数化(第2批:SKU/菜品9个) | P2 | 新建 SQL 迁移脚本 |
|
||
| **Phase 6** | 1-2天 | 数据库视图参数化(第3批:选址/空间14个) | P2 | 新建 SQL 迁移脚本 |
|
||
| **Phase 7** | 1天 | 数据导入脚本参数化 + 多月数据导入测试 | P2 | `import_salary_attendance.py`, `etl_fact_inventory.sql` |
|
||
| **Phase 8** | 1天 | `mv_store_risk_rating` 改为参数化函数 | P2 | SQL 迁移脚本 + 相关 API |
|
||
|
||
**总工期:约 10-16 天**
|
||
|
||
---
|
||
|
||
## 五、风险评估与注意事项
|
||
|
||
### 1. 数据覆盖
|
||
|
||
- 当前只有2026年4月数据,同比/环比功能需要至少2个月数据才能展示
|
||
- 需先导入多月数据(至少5月数据)才能验证环比功能
|
||
- 同比需要2025年数据,当前完全没有
|
||
|
||
### 2. 物化视图刷新
|
||
|
||
- 参数化后物化视图无法直接带参数,需改为函数或按月刷新策略
|
||
- `REFRESH MATERIALIZED VIEW` 是全量刷新,数据量大时耗时较长
|
||
- 建议采用 `CONCURRENTLY` 选项避免锁表
|
||
|
||
### 3. 性能
|
||
|
||
- 时间段汇总查询(如全年)可能较慢,需考虑:
|
||
- 预聚合表:按月汇总后再按时间段聚合
|
||
- 查询缓存:Redis 或前端 React Query 缓存
|
||
- 分页加载:趋势图先加载近6月,可展开查看更多
|
||
|
||
### 4. 前端兼容
|
||
|
||
- 改造期间需保持4月数据正常展示
|
||
- `MonthPicker` 默认值为 `2026-04`
|
||
- API 参数 `month` 默认值保持 `2026-04`,确保旧请求不报错
|
||
|
||
### 5. API 向后兼容
|
||
|
||
- 所有改造接口保持向后兼容:不传 `month` 参数时默认返回4月数据
|
||
- 新增接口(同比/环比/趋势)为独立路径,不影响现有接口
|
||
- 表名替换为函数调用时,保留旧视图作为别名(过渡期)
|
||
|
||
### 6. 测试策略
|
||
|
||
- 每个 Phase 完成后需验证:
|
||
- 不传 `month` 参数时,4月数据与改造前完全一致
|
||
- 传 `month=2026-04` 时,结果与不传参数一致
|
||
- 传 `month=2026-05` 时(如有数据),返回5月数据
|
||
- 回归测试:所有页面的4月数据展示不受影响
|
||
|
||
---
|
||
|
||
## 六、完整性检查清单
|
||
|
||
### 数据库层
|
||
|
||
- [ ] 32个 `_april` 视图/表全部改为参数化函数或通用视图(9物化视图 + 17普通视图 + 6基表)
|
||
- [ ] `mv_store_risk_rating` 改为支持月份参数
|
||
- [ ] 新建 `fn_store_cost_comparison(p_month)` 函数
|
||
- [ ] 新建 `fn_inventory_cost_classified(p_month)` 函数
|
||
- [ ] 新建 `fn_dish_sku_abc(p_month)` 函数
|
||
- [ ] 新建 `fn_store_action_priority(p_month)` 函数
|
||
- [ ] 新建 `fn_store_site_profile(p_month)` 函数
|
||
- [ ] 新建 `fn_store_overlap_risk(p_month)` 函数
|
||
- [ ] 其余19个函数按3批计划完成(总计25个唯一函数,6个已显式列出)
|
||
- [ ] 保留旧视图作为别名(过渡期兼容)
|
||
|
||
### 后端 API 层
|
||
|
||
- [ ] `parseMonth()` 函数保留默认值 `2026-04`
|
||
- [ ] 新增 `parseDateRange()` 函数
|
||
- [ ] 新增 `prevYearMonth()` 函数
|
||
- [ ] 新增 `prevMonth()` 函数
|
||
- [ ] `store-expense.ts` 11处硬编码改为参数化
|
||
- [ ] `data.ts` 26处硬编码改为参数化(9处 DATE + 17处 _april表名)
|
||
- [ ] `cost-analysis.ts` 4处硬编码改为参数化
|
||
- [ ] `situational-awareness.ts` 2处硬编码改为参数化
|
||
- [ ] `tasks.ts` 2处硬编码改为参数化
|
||
- [ ] 新增 `/overview/yoy` 接口
|
||
- [ ] 新增 `/overview/mom` 接口
|
||
- [ ] 新增 `/overview/trend` 接口
|
||
- [ ] 新增 `/overview/period` 接口
|
||
- [ ] 新增 `/overview/profit-trend` 接口
|
||
- [ ] 新增 `/meta/available-months` 接口
|
||
- [ ] 利润瀑布接口支持 `range_mode` 参数
|
||
- [ ] 利润机会池接口支持 `month` 参数
|
||
- [ ] 银行报告接口支持 `month` 参数
|
||
|
||
### 前端层
|
||
|
||
- [ ] 新建 `MonthPicker` 组件
|
||
- [ ] 新建 `RangeModeSwitch` 组件
|
||
- [ ] 新建 `ComparisonBadge` 组件
|
||
- [ ] 新建 `MonthlyTrendChart` 组件
|
||
- [ ] `MetricCard` 增加同比/环比展示
|
||
- [ ] BossPage 集成月份选择器和范围切换
|
||
- [ ] RevenuePage 集成月份选择器
|
||
- [ ] CostPage 集成月份选择器
|
||
- [ ] StoreExpensePage 集成月份选择器
|
||
- [ ] DataQualityPage 集成月份选择器
|
||
- [ ] CostAnalysisPage 集成月份选择器
|
||
- [ ] SiteSelectionPage 集成月份选择器
|
||
- [ ] SituationalAwarenessPage 集成月份选择器
|
||
- [ ] BankPage 集成月份选择器
|
||
- [ ] DashboardPage 集成月份选择器
|
||
- [ ] SKUPage 集成月份选择器
|
||
- [ ] StoreDetailPage 集成月份选择器(含去除前端硬编码 `month: '2026-05'`)
|
||
- [ ] StorePage 集成月份选择器(含去除前端硬编码 `month: '2026-04'` 和 `'2026-05'`)
|
||
- [ ] MonthlyReviewPage 集成月份选择器(含去除前端硬编码 `month: '2026-05'`)
|
||
- [ ] RegionalPage 集成月份选择器(含去除前端硬编码 `month: '2026-05'`)
|
||
- [ ] TasksPage 集成月份选择器(含去除前端硬编码 `month: '2026-05'`)
|
||
- [ ] BomPenetrationPage 集成月份选择器(含去除前端硬编码 `month: '2026-04'`)
|
||
- [ ] CentralKitchenPage 集成月份选择器(含去除前端硬编码 `month: '2026-04'`)
|
||
- [ ] DistributionReconciliationPage 集成月份选择器(含去除前端硬编码 `month: '2026-04'`)
|
||
- [ ] ProductionPlanPage 集成月份选择器(含去除前端硬编码 `month: '2026-04'`)
|
||
- [ ] 所有 `useQuery` 的 `queryKey` 加入 `month` 和 `rangeMode`
|
||
- [ ] 趋势图在 BossPage 展示
|
||
|
||
### 数据管道层
|
||
|
||
- [ ] `import_salary_attendance.py` 参数化 `report_month`
|
||
- [ ] `etl_fact_inventory.sql` 参数化 `report_month`
|
||
- [ ] `ontology_standard.sql` 指标 `effective_date` 评估是否需参数化(元数据,优先级低)
|
||
- [ ] 其他导入脚本参数化(如有)
|
||
- [ ] 物化视图按月刷新策略文档
|
||
|
||
### 测试验证
|
||
|
||
- [ ] 不传 `month` 参数时4月数据与改造前一致
|
||
- [ ] 传 `month=2026-04` 时结果与不传参数一致
|
||
- [ ] 所有页面4月数据展示不受影响(回归测试)
|
||
- [ ] TypeScript 编译无错误(`npx tsc --noEmit`)
|
||
- [ ] 生产环境部署后验证
|
||
|
||
---
|
||
|
||
## 七、预期成果
|
||
|
||
改造完成后系统将具备:
|
||
|
||
1. **任意月份分析**:用户可选择任意有数据的月份进行分析,不再局限于4月
|
||
2. **时间段汇总**:支持季度/半年/全年/自定义区间的汇总分析
|
||
3. **同比环比**:每个核心指标都展示同比和环比变化率,一眼看出趋势
|
||
4. **月度趋势图**:可视化展示多月趋势,支持移动平均和同比叠加
|
||
5. **数据管道参数化**:导入脚本支持任意月份,不再需要修改代码
|
||
6. **向后兼容**:不传参数时默认返回4月数据,现有功能不受影响
|