# DWD 层任务详解

> 本文档说明飞球 ETL 系统中 DWD（明细数据层）的所有任务。
> DWD 层负责从 ODS 读取原始数据，经清洗、类型转换和列映射后写入维度表（dim_*）和事实表（dwd_* / fact_*），
> 维度表采用 SCD2 或 Type1 Upsert 策略，事实表按时间增量装载。

---

## 概述

DWD 层共有 2 个已注册任务：

| 任务代码 | Python 类 | 注册参数 | 说明 |
|----------|-----------|----------|------|
| `DWD_LOAD_FROM_ODS` | `DwdLoadTask` | `layer="DWD"` | 核心装载任务：遍历 TABLE_MAP，维度走 SCD2/Type1，事实走增量 |
| `DWD_QUALITY_CHECK` | `DwdQualityTask` | `layer="DWD"`, `task_type="verification"` | ODS 与 DWD 行数/金额核对 |

> 注册位置：`orchestration/task_registry.py`
>
> **历史说明**：早期版本曾有 3 个独立 DWD 任务（TICKET_DWD、PAYMENTS_DWD、MEMBERS_DWD），
> 通过专用 Loader 写入不存在的 `billiards.*` schema。这些任务已于 2026-02-14 废弃删除，
> 其功能由 `DWD_LOAD_FROM_ODS` 的 TABLE_MAP 映射完全覆盖。

---

## DWD_LOAD_FROM_ODS — 核心装载任务

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWD_LOAD_FROM_ODS` |
| Python 类 | `tasks.dwd.dwd_load_task.DwdLoadTask` |
| 继承 | `BaseTask` |
| 数据来源 | `ods.*`（ODS 层各表） |
| 数据目标 | `dwd.*`（维度表 + 事实表，共 40+ 对映射） |
| 事务模式 | 每张表一次独立事务，单表失败回滚后继续后续表 |
| 配置项 | `dwd.only_tables`（可选，限定只处理指定表）、`dwd.fact_upsert`（默认 `True`） |

### 执行流程

```
extract(context) → 返回 {"now": datetime.now()}
    ↓
load(extracted, context) → 遍历 TABLE_MAP
    ↓
    对每张 DWD 表：
    ├─ 获取 DWD 列信息 + ODS 列信息
    ├─ 判断表名前缀：
    │   ├─ dim_* → _merge_dim()（维度合并）
    │   └─ 其他  → _merge_fact_increment()（事实增量）
    ├─ commit（成功）或 rollback（失败）
    └─ 记录 summary / errors
