# 工具类任务详解

> 本文档说明飞球 ETL 系统中所有工具类（Utility）和校验类（Verification）任务。
> 这些任务不属于 ODS/DWD/DWS/INDEX 四层业务 Flow，而是为系统初始化、
> 数据灌入、归档、截止时间检查和完整性校验等运维场景服务。

---

## 概述

工具类任务共 8 个（含 1 个校验类任务），注册于 `orchestration/task_registry.py`：

| 任务代码 | Python 类 | 用途 | task_type | requires_db_config |
|----------|-----------|------|-----------|-------------------|
| `INIT_ODS_SCHEMA` | `InitOdsSchemaTask` | 执行 ODS + meta DDL，创建必要目录 | utility | `False` |
| `INIT_DWD_SCHEMA` | `InitDwdSchemaTask` | 执行 DWD DDL | utility | `False` |
| `INIT_DWS_SCHEMA` | `InitDwsSchemaTask` | 执行 DWS DDL | utility | `False` |
| `MANUAL_INGEST` | `ManualIngestTask` | 从本地 JSON 文件手动入库到 ODS | utility | `False` |
| `ODS_JSON_ARCHIVE` | `OdsJsonArchiveTask` | 在线抓取 ODS 接口数据并落盘 JSON | utility | `False` |
| `CHECK_CUTOFF` | `CheckCutoffTask` | 检查各任务/表的数据截止时间 | utility | `False` |
| `SEED_DWS_CONFIG` | `SeedDwsConfigTask` | 初始化 DWS 配置种子数据 | utility | `True`（默认） |
| `DATA_INTEGRITY_CHECK` | `DataIntegrityTask` | API → ODS → DWD 数据完整性校验 | verification | `False` |

> 典型执行顺序（首次部署）：`INIT_ODS_SCHEMA` → `INIT_DWD_SCHEMA` → `INIT_DWS_SCHEMA` → `SEED_DWS_CONFIG`

---

## 1. INIT_ODS_SCHEMA — ODS + meta Schema 初始化

| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_ODS_SCHEMA` |
| Python 类 | `tasks.utility.init_schema_task.InitOdsSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 ODS 层和 meta 调度元数据的数据库结构，并准备运行时目录 |

### 执行流程

```
extract()
  ├── 读取 DDL 文件路径（schema_ODS_doc.sql、schema_etl_admin.sql）
  ├── 收集需创建的目录列表
  └── 返回 SQL 文本 + 目录列表

load()
  ├── 创建必要目录（log_root、export_root、fetch_root、ingest_dir）
  ├── 执行 meta DDL（schema_etl_admin.sql）
  └── 执行 ODS DDL（schema_ODS_doc.sql，清洗后）
```

### 执行的 DDL 文件

| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| meta DDL（代码默认 `database/schema_etl_admin.sql`） | `meta` | `etl_task`（任务注册表）、`etl_cursor`（游标表）、`etl_run`（运行记录表） |
| ODS DDL（代码默认 `database/schema_ODS_doc.sql`） | `ods` | 20+ 张 ODS 原始表（member_profiles、settlement_records、payment_transactions 等） |

> 注：模块内 `database/` 目录下的旧 DDL 文件已不存在。INIT 任务通过配置参数 `schema.ods_file` / `schema.etl_admin_file` 指定 DDL 路径。
> 当前 DDL 基线统一维护在 `docs/database/ddl/`（如 `etl_feiqiu__ods.sql`、`etl_feiqiu__meta.sql`），使用时需通过配置参数指向正确路径。

### ODS DDL 清洗逻辑

ODS DDL 文件可能包含头部说明文本和 `COMMENT ON` 语句（CamelCase 未加引号会导致执行失败），因此 `load()` 阶段会做轻量清洗：

1. 定位第一个 `DROP SCHEMA` 语句，丢弃之前的非 SQL 文本
2. 逐行过滤掉以 `COMMENT ON` 开头的行

