# 实现计划：业务日分割点机制（Business Day Cutoff）

## 概述

将全系统统计时间口径从自然日切换为以可配置小时值（默认 08:00）为分割点的营业日。实现顺序：配置层 → 共享工具层 → ETL 层 → 后端 API 层 → 数据库层 → 前端适配 → 历史数据重算 → 运维脚本排查。

## 任务

- [x] 1. 配置层：环境变量与配置加载
  - [x] 1.1 在根 `.env` 和 `.env.template` 中新增 `BUSINESS_DAY_START_HOUR=8`
    - `.env` 新增 `BUSINESS_DAY_START_HOUR=8`
    - `.env.template` 新增带注释的定义，说明日/周/月统计分割语义
    - _Requirements: 1.1, 1.2_

  - [x] 1.2 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验
    - 在 `apps/etl/connectors/feiqiu/config/settings.py` 的 `_validate` 方法中增加校验
    - 值不在 0–23 范围内或非整数时抛出 `SystemExit`
    - 环境变量缺失时使用默认值 8（已由 `defaults.py` 保证）
    - _Requirements: 1.3, 1.4, 3.1, 3.2, 3.3, 3.4_

  - [x] 1.3 在后端 `apps/backend/app/config.py` 中新增 `BUSINESS_DAY_START_HOUR` 常量
    - 新增 `BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))`
    - _Requirements: 6.1_

