# 需求文档:业务日分割点机制(Business Day Cutoff) ## 简介 引入"业务日分割点"机制,将全系统的统计时间口径从自然日/自然周/自然月切换为以可配置的小时值(默认 08:00)为分割点的营业日/营业周/营业月。影响范围覆盖配置层、共享包、ETL 层(ODS→DWD→DWS)、后端 API 层、前端展示层(管理后台、小程序)及数据库层。 ## 术语表 - **Business_Day_Cutoff**:营业日分割点,一个整数小时值(0–23),定义一个"业务日"的起始时刻。默认值为 8(即 08:00)。 - **Business_Date**:营业日,从当天 `Business_Day_Cutoff` 时刻到次日 `Business_Day_Cutoff` 时刻的时间段所归属的日期。`Business_Day_Cutoff` 之前的时间戳归属前一天。 - **Business_Week**:营业周,从周一 `Business_Day_Cutoff` 到次周一 `Business_Day_Cutoff` 的时间段。 - **Business_Month**:营业月,从当月1日 `Business_Day_Cutoff` 到次月1日 `Business_Day_Cutoff` 的时间段。 - **Shared_DateTime_Utils**:`packages/shared/src/neozqyy_shared/datetime_utils.py`,跨子系统共享的时间工具模块。 - **AppConfig**:ETL 配置管理器(`apps/etl/connectors/feiqiu/config/settings.py`),通过 `AppConfig.load()` 加载配置。 - **Backend_Config**:后端配置模块(`apps/backend/app/config.py`),从 `.env` 加载环境变量。 - **DWS_Task**:DWS 层聚合任务,从 DWD 事实表读取数据并按时间维度聚合写入 DWS 汇总表。 - **biz_date_sql_expr**:`Shared_DateTime_Utils` 中生成 PostgreSQL 营业日归属 SQL 表达式的函数。 - **stat_date**:DWS 汇总表中的统计日期字段,存储的是 Business_Date 而非自然日期。 - **Admin_Web**:管理后台前端(`apps/admin-web/`,React + Vite + Ant Design)。 - **Miniprogram**:微信小程序前端(`apps/miniprogram/`)。 ## 需求 ### 需求 1:环境变量配置 **用户故事:** 作为运维人员,我希望通过 `.env` 环境变量配置营业日分割点小时值,以便在不修改代码的情况下调整统计时间口径。 #### 验收标准 1. THE Root_Env SHALL 定义 `BUSINESS_DAY_START_HOUR` 环境变量,值为 0–23 的整数,默认值为 8 2. THE Env_Template SHALL 同步包含 `BUSINESS_DAY_START_HOUR` 的定义及注释说明(日/周/月统计的分割语义) 3. WHEN `BUSINESS_DAY_START_HOUR` 的值不在 0–23 范围内时, THEN THE AppConfig SHALL 在加载阶段抛出 `SystemExit` 错误并给出明确提示 4. WHEN `BUSINESS_DAY_START_HOUR` 环境变量缺失时, THE AppConfig SHALL 使用默认值 8 ### 需求 2:共享时间工具函数 **用户故事:** 作为开发者,我希望有一组经过充分测试的共享时间工具函数,以便所有子系统使用统一的营业日归属逻辑。 #### 验收标准 1. THE Shared_DateTime_Utils SHALL 提供 `business_date(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Date 2. THE Shared_DateTime_Utils SHALL 提供 `business_month(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Month 首日 3. THE Shared_DateTime_Utils SHALL 提供 `business_week_monday(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Week 的周一日期 4. THE Shared_DateTime_Utils SHALL 提供 `biz_date_sql_expr(col, day_start_hour)` 函数,生成 PostgreSQL 营业日归属 SQL 表达式(形如 `DATE(col - INTERVAL 'N hours')`) 5. WHEN `day_start_hour` 参数未传入时, THE Shared_DateTime_Utils SHALL 使用 `DEFAULT_BUSINESS_DAY_START_HOUR`(值为 8)作为默认值 6. THE Shared_DateTime_Utils SHALL 提供 `business_day_range(biz_date, day_start_hour)` 函数,返回给定 Business_Date 对应的精确时间戳范围 `(start_dt, end_dt)`,即 `(biz_date 当天 day_start_hour:00, biz_date 次日 day_start_hour:00)` 7. THE Shared_DateTime_Utils SHALL 提供 `business_week_range(week_monday, day_start_hour)` 函数,返回给定 Business_Week 周一对应的精确时间戳范围 8. THE Shared_DateTime_Utils SHALL 提供 `business_month_range(month_first, day_start_hour)` 函数,返回给定 Business_Month 首日对应的精确时间戳范围 9. FOR ALL 合法的 datetime 输入, `business_date` 的输出 SHALL 满足:`business_day_range(business_date(dt, h), h)[0] <= dt < business_day_range(business_date(dt, h), h)[1]`(往返一致性) 10. FOR ALL 合法的 datetime 输入, `business_month(dt, h)` SHALL 等于 `business_date(dt, h).replace(day=1)`(月归属与日归属一致性) 11. FOR ALL 合法的 datetime 输入, `business_week_monday(dt, h)` SHALL 等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`(周归属与日归属一致性) ### 需求 3:ETL 配置层集成 **用户故事:** 作为 ETL 开发者,我希望 `AppConfig` 正确加载并传播 `BUSINESS_DAY_START_HOUR`,以便 ETL 任务能获取到配置的分割点值。 #### 验收标准 1. THE AppConfig SHALL 在 `app.business_day_start_hour` 路径下存储 `BUSINESS_DAY_START_HOUR` 的整数值 2. THE Env_Parser SHALL 将环境变量 `BUSINESS_DAY_START_HOUR` 映射到 `app.business_day_start_hour` 配置路径 3. THE AppConfig_Defaults SHALL 将 `app.business_day_start_hour` 的默认值设为 8 4. WHEN AppConfig 加载完成后, THE AppConfig SHALL 通过 `cfg.get("app.business_day_start_hour")` 返回正确的整数值 ### 需求 4:ETL DWS 层聚合逻辑 **用户故事:** 作为数据分析师,我希望 DWS 层的所有日度/周度/月度聚合统计都基于营业日口径,以便统计结果与门店实际营业周期一致。 #### 验收标准 1. WHEN DWS_Task 从 DWD 表提取数据时, THE DWS_Task SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期归属计算 2. WHEN DWS_Task 按日聚合时, THE DWS_Task SHALL 使用 `DATE(timestamp_col - INTERVAL 'N hours')` 作为 `stat_date` 的分组依据,其中 N 为 `Business_Day_Cutoff` 3. WHEN DWS_Task 按月聚合时, THE DWS_Task SHALL 使用 Business_Month 口径(当月1日 cutoff 到次月1日 cutoff) 4. WHEN DWS_Task 按周聚合时, THE DWS_Task SHALL 使用 Business_Week 口径(周一 cutoff 到次周一 cutoff) 5. THE BaseDwsTask.iter_dwd_rows SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期过滤 6. THE BaseDwsTask.get_time_window_range SHALL 返回基于 Business_Date 口径的时间范围 7. WHILE ETL 任务运行期间, THE DWS_Task SHALL 从 `AppConfig` 读取 `app.business_day_start_hour` 值,禁止硬编码 ### 需求 5:受影响的 DWS 任务全面排查 **用户故事:** 作为项目负责人,我希望所有使用 `DATE()` 进行时间归属的 DWS 任务都被排查并改造,确保无遗漏。 #### 验收标准 1. THE FinanceBaseTask SHALL 将所有 `DATE(pay_time)` 替换为 `biz_date_sql_expr("pay_time", cutoff_hour)` 生成的表达式 2. THE FinanceDailyTask SHALL 使用 Business_Date 口径提取和聚合结账单、团购核销、充值等数据 3. THE FinanceRechargeTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式 4. THE FinanceDiscountTask SHALL 使用 Business_Date 口径聚合优惠明细 5. THE FinanceIncomeTask SHALL 使用 Business_Date 口径聚合收入结构 6. THE AssistantDailyTask SHALL 使用 Business_Date 口径聚合助教日度明细 7. THE AssistantOrderContributionTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式 8. THE AssistantCustomerTask SHALL 将 `DATE(start_use_time)` 替换为营业日归属表达式 9. THE AssistantMonthlyTask SHALL 使用 Business_Month 口径聚合助教月度汇总 10. THE AssistantFinanceTask SHALL 使用 Business_Date 口径聚合助教财务分析 11. THE MemberVisitTask SHALL 将 `DATE(pay_time)`、`DATE(start_use_time)`、`DATE(ledger_end_time)` 替换为营业日归属表达式 12. THE MemberConsumptionTask SHALL 将 `DATE(pay_time)` 和 `DATE(create_time)` 替换为营业日归属表达式 13. THE GoodsStockDailyTask SHALL 将 `DATE(fetched_at)` 替换为营业日归属表达式 14. THE GoodsStockWeeklyTask SHALL 使用 Business_Week 口径聚合库存周报 15. THE GoodsStockMonthlyTask SHALL 使用 Business_Month 口径聚合库存月报 16. THE SpendingPowerIndexTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式 17. THE MemberIndexBase SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式 18. THE MvRefreshTask SHALL 确保物化视图刷新的时间过滤条件使用 Business_Date 口径 ### 需求 6:后端 API 层时间范围计算 **用户故事:** 作为后端开发者,我希望后端 API 在处理"今日/本周/本月"等时间范围查询时使用营业日口径,以便前端展示的数据与 DWS 统计一致。 #### 验收标准 1. THE Backend_Config SHALL 加载 `BUSINESS_DAY_START_HOUR` 环境变量并暴露为模块级常量 2. WHEN 后端 API 需要计算"今日"时间范围时, THE Backend SHALL 使用 `business_day_range` 函数计算从当天 cutoff 到次日 cutoff 的时间戳范围 3. WHEN 后端 API 需要计算"本周"时间范围时, THE Backend SHALL 使用 `business_week_range` 函数计算从本周一 cutoff 到次周一 cutoff 的时间戳范围 4. WHEN 后端 API 需要计算"本月"时间范围时, THE Backend SHALL 使用 `business_month_range` 函数计算从本月1日 cutoff 到次月1日 cutoff 的时间戳范围 5. THE Backend SHALL 从 `Shared_DateTime_Utils` 导入时间工具函数,禁止在后端重复实现营业日逻辑 ### 需求 7:前端展示层适配 **用户故事:** 作为前端开发者,我希望管理后台和小程序在展示日期选择器和统计数据时,能正确反映营业日口径,避免用户困惑。 #### 验收标准 1. WHEN Admin_Web 展示日期选择器时, THE Admin_Web SHALL 在日期选择器旁标注营业日口径说明(如"营业日:08:00 起") 2. WHEN Admin_Web 展示"今日统计"时, THE Admin_Web SHALL 显示的时间范围为当天 cutoff 到次日 cutoff 3. WHEN Miniprogram 展示统计数据时, THE Miniprogram SHALL 使用后端 API 返回的基于营业日口径的数据 4. THE Admin_Web SHALL 通过后端 API 获取 `BUSINESS_DAY_START_HOUR` 配置值,禁止前端硬编码 5. IF 后端 API 返回的 `BUSINESS_DAY_START_HOUR` 配置值不可用, THEN THE Admin_Web SHALL 使用默认值 8 并在控制台输出警告 ### 需求 8:后端配置查询 API **用户故事:** 作为前端开发者,我希望有一个 API 端点能返回当前的营业日分割点配置,以便前端动态获取并展示。 #### 验收标准 1. THE Backend SHALL 提供一个 API 端点返回当前 `BUSINESS_DAY_START_HOUR` 的值 2. WHEN 前端请求该端点时, THE Backend SHALL 返回包含 `business_day_start_hour` 字段的 JSON 响应 3. THE Backend SHALL 确保该端点的响应值与 ETL 层使用的 `BUSINESS_DAY_START_HOUR` 值一致(均来源于同一 `.env` 配置) ### 需求 9:数据库层适配 **用户故事:** 作为 DBA,我希望数据库中的物化视图和 SQL 函数使用营业日口径,以便直接查询数据库时也能获得正确的统计结果。 #### 验收标准 1. WHEN 物化视图使用 `date_trunc` 或 `CURRENT_DATE` 进行时间过滤时, THE Migration_Script SHALL 将其替换为基于 `Business_Day_Cutoff` 的表达式 2. THE Migration_Script SHALL 提供一个 PostgreSQL 函数 `biz_date(timestamptz, int)` 用于在 SQL 中直接计算营业日归属 3. WHEN 迁移脚本执行后, THE Database SHALL 确保所有物化视图的时间过滤条件使用营业日口径 4. THE Migration_Script SHALL 使用日期前缀命名(如 `2026-03-XX__add_biz_date_function.sql`),遵循项目迁移脚本规范 ### 需求 10:数据迁移与历史数据兼容 **用户故事:** 作为运维人员,我希望引入营业日机制后,历史数据能被正确重算,确保统计连续性。 #### 验收标准 1. THE Migration_Plan SHALL 提供 DWS 历史数据重算脚本,按营业日口径重新聚合所有受影响的 DWS 表 2. WHEN 历史数据重算执行时, THE Rebuild_Script SHALL 使用与正式 ETL 任务相同的 `Business_Day_Cutoff` 配置值 3. THE Migration_Plan SHALL 记录重算前后的数据行数对比,用于验证重算正确性 4. IF 重算过程中发生错误, THEN THE Rebuild_Script SHALL 回滚到重算前的状态并记录错误日志 ### 需求 11:属性测试覆盖 **用户故事:** 作为测试工程师,我希望营业日归属逻辑有完整的属性测试覆盖,确保边界条件和不变量得到验证。 #### 验收标准 1. THE Property_Test SHALL 验证 `business_date` 的往返一致性:对任意 datetime dt,`business_day_range(business_date(dt, h), h)` 的范围包含 dt 2. THE Property_Test SHALL 验证 `business_month` 与 `business_date` 的一致性:`business_month(dt, h) == business_date(dt, h).replace(day=1)` 3. THE Property_Test SHALL 验证 `business_week_monday` 与 `business_date` 的一致性:`business_week_monday(dt, h).weekday() == 0`(结果始终为周一) 4. THE Property_Test SHALL 验证 `biz_date_sql_expr` 的幂等性:对同一输入参数多次调用返回相同结果 5. THE Property_Test SHALL 验证边界条件:cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天 6. THE Property_Test SHALL 验证 `business_day_range` 返回的范围恰好为 24 小时 7. THE Property_Test SHALL 验证 `business_week_range` 返回的范围恰好为 7 天(168 小时) 8. THE Property_Test SHALL 使用 hypothesis 库生成随机 datetime 和 day_start_hour(0–23)进行测试 9. FOR ALL `day_start_hour` 值(0–23), THE Property_Test SHALL 验证 `business_date` 函数的单调性:若 dt1 < dt2 且两者在同一 Business_Date 范围内,则 `business_date(dt1, h) == business_date(dt2, h)` ### 需求 12:运维脚本中的 DATE() 排查 **用户故事:** 作为运维人员,我希望 `scripts/ops/` 和 ETL `scripts/` 中使用 `DATE()` 的运维脚本也被排查,确保调试和排查工具与正式统计口径一致。 #### 验收标准 1. THE Ops_Scripts SHALL 排查 `scripts/ops/export_bug_report.py` 中的 `DATE(trash_time)`、`DATE(create_time)`、`DATE(start_use_time)` 调用,评估是否需要替换为营业日归属表达式 2. THE Ops_Scripts SHALL 排查 `scripts/ops/etl_consistency_check.py` 中的日期比较逻辑 3. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py` 中的 `::date` 类型转换 4. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/run_update.py` 中的 `.date()` 调用和 `datetime.combine` 逻辑 5. WHEN 运维脚本用于与 DWS 数据对比验证时, THE Ops_Scripts SHALL 使用与 DWS 任务相同的营业日归属逻辑