### 创建的目录

| 配置路径 | 说明 |
|----------|------|
| `io.log_root` | 日志输出根目录 |
| `io.export_root` | 数据导出根目录 |
| `pipeline.fetch_root` | API 抓取数据落盘目录 |
| `pipeline.ingest_source_dir` | 手动入库数据源目录（默认同 fetch_root） |

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.ods_file` | `database/schema_ODS_doc.sql`（⚠️ 旧路径，需配置为实际 DDL 位置） | ODS DDL 文件路径 |
| `schema.etl_admin_file` | `database/schema_etl_admin.sql`（⚠️ 旧路径） | meta DDL 文件路径 |
| `io.log_root` | — | 日志目录 |
| `io.export_root` | — | 导出目录 |
| `pipeline.fetch_root` | — | 抓取数据目录 |
| `pipeline.ingest_source_dir` | 同 fetch_root | 入库数据源目录 |

### CLI 示例

```bash
python -m cli.main --tasks INIT_ODS_SCHEMA --pg-dsn "$PG_DSN"
```

---

## 2. INIT_DWD_SCHEMA — DWD Schema 初始化

| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_DWD_SCHEMA` |
| Python 类 | `tasks.utility.init_dwd_schema_task.InitDwdSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 DWD 明细数据层的数据库结构 |

### 执行流程

```
extract()
  ├── 读取 DDL 文件路径（schema_dwd_doc.sql）
  └── 读取 drop_first 配置

load()
  ├── [可选] DROP SCHEMA dwd CASCADE
  └── 执行 DWD DDL（schema_dwd_doc.sql）
```

### 执行的 DDL 文件

| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| DWD DDL（代码默认 `database/schema_dwd_doc.sql`） | `dwd` | 维度表（dim_*，含 SCD2 约束）、事实表（dwd_*、fact_*）、扩展表（*_ex） |

> 注：DDL 基线见 `docs/database/ddl/etl_feiqiu__dwd.sql`，使用时需通过 `schema.dwd_file` 配置参数指向正确路径。

DWD DDL 的特殊处理：
- 自动为含 `scd2_start_time` 列的表设置 SCD2 默认值（`scd2_start_time=now()`、`scd2_end_time='9999-12-31'`、`scd2_is_current=1`、`scd2_version=1`）
- 自动创建 SCD2 排他约束（`EXCLUDE USING gist`，防止同一业务主键的生效区间重叠）
- 自动创建当前版本唯一索引（`WHERE scd2_is_current = 1`）

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.dwd_file` | `database/schema_dwd_doc.sql`（⚠️ 旧路径，需配置为实际 DDL 位置） | DWD DDL 文件路径 |
| `dwd.drop_schema_first` | `False` | 是否先 DROP 再重建（危险操作，会丢失所有 DWD 数据） |

### CLI 示例

```bash
# 常规初始化
python -m cli.main --tasks INIT_DWD_SCHEMA --pg-dsn "$PG_DSN"

# 重建（先删后建，慎用）
python -m cli.main --tasks INIT_DWD_SCHEMA --pg-dsn "$PG_DSN" --extra dwd.drop_schema_first=true
```

---

## 3. INIT_DWS_SCHEMA — DWS Schema 初始化

| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_DWS_SCHEMA` |
| Python 类 | `tasks.utility.init_dws_schema_task.InitDwsSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 DWS 数据服务层的数据库结构 |

### 执行流程

```
extract()
  ├── 读取 DDL 文件路径（schema_dws.sql）
  └── 读取 drop_first 配置

load()
  ├── [可选] DROP SCHEMA dws CASCADE
  └── 执行 DWS DDL（schema_dws.sql）
```

### 执行的 DDL 文件

| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| DWS DDL（代码默认 `database/schema_dws.sql`） | `dws` | 配置表（5 张 cfg_*）、助教域（5 张）、会员域（2 张）、财务域（7 张）、订单汇总（1 张） |