```

### TABLE_MAP 映射表

`TABLE_MAP` 定义了 DWD 表到 ODS 表的完整映射关系。每对映射中，DWD 表名为 key，ODS 表名为 value。
主表与扩展表（`_ex`）共享同一 ODS 源表，通过 `FACT_MAPPINGS` 中的列映射区分写入哪些字段。

#### 维度表映射

| DWD 表 | ODS 源表 | 说明 |
|--------|----------|------|
| `dwd.dim_site` | `ods.table_fee_transactions` | 门店维度（从台费流水的 siteprofile 快照提取） |
| `dwd.dim_site_ex` | `ods.table_fee_transactions` | 门店扩展（灯控、WiFi、客服等） |
| `dwd.dim_table` | `ods.site_tables_master` | 台桌维度 |
| `dwd.dim_table_ex` | `ods.site_tables_master` | 台桌扩展（台布使用时间等） |
| `dwd.dim_assistant` | `ods.assistant_accounts_master` | 助教维度 |
| `dwd.dim_assistant_ex` | `ods.assistant_accounts_master` | 助教扩展（简介、分组、灯控设备等） |
| `dwd.dim_member` | `ods.member_profiles` | 会员维度 |
| `dwd.dim_member_ex` | `ods.member_profiles` | 会员扩展（注册来源、组织等） |
| `dwd.dim_member_card_account` | `ods.member_stored_value_cards` | 会员储值卡维度 |
| `dwd.dim_member_card_account_ex` | `ods.member_stored_value_cards` | 储值卡扩展（电费抵扣、冻结余额等） |
| `dwd.dim_tenant_goods` | `ods.tenant_goods_master` | 租户商品维度 |
| `dwd.dim_tenant_goods_ex` | `ods.tenant_goods_master` | 租户商品扩展（条码、备注等） |
| `dwd.dim_store_goods` | `ods.store_goods_master` | 门店商品维度 |
| `dwd.dim_store_goods_ex` | `ods.store_goods_master` | 门店商品扩展（库存、安全库存等） |
| `dwd.dim_goods_category` | `ods.stock_goods_category_tree` | 商品分类维度（含子类展开） |
| `dwd.dim_groupbuy_package` | `ods.group_buy_packages` | 团购套餐维度 |
| `dwd.dim_groupbuy_package_ex` | `ods.group_buy_packages` | 团购套餐扩展 |
| `dwd.dim_staff` | `ods.staff_info_master` | 员工维度 |
| `dwd.dim_staff_ex` | `ods.staff_info_master` | 员工扩展 |


#### 事实表映射

| DWD 表 | ODS 源表 | 说明 |
|--------|----------|------|
| `dwd.dwd_settlement_head` | `ods.settlement_records` | 结算头（订单结算主记录）— 详见下方「结算头关键字段口径」 |
| `dwd.dwd_settlement_head_ex` | `ods.settlement_records` | 结算头扩展（支付方式、撤单、促销等） |
| `dwd.dwd_table_fee_log` | `ods.table_fee_transactions` | 台费流水 |
| `dwd.dwd_table_fee_log_ex` | `ods.table_fee_transactions` | 台费流水扩展（销售员、消费类型等） |
| `dwd.dwd_table_fee_adjust` | `ods.table_fee_discount_records` | 台费调整/折扣 |
| `dwd.dwd_table_fee_adjust_ex` | `ods.table_fee_discount_records` | 台费调整扩展 |
| `dwd.dwd_store_goods_sale` | `ods.store_goods_sales_records` | 商品销售记录 |
| `dwd.dwd_store_goods_sale_ex` | `ods.store_goods_sales_records` | 商品销售扩展 |
| `dwd.dwd_assistant_service_log` | `ods.assistant_service_records` | 助教服务记录 |
| `dwd.dwd_assistant_service_log_ex` | `ods.assistant_service_records` | 助教服务扩展 |
| ~~`dwd.dwd_assistant_trash_event`~~ | ~~`ods.assistant_cancellation_records`~~ | ~~助教取消/废单事件（2026-02-22 DROP，2026-03-01 清理残留）~~ |
| ~~`dwd.dwd_assistant_trash_event_ex`~~ | ~~`ods.assistant_cancellation_records`~~ | ~~助教取消扩展（2026-02-22 DROP，2026-03-01 清理残留）~~ |
| `dwd.dwd_member_balance_change` | `ods.member_balance_changes` | 会员余额变动 |
| `dwd.dwd_member_balance_change_ex` | `ods.member_balance_changes` | 会员余额变动扩展 |
| `dwd.dwd_groupbuy_redemption` | `ods.group_buy_redemption_records` | 团购核销记录 |
| `dwd.dwd_groupbuy_redemption_ex` | `ods.group_buy_redemption_records` | 团购核销扩展 |
| `dwd.dwd_platform_coupon_redemption` | `ods.platform_coupon_redemption_records` | 平台优惠券核销 |
| `dwd.dwd_platform_coupon_redemption_ex` | `ods.platform_coupon_redemption_records` | 平台优惠券核销扩展 |
| `dwd.dwd_recharge_order` | `ods.recharge_settlements` | 充值订单 |
| `dwd.dwd_recharge_order_ex` | `ods.recharge_settlements` | 充值订单扩展 |
| `dwd.dwd_payment` | `ods.payment_transactions` | 支付记录 |
| `dwd.dwd_refund` | `ods.refund_transactions` | 退款记录 |
| `dwd.dwd_refund_ex` | `ods.refund_transactions` | 退款扩展 |
| `dwd.dwd_goods_stock_summary` | `ods.goods_stock_summary` | 库存汇总 |
| `dwd.dwd_goods_stock_movement` | `ods.goods_stock_movements` | 库存变动 |

> 共计 **19 对维度映射**（含 `_ex`）+ **23 对事实映射**（含 `_ex`，已排除 2026-02-22 DROP 的 assistant_trash_event）= **42 对**有效映射。

---

### 结算头关键字段口径

`dwd_settlement_head` 是核心交易事实表，以下字段在下游消费时需特别注意：

#### settle_type 枚举

| 值 | 含义 | 说明 |
|----|------|------|
| 1 | 台桌结账 | 正常台桌消费结账 |
| 3 | 商城订单 | 商品零售订单 |
| 6 | 退货订单 | 商品退货 |
| 7 | 退款订单 | 金额退款 |

> DWS 层计算发生额、收入等指标时，通常只取 `settle_type IN (1, 3)`（正向交易），排除退货/退款。
> 本表无 `is_delete` 字段，不可用 `is_delete` 过滤。

#### consume_money 口径警告

`consume_money` 存在三种历史口径（A/B/C），**DWS 层不应直接使用**。
应使用 `items_sum` 口径：

```
items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money
```

> 详见 [consume_money 口径校准文档](../../../../docs/reports/DWD-DOC/consume/consume-money-caliber.md)
> 及 [BD 手册 dwd_settlement_head](../database/DWD/main/BD_manual_dwd_settlement_head.md)

#### 支付渠道恒等式

```
balance_amount = recharge_card_amount + gift_card_amount   -- 储值卡 = 充值卡 + 礼品卡
```

> `balance_amount` 是独立支付渠道，`recharge_card_amount` / `gift_card_amount` 是其分账明细，三者不可重复计算。

---

### 维度/事实分流逻辑

`load()` 方法遍历 `TABLE_MAP` 时，根据 DWD 表名前缀自动分流：

```python
if self._table_base(dwd_table).startswith("dim_"):
    # 维度表 → _merge_dim()