- [x] 2. 共享时间工具层：新增 range 函数
  - [x] 2.1 在 `packages/shared/src/neozqyy_shared/datetime_utils.py` 中实现三个 range 函数
    - 实现 `business_day_range(biz_date, day_start_hour)` → `tuple[datetime, datetime]`
    - 实现 `business_week_range(week_monday, day_start_hour)` → `tuple[datetime, datetime]`
    - 实现 `business_month_range(month_first, day_start_hour)` → `tuple[datetime, datetime]`
    - 所有返回值带 `Asia/Shanghai` 时区（使用 `SHANGHAI_TZ`）
    - 默认 `day_start_hour=8`
    - _Requirements: 2.6, 2.7, 2.8, 2.5_

  - [x] 2.2 编写属性测试：营业日归属往返一致性（Property 1）
    - **Property 1: 营业日归属往返一致性（Round-Trip）**
    - `business_day_range(business_date(dt, h), h)` 的范围 `[start, end)` 应满足 `start <= dt < end`
    - 使用 `hypothesis`，`@given(dt=st.datetimes(...), h=st.integers(0, 23))`，`@settings(max_examples=200)`
    - 测试文件：`tests/test_property_business_day_cutoff.py`
    - **Validates: Requirements 2.9, 11.1**

  - [x] 2.3 编写属性测试：营业月与营业日一致性（Property 2）
    - **Property 2: 营业月与营业日一致性**
    - `business_month(dt, h) == business_date(dt, h).replace(day=1)`
    - **Validates: Requirements 2.10, 11.2**

  - [x] 2.4 编写属性测试：营业周与营业日一致性（Property 3）
    - **Property 3: 营业周与营业日一致性**
    - `business_week_monday(dt, h) == business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`，且结果 `weekday() == 0`
    - **Validates: Requirements 2.11, 11.3**

  - [x] 2.5 编写属性测试：营业日归属单调性（Property 4）
    - **Property 4: 营业日归属单调性**
    - 若 `dt1 < dt2` 且两者在同一 `business_day_range` 范围内，则 `business_date(dt1, h) == business_date(dt2, h)`
    - **Validates: Requirements 11.9**

  - [x] 2.6 编写属性测试：时间范围长度不变量（Property 5）
    - **Property 5: 时间范围长度不变量**
    - `business_day_range(d, h)` 的 `end - start == timedelta(hours=24)`
    - `business_week_range(monday, h)` 的 `end - start == timedelta(days=7)`
    - **Validates: Requirements 11.6, 11.7**

  - [x] 2.7 编写属性测试：SQL 表达式生成幂等性（Property 6）
    - **Property 6: SQL 表达式生成幂等性**
    - `biz_date_sql_expr(col, h)` 多次调用返回完全相同的字符串
    - **Validates: Requirements 11.4**

  - [x] 2.8 编写属性测试：边界条件验证（Property 1 补充）
    - cutoff 时刻恰好在分割点上的时间戳归属当天，分割点前一秒归属前一天
    - 使用 hypothesis 生成 `day_start_hour`，构造边界时间戳验证
    - **Validates: Requirements 11.5**

  - [x] 2.9 编写单元测试：共享时间工具函数
    - 测试 `day_start_hour=8` 时 07:59:59 归属前一天、08:00:00 归属当天
    - 测试 `biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
    - 测试月末边界：1月31日 07:00 归属1月30日，`business_month` 返回1月1日
    - 测试默认值行为：不传 `day_start_hour` 时使用 8
    - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8_

- [x] 3. Checkpoint — 共享工具层验证
  - 确保所有属性测试和单元测试通过，运行 `pytest tests/test_property_business_day_cutoff.py -v`，如有问题请向用户确认。

- [x] 4. ETL 配置层集成与 BaseDwsTask 基类改造
  - [x] 4.1 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验（与 1.2 合并实现）
    - 确认 `defaults.py` 和 `env_parser.py` 已有配置映射（设计文档标注"已完成"）
    - 仅需在 `settings.py` 的 `_validate` 中增加校验逻辑
    - _Requirements: 3.1, 3.2, 3.3, 3.4_

  - [x] 4.2 编写属性测试：非法配置值拒绝（Property 7）
    - **Property 7: 非法配置值拒绝**
    - 对任意不在 0–23 范围内的整数值，`AppConfig.load()` 应抛出 `SystemExit`
    - **Validates: Requirements 1.3**

  - [x] 4.3 编写属性测试：合法配置值正确加载（Property 8）
    - **Property 8: 合法配置值正确加载**
    - 对任意 0–23 范围内的整数值 `v`，`AppConfig.load()` 后 `cfg.get("app.business_day_start_hour")` 应返回 `v`
    - **Validates: Requirements 3.4**

  - [x] 4.4 改造 `BaseDwsTask.iter_dwd_rows`，将 `DATE()` 替换为 `biz_date_sql_expr`
    - 从 `self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值
    - 调用 `biz_date_sql_expr(date_col, cutoff)` 生成 SQL 表达式
    - 替换 WHERE 子句中的 `DATE(col)` 为 `biz_date_sql_expr` 生成的表达式
    - _Requirements: 4.1, 4.5, 4.7_

  - [x] 4.5 改造 `BaseDwsTask.get_time_window_range`，使用 `business_date` 计算营业日归属
    - `base_date` 为 None 时使用 `business_date(now_shanghai(), cutoff)` 计算
    - 确保返回的 `TimeRange` 基于营业日口径
    - _Requirements: 4.6_

- [x] 5. DWS 任务 SQL 改造（财务类）
  - [x] 5.1 改造 FinanceBaseTask：`DATE(pay_time)` → `biz_date_sql_expr("pay_time", cutoff)`
    - 从 config 读取 cutoff，替换所有 `DATE(pay_time)` 为营业日表达式
    - 包括 SELECT、WHERE、GROUP BY 中的所有出现
    - _Requirements: 5.1_

  - [x] 5.2 改造 FinanceDailyTask：使用 Business_Date 口径
    - 替换结账单、团购核销、充值等数据的日期归属
    - _Requirements: 5.2_

  - [x] 5.3 改造 FinanceRechargeTask：`DATE(pay_time)` → 营业日归属表达式
    - _Requirements: 5.3_

  - [x] 5.4 改造 FinanceDiscountTask：使用 Business_Date 口径聚合优惠明细
    - _Requirements: 5.4_

  - [x] 5.5 改造 FinanceIncomeTask：使用 Business_Date 口径聚合收入结构
    - _Requirements: 5.5_