> 注：DDL 基线见 `docs/database/ddl/etl_feiqiu__dws.sql`，使用时需通过 `schema.dws_file` 配置参数指向正确路径。

DWS Schema 包含的配置表：

| 配置表 | 说明 |
|--------|------|
| `cfg_performance_tier` | 绩效档位配置（阈值、抽成比例、假期天数） |
| `cfg_assistant_level_price` | 助教等级定价 |
| `cfg_bonus_rules` | 奖金规则配置 |
| `cfg_area_category` | 台区分类映射 |
| `cfg_skill_type` | 技能课程类型映射 |

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.dws_file` | `database/schema_dws.sql`（⚠️ 旧路径，需配置为实际 DDL 位置） | DWS DDL 文件路径 |
| `dws.drop_schema_first` | `False` | 是否先 DROP 再重建（危险操作） |

### CLI 示例

```bash
python -m cli.main --tasks INIT_DWS_SCHEMA --pg-dsn "$PG_DSN"
```

---

## 4. SEED_DWS_CONFIG — DWS 配置种子数据初始化

| 属性 | 值 |
|------|-----|
| 任务代码 | `SEED_DWS_CONFIG` |
| Python 类 | `tasks.utility.seed_dws_config_task.SeedDwsConfigTask` |
| 继承 | `BaseTask` |
| 用途 | 向 DWS 配置表插入初始数据（绩效档位、等级定价、奖金规则等） |

### 前置条件

- `dws` schema 已创建（需先执行 `INIT_DWS_SCHEMA`）
- 配置表（`cfg_*`）已存在

### 执行流程

```
extract()
  └── 读取 seed_dws_config.sql 文件内容

load()
  └── 执行 SQL（TRUNCATE + INSERT 配置数据）
```

### 执行的种子文件

| 文件 | 目标表 | 说明 |
|------|--------|------|
| `database/seed_dws_config.sql` | `cfg_performance_tier` | 绩效档位（含历史口径：旧方案至 2026-02-28，新方案 2026-03-01 起） |
| | `cfg_assistant_level_price` | 助教等级定价 |
| | `cfg_bonus_rules` | 奖金规则 |
| | `cfg_area_category` | 台区分类映射 |
| | `cfg_skill_type` | 技能课程类型映射 |

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `schema.seed_dws_file` | `database/seed_dws_config.sql`（⚠️ 旧路径，种子数据现位于 `db/etl_feiqiu/seeds/seed_dws_config.sql`） | 种子数据 SQL 文件路径 |

### CLI 示例

```bash
# 通常与 INIT_DWS_SCHEMA 一起执行
python -m cli.main --tasks INIT_DWS_SCHEMA,SEED_DWS_CONFIG --pg-dsn "$PG_DSN"
```

---

## 5. MANUAL_INGEST — 手动 JSON 入库

| 属性 | 值 |
|------|-----|
| 任务代码 | `MANUAL_INGEST` |
| Python 类 | `tasks.utility.manual_ingest_task.ManualIngestTask` |
| 继承 | `BaseTask` |
| 用途 | 从本地 JSON 文件批量灌入 ODS 表，用于离线回放或示例数据导入 |

### 执行流程概览

```
execute()
  ├── 确定数据目录（manual.data_dir / pipeline.ingest_source_dir / tests/testdata_json）
  ├── 遍历目录下所有 .json 文件（按文件名排序）
  │   ├── [可选] 按 include_files 过滤
  │   ├── 读取并解析 JSON
  │   ├── 提取记录列表（兼容多层 data/list 包装）
  │   ├── 按文件名关键字匹配目标 ODS 表
  │   └── 批量入库（INSERT ON CONFLICT）
  └── 返回统计计数（fetched/inserted/updated/skipped/errors）
