Files

261 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实现计划业务日分割点机制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` 方法中增加校验
- 值不在 023 范围内或非整数时抛出 `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: 非法配置值拒绝**
- 对任意不在 023 范围内的整数值,`AppConfig.load()` 应抛出 `SystemExit`
- **Validates: Requirements 1.3**
- [x] 4.3 编写属性测试合法配置值正确加载Property 8
- **Property 8: 合法配置值正确加载**
- 对任意 023 范围内的整数值 `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:\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 仅需增加校验逻辑