# 需求文档：业务日分割点机制（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 任务相同的营业日归属逻辑