else:
    # 事实表 → _merge_fact_increment()
```

`_merge_dim()` 内部进一步判断维度合并策略：

| 条件 | 策略 | 方法 |
|------|------|------|
| DWD 表列中包含 SCD2 列（`scd2_start_time` / `scd2_end_time` / `scd2_is_current` / `scd2_version`） | **SCD2 合并**：关闭旧版 + 插入新版 | `_merge_dim_scd2()` |
| DWD 表列中不包含 SCD2 列 | **Type1 Upsert**：主键冲突则更新 | `_merge_dim_type1_upsert()` |

> SCD2 列集合定义：`SCD_COLS = {"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version"}`

---

### SCD2 处理流程

当维度表包含 SCD2 列时，执行 `_merge_dim_scd2()` 方法，完整流程如下：

#### 1. 最新快照选取

从 ODS 源表中按业务主键取最新快照，使用 `DISTINCT ON` + `fetched_at DESC` 去重：

```sql
SELECT DISTINCT ON (业务主键表达式)
    <列映射表达式>
FROM ods.<ods_table>
WHERE "fetched_at" IS NOT NULL
ORDER BY 业务主键表达式, "fetched_at" DESC NULLS LAST
```

- **业务主键**：从 DWD 表主键中剔除 SCD2 列后得到（`_strip_scd2_keys()`）
- **列映射**：优先使用 `FACT_MAPPINGS` 中的显式映射（支持 JSON 路径提取如 `siteprofile->>'shop_name'`、类型转换如 `::numeric`），其次按同名列直接映射
- **特殊处理**：`dim_goods_category` 表会额外读取 `categoryboxes` 列并展开子分类行（`_expand_goods_category_rows()`）

#### 2. 变更检测

将 ODS 最新快照与 DWD 当前版本（`scd2_is_current=1`）逐列对比：

```python
# 预加载 DWD 当前版本（避免逐行 SELECT）
SELECT * FROM dwd.<dwd_table> WHERE COALESCE(scd2_is_current, 1) = 1

