Files
SBrainCO/docs/20260801_系统升级方案_逐月与时间段分析.md
T

1067 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 系统升级方案:从"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月数据,现有功能不受影响