- [x] 6. DWS 任务 SQL 改造（助教类）
  - [x] 6.1 改造 AssistantDailyTask：`DATE(start_use_time)` → 营业日归属表达式
    - _Requirements: 5.6_

  - [x] 6.2 改造 AssistantOrderContributionTask：`DATE(pay_time)` → 营业日归属表达式
    - _Requirements: 5.7_

  - [x] 6.3 改造 AssistantCustomerTask：`DATE(start_use_time)` → 营业日归属表达式
    - _Requirements: 5.8_

  - [x] 6.4 改造 AssistantMonthlyTask：使用 Business_Month 口径聚合月度汇总
    - _Requirements: 5.9_

  - [x] 6.5 改造 AssistantFinanceTask：使用 Business_Date 口径聚合助教财务分析
    - _Requirements: 5.10_

- [x] 7. DWS 任务 SQL 改造（会员类 + 商品库存类 + 指标类）
  - [x] 7.1 改造 MemberVisitTask：`DATE(pay_time)`、`DATE(start_use_time)`、`DATE(ledger_end_time)` → 营业日归属表达式
    - _Requirements: 5.11_

  - [x] 7.2 改造 MemberConsumptionTask：`DATE(pay_time)` 和 `DATE(create_time)` → 营业日归属表达式
    - _Requirements: 5.12_

  - [x] 7.3 改造 GoodsStockDailyTask：`DATE(fetched_at)` → 营业日归属表达式
    - _Requirements: 5.13_

  - [x] 7.4 改造 GoodsStockWeeklyTask：使用 Business_Week 口径聚合库存周报
    - _Requirements: 5.14_

  - [x] 7.5 改造 GoodsStockMonthlyTask：使用 Business_Month 口径聚合库存月报
    - _Requirements: 5.15_

  - [x] 7.6 改造 SpendingPowerIndexTask：`DATE(pay_time)` → 营业日归属表达式
    - _Requirements: 5.16_

  - [x] 7.7 改造 MemberIndexBase：`DATE(pay_time)` → 营业日归属表达式
    - _Requirements: 5.17_

  - [x] 7.8 改造 MvRefreshTask：确保物化视图刷新的时间过滤条件使用 Business_Date 口径
    - _Requirements: 5.18_

- [x] 8. Checkpoint — ETL DWS 层改造验证
  - 确保所有 18 个 DWS 任务的 SQL 改造完成，`DATE()` 调用已全部替换为 `biz_date_sql_expr` 生成的表达式。运行 ETL 单元测试 `cd apps/etl/connectors/feiqiu && pytest tests/unit -v`，如有问题请向用户确认。

- [x] 9. 后端 API 层
  - [x] 9.1 创建 `apps/backend/app/routers/business_day.py`，实现 `/api/config/business-day` 端点
    - 创建 `APIRouter(prefix="/api/config", tags=["业务配置"])`
    - 实现 `GET /business-day` 返回 `{"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}`
    - 无需认证（公开配置）
    - _Requirements: 8.1, 8.2, 8.3_

  - [x] 9.2 在后端主路由中注册 `business_day` 路由
    - 在 `apps/backend/app/main.py` 或路由注册文件中 `include_router`
    - _Requirements: 8.1_

  - [x] 9.3 后端时间范围查询统一使用 `business_*_range` 函数
    - 在后端需要计算"今日/本周/本月"时间范围的 API 中，导入并使用 `business_day_range`、`business_week_range`、`business_month_range`
    - 从 `Shared_DateTime_Utils` 导入，禁止重复实现
    - _Requirements: 6.2, 6.3, 6.4, 6.5_

  - [x] 9.4 编写单元测试：后端配置查询 API
    - 测试 `/api/config/business-day` 返回正确 JSON 格式
    - 测试返回值与 `config.BUSINESS_DAY_START_HOUR` 一致
    - _Requirements: 8.1, 8.2, 8.3_