# 逐行对比（跳过 SCD2 列本身）
for col in dwd_cols:
    if col in SCD_COLS: continue
    if not _values_equal(current[col], incoming[col]):
        return True  # 有变更
```

`_values_equal()` 在对比前会做类型归一化：
- **空值归一化**：空字符串 `""` 和 `None` 视为等价
- **日期时间归一化**：朴素时间（naive）与时区感知时间（aware）统一比较
- **布尔值归一化**：`"true"/"false"/"1"/"0"/"yes"/"no"` 等字符串与布尔值统一比较
- **数值归一化**：字符串形式的数字（如 `"3.14"`）与 `Decimal` / `float` 统一比较

#### 3. 版本关闭与新建

对于检测到变更的记录，分两步批量操作：

**步骤 A — 批量关闭旧版本**（`_close_current_dim_bulk()`）：

```sql
-- 单主键优化：使用 ANY 数组
UPDATE dwd.<table>
SET scd2_end_time = <now>, scd2_is_current = 0
WHERE COALESCE(scd2_is_current, 1) = 1 AND "<pk>" = ANY(<ids>)

-- 复合主键：逐条 execute_batch
UPDATE dwd.<table>
SET scd2_end_time = <now>, scd2_is_current = 0
WHERE COALESCE(scd2_is_current, 1) = 1 AND "<pk1>" = %s AND "<pk2>" = %s
```

**步骤 B — 批量插入新版本**（`_insert_dim_rows_bulk()`）：

```sql
INSERT INTO dwd.<table> (<所有列>) VALUES %s
```

新版本行的 SCD2 列填充规则：
| SCD2 列 | 值 |
|---------|-----|
| `scd2_start_time` | 当前时间（`now`） |
| `scd2_end_time` | `9999-12-31 00:00:00`（表示"当前有效"） |
| `scd2_is_current` | `1` |
| `scd2_version` | 旧版本号 + 1（新记录为 1） |

#### 4. 返回统计

```python
{
    "processed": <ODS 去重后总行数>,
    "inserted":  <新增记录数（首次出现的业务主键）>,
    "updated":   <变更记录数（关闭旧版+插入新版）>,
    "skipped":   <无变更跳过数>
}
```

---

### Type1 Upsert 处理流程

当维度表不包含 SCD2 列时，执行 `_merge_dim_type1_upsert()` 方法：

1. 从 ODS 取最新快照（同 SCD2 的 `DISTINCT ON` 逻辑）
2. 按主键去重，跳过主键为 `NULL` 的行
3. 使用 PostgreSQL `INSERT ... ON CONFLICT DO UPDATE` 一次性写入：

```sql
INSERT INTO dwd.<table> (<列>) VALUES %s
ON CONFLICT (<主键>) DO UPDATE SET <非主键列> = EXCLUDED.<列>
WHERE <任一非主键列 IS DISTINCT FROM EXCLUDED 值>  -- 仅在有实际变更时才更新
RETURNING (xmax = 0) AS inserted                    -- 区分新增 vs 更新
```

> Type1 Upsert 不保留历史版本，直接覆盖旧值。SCD2 列（如果存在于列定义中）会被填充默认值（`scd2_start_time=now`, `scd2_version=1`）但不参与冲突判断。

---

### 事实表增量装载

非 `dim_*` 前缀的表走 `_merge_fact_increment()` 方法，核心逻辑如下：

#### 1. 水位线（Watermark）机制

事实表统一使用 `fetched_at` 列作为增量过滤依据，水位线获取优先级：

| 优先级 | 条件 | 行为 |
|--------|------|------|
| 1 | 配置了手动时间窗口（`run.window_override.start/end`） | `WHERE fetched_at >= <start> AND fetched_at < <end>` |
| 2 | 无手动窗口 | 自动计算水位线 `_get_fact_watermark()`，`WHERE fetched_at > <watermark>` |

`_get_fact_watermark()` 的计算逻辑：
- 若 DWD 表包含 `fetched_at` 列 → `SELECT MAX(fetched_at) FROM <dwd_table>`
- 若 DWD 表不含 `fetched_at` 但有主键 → 通过 ODS JOIN DWD 取 `MAX(ods.fetched_at)`
- 兜底 → `"1970-01-01"`（全量装载）

#### 2. 列映射与类型转换

列映射来源（按优先级）：
1. `FACT_MAPPINGS` 中的显式映射：`(dwd_列名, ods_源表达式, 可选类型转换)`
2. DWD 与 ODS 同名列直接映射
3. 主键兜底：若 DWD 主键在 ODS 中不存在但 ODS 有 `id` 列，自动映射 `pk → id`

类型转换（`_build_fact_select_exprs()`）：
- 当 DWD 列为数值类型（`integer`/`numeric`/`bigint` 等）而 ODS 列为文本类型时，自动添加 `CAST(NULLIF(CAST("<col>" AS text), '') AS <type>)`

#### 3. 快照去重

若 ODS 表包含 `content_hash` 列（`snapshot_mode=True`），则使用 `DISTINCT ON` 按主键 + `fetched_at DESC` 取最新快照，避免重复记录。

#### 4. 写入策略

根据配置 `dwd.fact_upsert`（默认 `True`）和 `snapshot_mode` 决定冲突处理：

| 条件 | SQL 策略 |
|------|----------|
| `snapshot_mode=True` 或 `fact_upsert=True` | `ON CONFLICT (<pk>) DO UPDATE SET ... WHERE <任一列 IS DISTINCT FROM>` |
| `fact_upsert=False` 且无 `content_hash` | `ON CONFLICT (<pk>) DO NOTHING` |

写入后通过 `RETURNING (xmax = 0) AS inserted` 区分新增（`xmax=0`）和更新（`xmax≠0`）。

#### 5. 缺失主键回补

对于可能出现"回补旧记录"的事实表（如 `dwd_assistant_service_log`，定义在 `FACT_MISSING_FILL_TABLES` 中），
在主增量写入完成后，额外执行 `_insert_missing_by_pk()`：

```sql
INSERT INTO <dwd_table> (<列>)
SELECT <列> FROM <ods_table> o
LEFT JOIN <dwd_table> d ON d.<pk> = o.<pk>
WHERE d.<pk> IS NULL
  AND o.fetched_at > <watermark>  -- 同样受水位线约束
