# 系统升级方案：从"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月数据，现有功能不受影响
