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

48 KiB
Raw Permalink Blame History

系统升级方案:从"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_aprilv_store_inventory_efficiency_aprildata.ts 中被引用,但数据库中不存在这两个对象(可能已删除或从未创建),需在改造时排查。 注意: 部分对象有同名 v_(视图)和 mv_(基表)两个版本,如 v_store_site_profile_aprilmv_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.ts11处 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.ts26处: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.ts4处)

行号 接口 硬编码内容
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.ts2处)

行号 接口 硬编码内容
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.ts2处)

行号 接口 硬编码内容
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_attimestamptz 账单事实表,通过 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 函数现状

// 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 → 函数

-- 创建参数化函数
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月数据。需改为按月生成:

-- 方案:改为参数化函数
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 函数

// 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 参数

// 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::date3处 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 新增同比/环比接口

// 同比:当前月 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 利润瀑布/利润机会池支持时间段汇总

// 利润瀑布 — 支持月度和时间段两种模式
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 全局月份选择器组件

// 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 时间范围切换器

// 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 同比/环比展示组件

// 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 指标卡集成同比环比

// 在 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 趋势图组件

// 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 参数联动

// 在每个页面中,将 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/comparisonmv_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-qualityv_inventory_cost_classified_april
CostAnalysisPage /cost-analysis/store-overview, /cost-analysis/store-rankingmv_store_theoretical_actual_cost_april
SiteSelectionPage /site-selection/*5个 _april 表名)
SituationalAwarenessPage /situational-awareness/health-score, /correlationmv_store_theoretical_actual_cost_april
BankPage /bank/report5处 DATE '2026-04-01'
DashboardPage /store-expense/overviewDATE '2026-04-01'
SKUPage /sku/abcv_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-governancev_dish_sku_abc_april+ 前端硬编码 month: '2026-05'
RegionalPage /stores/riskmv_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'
// 页面顶部控制栏
<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 数据导入脚本参数化

# 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/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 物化视图按月刷新策略

-- 刷新指定月份的物化视图
-- 方案1:物化视图不按月分区,REFRESH 全量刷新
REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.mv_store_operating_expense_monthly;

-- 方案2(推荐):将物化视图改为按月分区表
-- 每月导入后只刷新该月分区

4.3 可用月份查询接口

// 新增:返回系统中有数据的月份列表
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'
  • 所有 useQueryqueryKey 加入 monthrangeMode
  • 趋势图在 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月数据,现有功能不受影响