ON CONFLICT (<pk>) DO NOTHING
```

> 此步骤确保因时序乱序导致的遗漏记录能被补齐。

#### 6. FACT_MAPPINGS 列映射详解

`FACT_MAPPINGS` 是一个字典，key 为 DWD 表全名，value 为三元组列表 `(dwd_列名, ods_源表达式, 类型转换)`。

映射类型示例：

| 映射类型 | 示例 | 说明 |
|----------|------|------|
| 简单重命名 | `("table_id", "id", None)` | ODS `id` → DWD `table_id` |
| JSON 路径提取 | `("shop_name", "siteprofile->>'shop_name'", None)` | 从 JSONB 字段提取 |
| 类型转换 | `("longitude", "siteprofile->>'longitude'", "numeric")` | 提取后转 `numeric` |
| 布尔转换 | `("is_first_limit", "is_first_limit", "boolean")` | 转布尔类型 |
| 日期转换 | `("pay_date", "pay_time", "date")` | 时间戳截断为日期 |
| SQL 表达式 | `("category_level", "CASE WHEN pid = 0 THEN 1 ELSE 2 END", None)` | 计算列 |

> 维度表和事实表共用 `FACT_MAPPINGS`（名称虽含 "FACT" 但实际覆盖所有表）。

---

### 配置与环境变量

| 配置项 | 来源 | 说明 |
|--------|------|------|
| `dwd.only_tables` | AppConfig / 环境变量 `DWD_ONLY_TABLES` | 逗号分隔的表名列表，限定只处理指定表（支持全名或短名） |
| `dwd.fact_upsert` | AppConfig | 事实表是否使用 upsert（默认 `True`），设为 `False` 则用 `DO NOTHING` |
| `run.window_override.start` / `end` | AppConfig | 手动指定时间窗口，覆盖自动水位线 |

---

### 错误处理

- 每张表独立事务：成功则 `commit`，失败则 `rollback` 并记录错误后继续下一张表
- 主键缺失的行会被跳过并记录警告日志
- ODS 表缺少 `fetched_at` 列时跳过该表并记录错误
- `fetched_at` 为 `NULL` 的 ODS 行会被过滤（`WHERE fetched_at IS NOT NULL`）
- 最终返回 `{"tables": [<各表统计>], "errors": [<失败表及原因>]}`


---

## DWD_QUALITY_CHECK — 数据质量检查

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWD_QUALITY_CHECK` |
| Python 类 | `tasks.dwd.dwd_quality_task.DwdQualityTask` |
| 继承 | `BaseTask` |
| 任务类型 | `verification`（校验类任务） |
| 数据来源 | `DwdLoadTask.TABLE_MAP` 中定义的所有 ODS / DWD 表对（40 对） |
| 输出文件 | `reports/dwd_quality_report.json` |