```

### 文件匹配规则

`MANUAL_INGEST` 通过 `FILE_MAPPING` 将文件名关键字映射到目标 ODS 表。匹配逻辑：**文件名中包含关键字即匹配**（大小写敏感）。

| 文件名关键字 | 目标 ODS 表 |
|-------------|------------|
| `member_profiles` | `ods.member_profiles` |
| `member_balance_changes` | `ods.member_balance_changes` |
| `member_stored_value_cards` | `ods.member_stored_value_cards` |
| `recharge_settlements` | `ods.recharge_settlements` |
| `settlement_records` | `ods.settlement_records` |
| `assistant_accounts_master` | `ods.assistant_accounts_master` |
| `assistant_service_records` | `ods.assistant_service_records` |
| `site_tables_master` | `ods.site_tables_master` |
| `table_fee_discount_records` | `ods.table_fee_discount_records` |
| `table_fee_transactions` | `ods.table_fee_transactions` |
| `goods_stock_movements` | `ods.goods_stock_movements` |
| `stock_goods_category_tree` | `ods.stock_goods_category_tree` |
| `goods_stock_summary` | `ods.goods_stock_summary` |
| `payment_transactions` | `ods.payment_transactions` |
| `refund_transactions` | `ods.refund_transactions` |
| `platform_coupon_redemption_records` | `ods.platform_coupon_redemption_records` |
| `group_buy_redemption_records` | `ods.group_buy_redemption_records` |
| `group_buy_packages` | `ods.group_buy_packages` |
| `store_goods_master` | `ods.store_goods_master` |
| `tenant_goods_master` | `ods.tenant_goods_master` |
| `store_goods_sales_records` | `ods.store_goods_sales_records` |

### JSON 解析逻辑

`_extract_records()` 方法兼容多种 JSON 包装格式：

1. **顶层数组**：`[{...}, {...}]` → 直接作为记录列表
2. **data 包装**：`{"data": [...]}` 或 `{"code": 0, "data": [...]}` → 展开 `data` 字段
3. **嵌套 list**：`{"data": {"someKey": [{...}]}}` → 自动查找第一个 list 类型的值
4. **settleList 特殊处理**：充值/结算记录的 `data.settleList` 结构会被展开，内层 `settleList` 提取为独立记录，并保留外层 `siteProfile` 供字段补充

### 入库流程

对每张目标表，入库过程如下：

1. **查询表结构**：通过 `information_schema.columns` 获取目标表的列名、数据类型
2. **构建 SQL**：生成 `INSERT INTO ... VALUES %s ON CONFLICT ...` 语句
   - 有 `content_hash` 列：`ON CONFLICT (pk, content_hash) DO NOTHING`（内容去重）
   - 无 `content_hash` 列：`ON CONFLICT (pk) DO UPDATE SET ...`（upsert 覆盖）
3. **值映射**：逐列匹配 JSON 字段（忽略大小写），特殊列处理：
   - `payload`：存储原始 JSON 记录
   - `source_file`：填入文件名
   - `fetched_at`：取记录中的值或当前时间
   - `content_hash`：基于记录内容计算 SHA-256（排除 `fetched_at`、`payload` 等 ETL 元数据字段）
   - JSON 类型列：自动包装为 `psycopg2.extras.Json`
   - 整数/浮点/时间戳列：自动类型转换
4. **批量执行**：使用 `psycopg2.extras.execute_values` 分批提交（默认 chunk_size=50，最大 500）
5. **降级处理**：批量执行失败时，降级为逐行 + `SAVEPOINT` 模式，跳过异常行继续处理
6. **事务粒度**：每个文件一次 `commit`，避免长事务

### 特殊处理

- **充值/结算记录**（`recharge_settlements`、`settlement_records`）：自动从 `siteProfile` 补齐 `tenantid`、`siteid`、`sitename`
- **空值规范化**：空字符串 `""`、空 JSON `"{}"` / `"[]"` 统一转为 `None`
- **主键校验**：主键值为 `None` 或空字符串的记录直接跳过

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `manual.data_dir` | — | JSON 数据文件目录（优先级最高） |
| `pipeline.ingest_source_dir` | — | 入库数据源目录（次优先） |
| — | `tests/testdata_json` | 兜底默认目录 |
| `manual.include_files` | `[]`（全部） | 限定处理的文件名列表（不含扩展名，小写匹配） |
| `manual.execute_values_page_size` | `50` | 批量插入每批行数（1-500） |

### CLI 示例

```bash
# 从默认目录灌入所有 JSON
python -m cli.main --tasks MANUAL_INGEST --pg-dsn "$PG_DSN"

