微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
1
.kiro/specs/business-day-cutoff/.config.kiro
Normal file
1
.kiro/specs/business-day-cutoff/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "98a585de-82d9-4bbd-bed8-179208c12f8b", "workflowType": "requirements-first", "specType": "feature"}
|
||||
422
.kiro/specs/business-day-cutoff/design.md
Normal file
422
.kiro/specs/business-day-cutoff/design.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# 设计文档:业务日分割点机制(Business Day Cutoff)
|
||||
|
||||
## 概述
|
||||
|
||||
本设计将全系统的统计时间口径从自然日切换为以可配置小时值(默认 08:00)为分割点的营业日。影响范围覆盖六个层面:
|
||||
|
||||
1. **配置层**:`.env` 新增 `BUSINESS_DAY_START_HOUR`,ETL `AppConfig` 和后端 `config.py` 同步加载
|
||||
2. **共享工具层**:`packages/shared` 的 `datetime_utils.py` 扩展 `business_day_range`、`business_week_range`、`business_month_range` 三个范围函数
|
||||
3. **ETL DWS 层**:`BaseDwsTask.iter_dwd_rows` 的 `DATE()` 替换为 `biz_date_sql_expr`,18 个具体 DWS 任务的 SQL 全面改造
|
||||
4. **后端 API 层**:新增 `/api/config/business-day` 端点,时间范围查询统一使用 `business_*_range` 函数
|
||||
5. **数据库层**:新增 PostgreSQL `biz_date()` 函数,物化视图迁移
|
||||
6. **前端展示层**:管理后台日期选择器标注营业日口径,小程序透传后端数据
|
||||
|
||||
### 设计决策
|
||||
|
||||
1. **单一配置源**:`BUSINESS_DAY_START_HOUR` 仅在根 `.env` 定义一次,ETL 通过 `AppConfig`、后端通过 `config.py`、数据库通过迁移脚本参数化读取,避免多处硬编码导致不一致。
|
||||
2. **共享包作为唯一逻辑实现**:所有营业日归属计算集中在 `packages/shared/datetime_utils.py`,ETL 和后端均从此导入,禁止各子系统重复实现。
|
||||
3. **SQL 表达式生成器模式**:`biz_date_sql_expr(col, hour)` 生成 `DATE(col - INTERVAL 'N hours')` 字符串,DWS 任务在 SQL 拼接时调用,避免在 Python 侧逐行转换。
|
||||
4. **BaseDwsTask 基类统一改造**:`iter_dwd_rows` 的日期过滤从 `DATE(col)` 改为 `biz_date_sql_expr(col)`,所有子类自动继承,减少逐任务修改量。
|
||||
5. **物化视图通过迁移脚本重建**:物化视图的时间过滤条件无法动态参数化,需通过 SQL 迁移脚本 DROP + CREATE 重建。
|
||||
6. **历史数据重算采用 CLI 批量模式**:提供独立重算脚本,复用正式 ETL 任务逻辑(相同 `Business_Day_Cutoff` 配置),按日期窗口分批执行。
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph 配置层
|
||||
ENV[".env<br/>BUSINESS_DAY_START_HOUR=8"]
|
||||
ENV --> AC[AppConfig<br/>app.business_day_start_hour]
|
||||
ENV --> BC[Backend config.py<br/>BUSINESS_DAY_START_HOUR]
|
||||
end
|
||||
|
||||
subgraph 共享工具层
|
||||
DU["datetime_utils.py<br/>business_date / business_*_range<br/>biz_date_sql_expr"]
|
||||
end
|
||||
|
||||
subgraph ETL DWS 层
|
||||
BDT["BaseDwsTask<br/>iter_dwd_rows(biz_date_sql_expr)<br/>get_time_window_range"]
|
||||
BDT --> FT["FinanceBaseTask / FinanceDailyTask<br/>FinanceRechargeTask / FinanceDiscountTask<br/>FinanceIncomeTask"]
|
||||
BDT --> AT["AssistantDailyTask / AssistantMonthlyTask<br/>AssistantFinanceTask / AssistantCustomerTask<br/>AssistantOrderContributionTask"]
|
||||
BDT --> MT["MemberVisitTask / MemberConsumptionTask"]
|
||||
BDT --> GT["GoodsStockDailyTask / WeeklyTask / MonthlyTask"]
|
||||
BDT --> IT["SpendingPowerIndexTask / MemberIndexBase"]
|
||||
BDT --> MV["MvRefreshTask"]
|
||||
end
|
||||
|
||||
subgraph 后端 API 层
|
||||
API["/api/config/business-day<br/>GET → business_day_start_hour"]
|
||||
TR["时间范围计算<br/>business_day_range / week_range / month_range"]
|
||||
end
|
||||
|
||||
subgraph 数据库层
|
||||
PGF["biz_date(timestamptz, int)<br/>PostgreSQL 函数"]
|
||||
MVR["物化视图重建<br/>迁移脚本"]
|
||||
end
|
||||
|
||||
subgraph 前端
|
||||
AW["Admin_Web<br/>日期选择器标注"]
|
||||
MP["Miniprogram<br/>透传后端数据"]
|
||||
end
|
||||
|
||||
AC --> BDT
|
||||
AC --> DU
|
||||
BC --> API
|
||||
BC --> TR
|
||||
DU --> BDT
|
||||
DU --> TR
|
||||
API --> AW
|
||||
API --> MP
|
||||
PGF --> MVR
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 1. 共享时间工具(`packages/shared/src/neozqyy_shared/datetime_utils.py`)
|
||||
|
||||
现有函数(已实现):
|
||||
- `business_date(dt, day_start_hour) -> date`
|
||||
- `business_month(dt, day_start_hour) -> date`
|
||||
- `business_week_monday(dt, day_start_hour) -> date`
|
||||
- `biz_date_sql_expr(col, day_start_hour) -> str`
|
||||
|
||||
新增函数:
|
||||
|
||||
```python
|
||||
def business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
|
||||
"""返回给定营业日的精确时间戳范围 [start, end)。
|
||||
|
||||
start = biz_date 当天 day_start_hour:00
|
||||
end = biz_date 次日 day_start_hour:00
|
||||
"""
|
||||
|
||||
def business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
|
||||
"""返回给定营业周(周一)的精确时间戳范围 [start, end)。
|
||||
|
||||
start = week_monday 当天 day_start_hour:00
|
||||
end = week_monday + 7天 day_start_hour:00
|
||||
"""
|
||||
|
||||
def business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
|
||||
"""返回给定营业月(首日)的精确时间戳范围 [start, end)。
|
||||
|
||||
start = month_first 当天 day_start_hour:00
|
||||
end = 次月1日 day_start_hour:00
|
||||
"""
|
||||
```
|
||||
|
||||
所有 `*_range` 函数返回的时间戳带 `Asia/Shanghai` 时区信息(使用 `SHANGHAI_TZ`)。
|
||||
|
||||
### 2. ETL 配置层(`apps/etl/connectors/feiqiu/config/`)
|
||||
|
||||
**已完成**(代码库中已存在):
|
||||
- `defaults.py`:`"app": {"business_day_start_hour": 8}`
|
||||
- `env_parser.py`:`"BUSINESS_DAY_START_HOUR": ("app.business_day_start_hour",)`
|
||||
|
||||
**需新增**:`settings.py` 的 `_validate` 方法增加范围校验:
|
||||
|
||||
```python
|
||||
# 在 _validate 中新增
|
||||
hour = cfg["app"].get("business_day_start_hour", 8)
|
||||
if not isinstance(hour, int) or not (0 <= hour <= 23):
|
||||
raise SystemExit("app.business_day_start_hour 必须为 0–23 的整数")
|
||||
```
|
||||
|
||||
### 3. 后端配置层(`apps/backend/app/config.py`)
|
||||
|
||||
新增模块级常量:
|
||||
|
||||
```python
|
||||
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
```
|
||||
|
||||
### 4. 后端配置查询 API(`apps/backend/app/routers/business_day.py`)
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/config", tags=["业务配置"])
|
||||
|
||||
@router.get("/business-day")
|
||||
async def get_business_day_config():
|
||||
"""返回当前营业日分割点配置。"""
|
||||
return {"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}
|
||||
```
|
||||
|
||||
无需认证(公开配置),前端启动时调用一次缓存。
|
||||
|
||||
### 5. BaseDwsTask 基类改造
|
||||
|
||||
**`iter_dwd_rows` 改造**:
|
||||
|
||||
```python
|
||||
def iter_dwd_rows(self, table_name, columns, start_date, end_date,
|
||||
date_col="created_at", ...):
|
||||
cutoff = self.config.get("app.business_day_start_hour", 8)
|
||||
biz_expr = biz_date_sql_expr(date_col, cutoff)
|
||||
where_parts = [f"{biz_expr} >= %s", f"{biz_expr} <= %s"]
|
||||
# ... 其余逻辑不变
|
||||
```
|
||||
|
||||
**`get_time_window_range` 改造**:
|
||||
|
||||
当前方法返回 `TimeRange(start=date, end=date)`,改造后语义不变(仍返回 `date` 范围),但内部使用 `business_date` 计算 `base_date` 的营业日归属:
|
||||
|
||||
```python
|
||||
def get_time_window_range(self, window, base_date=None):
|
||||
if base_date is None:
|
||||
from neozqyy_shared.datetime_utils import now_shanghai, business_date
|
||||
cutoff = self.config.get("app.business_day_start_hour", 8)
|
||||
base_date = business_date(now_shanghai(), cutoff)
|
||||
# ... 其余逻辑使用 base_date(已是营业日)
|
||||
```
|
||||
|
||||
### 6. 各 DWS 任务 SQL 改造模式
|
||||
|
||||
所有任务的 SQL 改造遵循统一模式:
|
||||
|
||||
```sql
|
||||
-- 改造前
|
||||
DATE(pay_time) AS stat_date
|
||||
WHERE DATE(pay_time) >= %s AND DATE(pay_time) <= %s
|
||||
GROUP BY DATE(pay_time)
|
||||
|
||||
-- 改造后(cutoff_hour=8 时)
|
||||
DATE(pay_time - INTERVAL '8 hours') AS stat_date
|
||||
WHERE DATE(pay_time - INTERVAL '8 hours') >= %s AND DATE(pay_time - INTERVAL '8 hours') <= %s
|
||||
GROUP BY DATE(pay_time - INTERVAL '8 hours')
|
||||
```
|
||||
|
||||
任务从 `self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值,调用 `biz_date_sql_expr(col, cutoff)` 生成表达式。
|
||||
|
||||
**受影响任务清单**(18 个):
|
||||
|
||||
| 任务 | 主要时间列 | 聚合粒度 |
|
||||
|------|-----------|---------|
|
||||
| FinanceBaseTask | pay_time | 日 |
|
||||
| FinanceDailyTask | pay_time | 日 |
|
||||
| FinanceRechargeTask | pay_time | 日 |
|
||||
| FinanceDiscountTask | pay_time | 日 |
|
||||
| FinanceIncomeTask | pay_time | 日 |
|
||||
| AssistantDailyTask | start_use_time | 日 |
|
||||
| AssistantOrderContributionTask | pay_time, start_use_time | 日 |
|
||||
| AssistantCustomerTask | start_use_time | 日 |
|
||||
| AssistantMonthlyTask | (基于日度数据) | 月 |
|
||||
| AssistantFinanceTask | start_use_time | 日 |
|
||||
| MemberVisitTask | pay_time, start_use_time, ledger_end_time | 日 |
|
||||
| MemberConsumptionTask | pay_time, create_time | 日 |
|
||||
| GoodsStockDailyTask | fetched_at | 日 |
|
||||
| GoodsStockWeeklyTask | fetched_at | 周 |
|
||||
| GoodsStockMonthlyTask | fetched_at | 月 |
|
||||
| SpendingPowerIndexTask | pay_time | 日 |
|
||||
| MemberIndexBase | pay_time | 日 |
|
||||
| MvRefreshTask | (物化视图刷新) | - |
|
||||
|
||||
### 7. 数据库层
|
||||
|
||||
**新增 PostgreSQL 函数**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`):
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8)
|
||||
RETURNS date AS $$
|
||||
SELECT (ts - make_interval(hours => cutoff_hour))::date;
|
||||
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
|
||||
```
|
||||
|
||||
**物化视图重建**(迁移脚本 `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` 改为 `dws.biz_date(NOW())` 或等效表达式。
|
||||
|
||||
### 8. 前端适配
|
||||
|
||||
**Admin_Web**:
|
||||
- 日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
|
||||
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
|
||||
- 降级策略:API 不可用时使用默认值 8,`console.warn` 输出警告
|
||||
|
||||
**Miniprogram**:
|
||||
- 不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
|
||||
- 无需前端改造,仅确认后端 API 返回的数据已是营业日口径
|
||||
|
||||
### 9. 历史数据重算
|
||||
|
||||
提供 `scripts/ops/rebuild_dws_biz_date.py` 脚本:
|
||||
|
||||
```python
|
||||
# 伪代码
|
||||
for task_cls in ALL_DWS_TASKS:
|
||||
for date_window in split_by_month(history_start, history_end):
|
||||
task = task_cls(config)
|
||||
task.run(window_start=date_window.start, window_end=date_window.end)
|
||||
```
|
||||
|
||||
- 复用正式 ETL 任务逻辑,确保与正式运行使用相同的 `Business_Day_Cutoff`
|
||||
- 按月分窗口执行,避免单次事务过大
|
||||
- 执行前后记录行数对比到日志
|
||||
- 支持 `--dry-run` 模式预览影响范围
|
||||
|
||||
### 10. 运维脚本排查
|
||||
|
||||
| 脚本 | 涉及的 DATE() 调用 | 处理方式 |
|
||||
|------|-------------------|---------|
|
||||
| `scripts/ops/export_bug_report.py` | `DATE(trash_time)`, `DATE(create_time)`, `DATE(start_use_time)` | 替换为 `biz_date_sql_expr` 生成的表达式 |
|
||||
| `scripts/ops/etl_consistency_check.py` | 日期比较逻辑 | 评估后按需替换 |
|
||||
| `apps/etl/.../debug_blackbox.py` | `::date` 类型转换 | 替换为 `biz_date()` 函数调用 |
|
||||
| `apps/etl/.../run_update.py` | `.date()` 和 `datetime.combine` | 替换为 `business_date()` + `business_day_range()` |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 配置数据
|
||||
|
||||
```
|
||||
BUSINESS_DAY_START_HOUR: int (0–23, 默认 8)
|
||||
```
|
||||
|
||||
存储位置:
|
||||
- 根 `.env`:`BUSINESS_DAY_START_HOUR=8`
|
||||
- ETL:`AppConfig.config["app"]["business_day_start_hour"]`
|
||||
- 后端:`config.BUSINESS_DAY_START_HOUR`
|
||||
|
||||
### 时间工具函数签名
|
||||
|
||||
```python
|
||||
# 输入/输出类型
|
||||
business_date(dt: datetime, day_start_hour: int = 8) -> date
|
||||
business_month(dt: datetime, day_start_hour: int = 8) -> date
|
||||
business_week_monday(dt: datetime, day_start_hour: int = 8) -> date
|
||||
business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
|
||||
business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
|
||||
business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
|
||||
biz_date_sql_expr(col: str, day_start_hour: int = 8) -> str
|
||||
```
|
||||
|
||||
### 数据库函数
|
||||
|
||||
```sql
|
||||
dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date
|
||||
-- 等价于 Python 的 business_date,用于 SQL 查询和物化视图
|
||||
```
|
||||
|
||||
### API 响应模型
|
||||
|
||||
```json
|
||||
// GET /api/config/business-day
|
||||
{
|
||||
"business_day_start_hour": 8
|
||||
}
|
||||
```
|
||||
|
||||
### DWS 表影响
|
||||
|
||||
所有 DWS 表的 `stat_date` 字段语义从"自然日"变为"营业日"。表结构不变,仅数据内容因重算而变化。
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 营业日归属往返一致性(Round-Trip)
|
||||
|
||||
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h(0–23),`business_day_range(business_date(dt, h), h)` 返回的范围 `[start, end)` 应满足 `start <= dt < end`。
|
||||
|
||||
**Validates: Requirements 2.9, 11.1**
|
||||
|
||||
### Property 2: 营业月与营业日一致性
|
||||
|
||||
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h(0–23),`business_month(dt, h)` 应等于 `business_date(dt, h).replace(day=1)`。
|
||||
|
||||
**Validates: Requirements 2.10, 11.2**
|
||||
|
||||
### Property 3: 营业周与营业日一致性
|
||||
|
||||
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h(0–23),`business_week_monday(dt, h)` 应等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果的 `weekday()` 始终为 0(周一)。
|
||||
|
||||
**Validates: Requirements 2.11, 11.3**
|
||||
|
||||
### Property 4: 营业日归属单调性
|
||||
|
||||
*对任意* 两个 datetime `dt1 < dt2` 和任意合法的 `day_start_hour` h(0–23),若 `dt1` 和 `dt2` 都在同一个 `business_day_range(d, h)` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`。等价表述:`business_date(dt, h)` 关于 `dt` 是单调非递减的。
|
||||
|
||||
**Validates: Requirements 11.9**
|
||||
|
||||
### Property 5: 时间范围长度不变量
|
||||
|
||||
*对任意* date `d` 和任意合法的 `day_start_hour` h(0–23):
|
||||
- `business_day_range(d, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(hours=24)`
|
||||
- `business_week_range(monday, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(days=7)`
|
||||
|
||||
**Validates: Requirements 11.6, 11.7**
|
||||
|
||||
### Property 6: SQL 表达式生成幂等性
|
||||
|
||||
*对任意* 列名 `col` 和任意合法的 `day_start_hour` h(0–23),`biz_date_sql_expr(col, h)` 多次调用应返回完全相同的字符串。
|
||||
|
||||
**Validates: Requirements 11.4**
|
||||
|
||||
### Property 7: 非法配置值拒绝
|
||||
|
||||
*对任意* 不在 0–23 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 设为 `v` 时,`AppConfig.load()` 应抛出 `SystemExit`。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 8: 合法配置值正确加载
|
||||
|
||||
*对任意* 0–23 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 环境变量设为 `v` 时,`AppConfig.load()` 后 `cfg.get("app.business_day_start_hour")` 应返回 `v`。
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| `BUSINESS_DAY_START_HOUR` 值超出 0–23 | `AppConfig._validate` 抛出 `SystemExit`,明确提示合法范围 |
|
||||
| `BUSINESS_DAY_START_HOUR` 环境变量缺失 | 使用默认值 8,不报错 |
|
||||
| `BUSINESS_DAY_START_HOUR` 值为非整数字符串 | `env_parser._coerce_env` 保持字符串,`_validate` 阶段类型检查失败抛出 `SystemExit` |
|
||||
| 后端 `/api/config/business-day` 不可用 | Admin_Web 使用默认值 8,`console.warn` 输出警告 |
|
||||
| 历史数据重算脚本执行失败 | 按月窗口回滚当前批次,记录错误日志,继续下一窗口或中止(由 `--fail-fast` 参数控制) |
|
||||
| 物化视图迁移脚本执行失败 | 标准 PostgreSQL 事务回滚,迁移脚本幂等设计(`CREATE OR REPLACE`) |
|
||||
| `business_day_range` 等函数收到非法 `day_start_hour` | 函数内部不做校验(调用方负责),依赖 AppConfig 加载阶段的前置校验 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 `hypothesis` 库,测试文件位于 `tests/test_property_business_day_cutoff.py`。
|
||||
|
||||
每个属性测试最少运行 100 次迭代,使用 `@settings(max_examples=200)` 配置。
|
||||
|
||||
生成策略:
|
||||
- `day_start_hour`:`st.integers(min_value=0, max_value=23)`
|
||||
- `dt`:`st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31))`(避免极端日期)
|
||||
- `biz_date`:`st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))`
|
||||
|
||||
每个测试函数以注释标注对应的设计属性:
|
||||
|
||||
```python
|
||||
# Feature: business-day-cutoff, Property 1: 营业日归属往返一致性
|
||||
@given(dt=st.datetimes(...), h=st.integers(0, 23))
|
||||
@settings(max_examples=200)
|
||||
def test_business_date_round_trip(dt, h):
|
||||
...
|
||||
|
||||
# Feature: business-day-cutoff, Property 2: 营业月与营业日一致性
|
||||
# Feature: business-day-cutoff, Property 3: 营业周与营业日一致性
|
||||
# Feature: business-day-cutoff, Property 4: 营业日归属单调性
|
||||
# Feature: business-day-cutoff, Property 5: 时间范围长度不变量
|
||||
# Feature: business-day-cutoff, Property 6: SQL 表达式生成幂等性
|
||||
# Feature: business-day-cutoff, Property 7: 非法配置值拒绝
|
||||
# Feature: business-day-cutoff, Property 8: 合法配置值正确加载
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试覆盖属性测试不适合的场景:
|
||||
|
||||
- **边界示例**:`day_start_hour=8` 时,07:59:59 归属前一天,08:00:00 归属当天
|
||||
- **默认值行为**:`BUSINESS_DAY_START_HOUR` 缺失时 AppConfig 返回 8
|
||||
- **API 端点**:`/api/config/business-day` 返回正确 JSON 格式
|
||||
- **SQL 表达式格式**:`biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
|
||||
- **月末边界**:1月31日 07:00 归属1月30日(营业日),`business_month` 返回1月1日
|
||||
|
||||
### 测试配置
|
||||
|
||||
- 属性测试库:`hypothesis`(已在项目 `pyproject.toml` 中声明)
|
||||
- 每个属性测试对应设计文档中的一个 Property,由单个 `@given` 装饰的测试函数实现
|
||||
- 运行命令:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`
|
||||
186
.kiro/specs/business-day-cutoff/requirements.md
Normal file
186
.kiro/specs/business-day-cutoff/requirements.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 需求文档:业务日分割点机制(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 任务相同的营业日归属逻辑
|
||||
260
.kiro/specs/business-day-cutoff/tasks.md
Normal file
260
.kiro/specs/business-day-cutoff/tasks.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# 实现计划:业务日分割点机制(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:\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 仅需增加校验逻辑
|
||||
Reference in New Issue
Block a user