### 用途

在 `DWD_LOAD_FROM_ODS` 装载完成后，对 ODS 与 DWD 两端进行**行数**和**金额**的交叉核对，
自动生成 JSON 格式的质检报表。用于发现装载过程中可能出现的数据丢失或金额偏差。

---

### 执行流程

```
extract(context) → 返回 {"now": datetime.now()}
    ↓
load(extracted, context)
    ↓
    遍历 DwdLoadTask.TABLE_MAP 中的每对 (dwd_table, ods_table)：
    ├─ _compare_counts()  → 行数核对
    ├─ _compare_amounts() → 金额核对
    └─ 结果追加到 report["tables"]
    ↓
    写入 reports/dwd_quality_report.json
```

> 注意：本任务不执行 `transform()` 阶段，直接在 `load()` 中完成查询与报表输出。

---

### 行数核对逻辑

`_compare_counts(cur, dwd_table, ods_table)` 分别对 DWD 表和 ODS 表执行 `COUNT(1)`，
返回两端行数及差值：

```sql
-- DWD 端
SELECT COUNT(1) AS cnt FROM "dwd"."<dwd_table>"

-- ODS 端
SELECT COUNT(1) AS cnt FROM "ods"."<ods_table>"
```

返回结构：

```json
{
  "dwd": 12345,
  "ods": 12350,
  "diff": -5
}
```

- `diff = dwd - ods`：正值表示 DWD 多于 ODS（可能因 SCD2 产生多版本），负值表示 DWD 少于 ODS（可能有数据丢失）
- 维度表因 SCD2 历史版本的存在，DWD 行数通常 ≥ ODS 行数，`diff > 0` 属于正常现象
- 事实表理论上 DWD 行数应 ≤ ODS 行数（去重后），`diff > 0` 需要关注

**表名解析**：`_split_table_name()` 将全限定名（如 `dwd.dim_member`）拆分为 `(schema, table)`。
若表名不含 `.`，则使用默认 schema（DWD 端默认 `dwd`，ODS 端默认 `ods`）。

---

### 金额列自动扫描规则

`_compare_amounts(cur, dwd_table, ods_table)` 自动识别两端表中的金额相关列，
对公共列逐列汇总对比。

#### 扫描机制

通过 `_get_numeric_amount_columns(cur, schema, table)` 从 `information_schema.columns` 查询：

```sql
SELECT column_name
FROM information_schema.columns
WHERE table_schema = %s
  AND table_name = %s
  AND data_type IN ('numeric', 'double precision', 'integer', 'bigint', 'smallint', 'real', 'decimal')
```

在返回的数值型列中，进一步按**列名关键词**过滤，只保留列名中包含以下任一关键词的列：

| 关键词 | 匹配示例 |
|--------|----------|
| `amount` | `pay_amount`, `discount_amount`, `actual_amount` |
| `money` | `consume_money`, `member_money` |
| `fee` | `table_fee`, `service_fee`, `fee_amount` |
| `balance` | `card_balance`, `gift_balance` |

