# 系统升级方案:从"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 (
{open && (
{year}年
{months.map(m => ( ))}
)}
) } ``` #### 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 (
{modes.map(m => ( ))}
) } ``` #### 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 {label}: — const isUp = changePct > 0 const isGood = invertColors ? !isUp : isUp return ( {label} {isUp ? '↑' : '↓'} {Math.abs(changePct).toFixed(1)}% ) } ``` #### 3.4 指标卡集成同比环比 ```tsx // 在 MetricCard 中增加 yoy/mom 展示 interface MetricCardProps { title: string value: number | string ... yoyChange?: number | null // 同比变化率 momChange?: number | null // 环比变化率 invertColors?: boolean } // 渲染时在值下方显示 {yoyChange !== undefined && } {momChange !== undefined && } ``` #### 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 ( m.slice(5, 7) + '月'} /> m} /> ) } ``` #### 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 // 页面顶部控制栏

总部驾驶舱

``` ### 第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月数据,现有功能不受影响