# 指定数据目录
python -m cli.main --tasks MANUAL_INGEST --pg-dsn "$PG_DSN" \
  --extra manual.data_dir=/path/to/json_files

# 只灌入指定文件
python -m cli.main --tasks MANUAL_INGEST --pg-dsn "$PG_DSN" \
  --extra manual.include_files=member_profiles,settlement_records
```

---

## 6. ODS_JSON_ARCHIVE — ODS 接口数据归档

| 属性 | 值 |
|------|-----|
| 任务代码 | `ODS_JSON_ARCHIVE` |
| Python 类 | `tasks.ods.ods_json_archive_task.OdsJsonArchiveTask` |
| 继承 | `BaseTask` |
| 用途 | 在线抓取所有 ODS 相关 API 接口数据，落盘为简化 JSON 文件，供后续离线回放/入库 |

> 注意：虽然注册为 `task_type="utility"`，但该任务的源文件位于 `tasks/ods/` 目录下，因为它本质上是 ODS 数据的抓取归档。

### 归档策略

- **输出格式**：每页一个 JSON 文件，格式为 `{"code": 0, "data": [...records...]}`，与 `MANUAL_INGEST` 的解析逻辑兼容
- **文件命名**：`{endpoint_stem}__p{page_no:04d}.json`（如 `GetAllOrderSettleList__p0001.json`）
- **小票文件**：按 `orderSettleId` 分文件写入（`GetOrderSettleTicketNew__{orderSettleId}.json`）
- **清单文件**：抓取完成后生成 `manifest.json`，记录窗口、端点、记录数等元信息

### 抓取的 API 端点

任务内置 22 个端点配置（`ENDPOINTS`），按窗口参数风格分类：

| 窗口风格 | 参数 | 端点示例 |
|----------|------|----------|
| `site` | `siteId` | `/MemberProfile/GetTenantMemberList`、`/Table/GetSiteTables` 等 |
| `start_end` | `siteId` + `startTime` / `endTime` | `/MemberProfile/GetMemberCardBalanceChange`、`/TenantGoods/GetGoodsSalesList` 等 |
| `range` | `siteId` + `rangeStartTime` / `rangeEndTime` | `/Site/GetAllOrderSettleList`、`/Site/GetRechargeSettleList` |
| `pay` | `siteId` + `StartPayTime` / `EndPayTime` | `/PayLog/GetPayLogListPage` |

此外，还有一个特殊端点 `/Order/GetOrderSettleTicketNew`（小票详情），按支付日志中提取的 `orderSettleId` 逐单抓取。

### 执行流程

```
extract()
  ├── 验证 API 客户端类型（必须为 APIClient，即在线模式）
  ├── 确定输出目录（api.output_dir / pipeline.fetch_root）
  ├── 遍历 ENDPOINTS，逐端点分页抓取
  │   ├── 构建请求参数（按 window_style 选择参数格式）
  │   ├── 调用 iter_paginated() 分页获取
  │   ├── 每页落盘为独立 JSON 文件
  │   └── 从支付日志中收集 orderSettleId（用于小票抓取）
  ├── 按 orderSettleId 逐单抓取小票详情
  └── 生成 manifest.json 清单文件