> 关键词定义：`AMOUNT_KEYWORDS = ("amount", "money", "fee", "balance")`
> 匹配规则：列名（小写）包含关键词即命中，如 `pay_amount` 包含 `amount`。

#### 公共列取交集

分别获取 DWD 端和 ODS 端的金额列后，取**交集**（`set(dwd_cols) & set(ods_cols)`）并排序，
只对两端都存在的同名金额列进行汇总对比。

#### 汇总对比

对每个公共金额列执行 `SUM()`：

```sql
-- DWD 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "dwd"."<dwd_table>"

-- ODS 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "ods"."<ods_table>"
```

返回结构（每个金额列一条记录）：

```json
{
  "column": "pay_amount",
  "dwd_sum": 98765.50,
  "ods_sum": 98770.00,
  "diff": -4.50
}
```

- `diff = dwd_sum - ods_sum`：非零值表示两端金额不一致，需排查原因
- `COALESCE(..., 0)` 确保 `NULL` 值不影响汇总结果
- 若某对表没有公共金额列，`amounts` 数组为空

---

### JSON 报表输出格式

报表写入路径：`reports/dwd_quality_report.json`（目录不存在时自动创建）。

#### 完整结构

```json
{
  "generated_at": "2025-01-15T14:30:00.123456",
  "tables": [
    {
      "dwd_table": "dwd.dim_member",
      "ods_table": "ods.member_profiles",
      "count": {
        "dwd": 1200,
        "ods": 1000,
        "diff": 200
      },
      "amounts": [
        {
          "column": "balance",
          "dwd_sum": 50000.00,
          "ods_sum": 50000.00,
          "diff": 0.0
        }
      ]
    },
    {
      "dwd_table": "dwd.dwd_payment",
      "ods_table": "ods.payment_transactions",
      "count": {
        "dwd": 5000,
        "ods": 5000,
        "diff": 0
      },
      "amounts": [
        {
          "column": "pay_amount",
          "dwd_sum": 123456.78,
          "ods_sum": 123456.78,
          "diff": 0.0
        },
        {
          "column": "fee_amount",
          "dwd_sum": 1234.56,
          "ods_sum": 1234.56,
          "diff": 0.0
        }
      ]
    }
  ],
  "note": "行数/金额核对，金额字段基于列名包含 amount/money/fee/balance 的数值列自动扫描。"
}
```

#### 字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `generated_at` | `string` | 报表生成时间（ISO 8601 格式） |
| `tables` | `array` | 每对 DWD↔ODS 表的核对结果 |
| `tables[].dwd_table` | `string` | DWD 表全限定名 |
| `tables[].ods_table` | `string` | ODS 表全限定名 |
| `tables[].count` | `object` | 行数核对结果 |
| `tables[].count.dwd` | `integer` | DWD 端行数 |
| `tables[].count.ods` | `integer` | ODS 端行数 |
| `tables[].count.diff` | `integer` | 行数差值（`dwd - ods`） |
| `tables[].amounts` | `array` | 金额列核对结果（可能为空数组） |
| `tables[].amounts[].column` | `string` | 金额列名 |
| `tables[].amounts[].dwd_sum` | `float` | DWD 端汇总值 |
| `tables[].amounts[].ods_sum` | `float` | ODS 端汇总值 |
| `tables[].amounts[].diff` | `float` | 金额差值（`dwd_sum - ods_sum`） |
| `note` | `string` | 报表说明文字 |

---

### 注意事项

- **全量扫描**：本任务对 TABLE_MAP 中所有 40 对表执行全表 `COUNT` 和 `SUM`，在数据量较大时可能耗时较长
- **不区分增量**：行数和金额对比基于全表统计，不受时间窗口限制
- **SCD2 影响**：维度表因 SCD2 历史版本的存在，DWD 行数通常大于 ODS 行数，这是预期行为
- **列名匹配大小写**：金额列扫描时将列名统一转为小写后匹配关键词
- **报表覆盖**：每次运行会覆盖上一次的报表文件（`reports/dwd_quality_report.json`）
