# 系统升级方案:从"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 (