```

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `pipeline.fetch_root` | — | JSON 文件输出目录 |
| `api.page_size` | `200` | API 分页大小 |
| `io.write_pretty_json` | `False` | 是否格式化输出 JSON |

### CLI 示例

```bash
# 在线抓取并归档
python -m cli.main --tasks ODS_JSON_ARCHIVE --pg-dsn "$PG_DSN" \
  --store-id "$STORE_ID" --api-token "$API_TOKEN"
```

---

## 7. CHECK_CUTOFF — 数据截止时间检查

| 属性 | 值 |
|------|-----|
| 任务代码 | `CHECK_CUTOFF` |
| Python 类 | `tasks.utility.check_cutoff_task.CheckCutoffTask` |
| 继承 | `BaseTask` |
| 用途 | 报告各任务的游标截止时间和各层数据表的最新时间戳，用于运维监控 |

### 执行流程

该任务不走标准的 extract → transform → load 流程，而是直接在 `execute()` 中完成所有逻辑：

```
execute()
  ├── 1. 查询 meta 游标截止时间
  │   ├── 关联 etl_task + etl_cursor 表
  │   ├── 筛选当前门店已启用的任务
  │   ├── [可选] 按 task_codes 过滤
  │   └── 计算总体截止时间（排除 INIT_* 任务的最小 last_end）
  ├── 2. 探测 ODS 表抓取时间
  │   ├── 遍历 DwdLoadTask.TABLE_MAP 中的 ODS 表
  │   ├── 查询每张表的 MAX(fetched_at) 和 COUNT(*)
  │   └── 计算 ODS 截止时间（最小 max_fetched_at）
  └── 3. 探测 DWD/DWS 关键时间列
      ├── DWD: max(pay_time) from dwd_settlement_head / dwd_payment / dwd_refund
      └── DWS: max(order_date) / max(updated_at) from dws_order_summary
```

### 校验逻辑

- **游标截止时间**：从 `meta.etl_cursor.last_end` 获取每个任务的最后成功窗口结束时间，排除 `INIT_*` 任务后取最小值作为总体截止时间
- **ODS 抓取时间**：查询每张 ODS 表的 `MAX(fetched_at)`，取最小值作为 ODS 层截止时间
- **DWD/DWS 业务时间**：探测关键业务时间列（`pay_time`、`order_date`、`updated_at`），反映数据实际覆盖范围

### 输出

任务通过日志输出检查结果，同时在返回值的 `report` 字段中包含结构化数据：

```python
{
    "rows": [...],              # 每个任务的游标信息
    "overall_cutoff": datetime, # 总体截止时间
    "ods_fetched_at": {...},    # 每张 ODS 表的 max_fetched_at
    "dw_max_times": {...},      # DWD/DWS 关键时间列
}
```

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `app.store_id` | — | 门店 ID（必填） |
| `run.cutoff_task_codes` | `None`（全部） | 逗号分隔的任务代码列表，限定检查范围 |

### CLI 示例

```bash
# 检查所有任务的截止时间
python -m cli.main --tasks CHECK_CUTOFF --pg-dsn "$PG_DSN" --store-id "$STORE_ID"

# 只检查指定任务
python -m cli.main --tasks CHECK_CUTOFF --pg-dsn "$PG_DSN" --store-id "$STORE_ID" \
  --extra run.cutoff_task_codes=ORDERS,PAYMENTS,MEMBERS
```

---

## 8. DATA_INTEGRITY_CHECK — 数据完整性校验

| 属性 | 值 |
|------|-----|
| 任务代码 | `DATA_INTEGRITY_CHECK` |
| Python 类 | `tasks.utility.data_integrity_task.DataIntegrityTask` |
| 继承 | `BaseTask` |
| 注册 task_type | `verification`（非 utility，但在本文档中一并说明） |
| 用途 | 检查 API → ODS → DWD 全链路数据完整性，支持自动回填缺失数据 |

### 两种运行模式

#### 1. 历史模式（`history`，默认）

从指定起始日期到结束日期，按月分段检查全量历史数据的完整性。

```
execute() [mode=history]
  ├── 解析 history_start / history_end 时间范围
  ├── 调用 run_history_flow()
  │   ├── 按月分段执行完整性检查
  │   ├── 对比 API 记录数 vs ODS 记录数
  │   ├── [可选] 对比内容一致性（content_hash）
  │   └── [可选] 自动回填缺失数据
  └── 生成 JSON 报表