- [x] 10. 数据库层：PostgreSQL 函数与物化视图迁移
  - [x] 10.1 创建迁移脚本：新增 `dws.biz_date()` PostgreSQL 函数
    - 文件：`db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`
    - `CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date`
    - 标记为 `IMMUTABLE PARALLEL SAFE`
    - _Requirements: 9.2, 9.4_

  - [x] 10.2 创建迁移脚本：重建物化视图使用 `biz_date()` 函数
    - 文件：`db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`
    - 将 `mv_dws_finance_daily_summary_l1..l4` 和 `mv_dws_assistant_daily_detail_l1..l4` 的 `CURRENT_DATE` / `date_trunc` 替换为 `dws.biz_date(NOW())`
    - 使用 `DROP MATERIALIZED VIEW IF EXISTS` + `CREATE MATERIALIZED VIEW` 重建
    - _Requirements: 9.1, 9.3_

- [x] 11. 前端适配
  - [x] 11.1 Admin_Web：日期选择器旁标注营业日口径说明
    - 在日期选择器组件旁增加 Tooltip 或文字标注：`营业日：{HH}:00 起`
    - 通过 `/api/config/business-day` 获取 `business_day_start_hour`，启动时请求一次存入全局状态
    - 降级策略：API 不可用时使用默认值 8，`console.warn` 输出警告
    - _Requirements: 7.1, 7.2, 7.4, 7.5_

  - [x] 11.2 Miniprogram：确认后端 API 返回的数据已是营业日口径
    - 小程序不直接使用 cutoff 值，所有统计数据由后端 API 按营业日口径返回
    - 确认无需前端改造，仅验证后端 API 数据正确性
    - _Requirements: 7.3_

- [x] 12. Checkpoint — 后端 + 数据库 + 前端验证
  - 确保后端 API 端点可用、迁移脚本语法正确、前端标注正常显示。如有问题请向用户确认。

- [x] 13. 历史数据重算脚本
  - [x] 13.1 创建 `scripts/ops/rebuild_dws_biz_date.py` 历史数据重算脚本
    - 复用正式 ETL 任务逻辑，使用相同的 `Business_Day_Cutoff` 配置
    - 按月分窗口执行，避免单次事务过大
    - 执行前后记录行数对比到日志
    - 支持 `--dry-run` 模式预览影响范围
    - 支持 `--fail-fast` 参数控制错误时中止或继续
    - 错误时回滚当前批次并记录错误日志
    - _Requirements: 10.1, 10.2, 10.3, 10.4_

- [x] 14. 运维脚本排查与改造
  - [x] 14.1 排查并改造 `scripts/ops/export_bug_report.py`
    - 将 `DATE(trash_time)`、`DATE(create_time)`、`DATE(start_use_time)` 替换为 `biz_date_sql_expr` 生成的表达式
    - _Requirements: 12.1, 12.5_

  - [x] 14.2 排查并改造 `scripts/ops/etl_consistency_check.py`
    - 评估日期比较逻辑，按需替换为营业日归属表达式
    - _Requirements: 12.2, 12.5_

  - [x] 14.3 排查并改造 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py`
    - 将 `::date` 类型转换替换为 `biz_date()` 函数调用
    - _Requirements: 12.3_

  - [x] 14.4 排查并改造 `apps/etl/connectors/feiqiu/scripts/run_update.py`
    - 将 `.date()` 和 `datetime.combine` 替换为 `business_date()` + `business_day_range()`
    - _Requirements: 12.4, 12.5_

- [x] 15. Final Checkpoint — 全量验证
  - 确保所有属性测试通过：`cd C:\Project\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`
  - 确保 ETL 单元测试通过：`cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
  - 确认所有 12 项需求的验收标准均有对应任务覆盖
  - 如有问题请向用户确认。

## 备注

- 标记 `*` 的子任务为可选，可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号，确保可追溯性
- 属性测试验证设计文档中的 8 个正确性属性
- Checkpoint 任务确保增量验证，及时发现问题
- 设计文档标注 `defaults.py` 和 `env_parser.py` 已完成，任务 4.1 仅需增加校验逻辑