```

#### 2. 窗口模式（`window`）

检查指定时间窗口内的数据完整性，当提供 CLI 窗口覆盖参数时自动切换到此模式。

```
execute() [mode=window]
  ├── 获取时间窗口（支持 CLI 覆盖）
  ├── 构建窗口分段（build_window_segments）
  ├── 调用 run_window_flow()
  │   ├── 逐段执行完整性检查
  │   ├── 汇总缺失/不一致/错误计数
  │   └── [可选] 自动回填 + 复查
  └── 生成 JSON 报表
```

### 校验逻辑

核心校验由 `quality/integrity_service.py` 和 `quality/integrity_checker.py` 实现：

1. **记录数对比**：API 返回的记录数 vs ODS 表中的记录数
2. **内容一致性**（可选）：抽样对比 API 记录与 ODS 记录的 `content_hash`
3. **缺失检测**：识别 API 中存在但 ODS 中缺失的记录
4. **不一致检测**：识别 API 与 ODS 中内容不匹配的记录

### 自动回填

当 `auto_backfill=True` 时，检测到缺失或不一致数据后会自动触发回填：

1. 调用 `scripts/repair/backfill_missing_data.run_backfill()` 重新抓取缺失数据
2. 回填完成后可选复查（`recheck_after_backfill`），验证回填效果

### 配置参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `integrity.mode` | `history` | 运行模式：`history`（历史全量）/ `window`（时间窗口） |
| `integrity.history_start` | `2025-07-01` | 历史模式起始日期 |
| `integrity.history_end` | —（当前时间） | 历史模式结束日期 |
| `integrity.include_dimensions` | `False` | 是否包含维度表检查 |
| `integrity.ods_task_codes` | —（全部） | 限定检查的 ODS 任务代码 |
| `integrity.auto_backfill` | `False` | 是否自动回填缺失数据 |
| `integrity.compare_content` | `True` | 是否对比内容一致性 |
| `integrity.content_sample_limit` | — | 内容对比抽样上限 |
| `integrity.backfill_mismatch` | `True` | 是否回填不一致数据（仅 auto_backfill 时生效） |
| `integrity.recheck_after_backfill` | `True` | 回填后是否复查 |
| `integrity.force_monthly_split` | `True` | 是否强制按月分段 |
| `run.window_override.start` | — | CLI 窗口覆盖起始时间（触发 window 模式） |
| `run.window_override.end` | — | CLI 窗口覆盖结束时间 |

### 输出报表

检查结果以 JSON 格式写入 `reports/` 目录：

- 历史模式：`reports/data_integrity_history_{timestamp}.json`
- 窗口模式：`reports/data_integrity_window_{timestamp}.json`

### CLI 示例

```bash
# 历史全量检查（默认从 2025-07-01 至今）
python -m cli.main --tasks DATA_INTEGRITY_CHECK --pg-dsn "$PG_DSN" \
  --store-id "$STORE_ID" --api-token "$API_TOKEN"

# 指定时间范围
python -m cli.main --tasks DATA_INTEGRITY_CHECK --pg-dsn "$PG_DSN" \
  --store-id "$STORE_ID" --api-token "$API_TOKEN" \
  --extra integrity.history_start=2026-01-01 --extra integrity.history_end=2026-02-01

# 窗口模式 + 自动回填
python -m cli.main --tasks DATA_INTEGRITY_CHECK --pg-dsn "$PG_DSN" \
  --store-id "$STORE_ID" --api-token "$API_TOKEN" \
  --window-start "2026-02-01" --window-end "2026-02-15" \
  --extra integrity.auto_backfill=true
```
