init: 项目初始提交 - NeoZQYY Monorepo 完整代码

This commit is contained in:
Neo
2026-02-15 14:58:14 +08:00
commit ded6dfb9d8
769 changed files with 182616 additions and 0 deletions

View File

@@ -0,0 +1,346 @@
# 飞球 ETL 任务说明文档
> 本文档是飞球 ETL 系统etl-billiards任务说明的总览入口。
> 系统从上游 SaaS API 抽取台球门店运营数据,经 ODS → DWD → DWS 三层处理后,
> 输出助教业绩、财务日报、会员分析、工资计算及自定义指数等业务报表。
## 目录
- [数据流向](#数据流向)
- [文档索引](#文档索引)
- [任务清单](#任务清单)
- [ODS 层(操作数据存储)](#ods-层操作数据存储)
- [DWD 层(明细数据)](#dwd-层明细数据)
- [DWS 层(数据服务)](#dws-层数据服务)
- [INDEX 层(指数算法)](#index-层指数算法)
- [工具类 / 校验类](#工具类--校验类)
- [管道类型](#管道类型)
- [处理模式](#处理模式)
- [数据源模式](#数据源模式)
- [CLI 参数速查表](#cli-参数速查表)
- [常见命令示例](#常见命令示例)
---
## 数据流向
```mermaid
graph LR
API["上游 SaaS API"] -->|抓取| ODS["ODS<br/>操作数据存储层"]
JSON["本地 JSON 文件"] -->|手动入库| ODS
ODS -->|清洗 / SCD2 / 增量| DWD["DWD<br/>明细数据层"]
DWD -->|聚合 / 计算| DWS["DWS<br/>数据服务层"]
DWD -->|指数算法| INDEX["INDEX<br/>指数算法层"]
DWS --> REPORT["业务报表<br/>助教业绩 · 财务日报<br/>会员分析 · 工资计算"]
INDEX --> REPORT
```
**层级说明:**
| 层 | Schema | 职责 |
|---|---|---|
| ODS | `billiards_ods` | 保留 API 原始 payload便于回溯 |
| DWD | `billiards_dwd` | 清洗后的维度表dim_*SCD2和事实表fact_* / dwd_*,增量) |
| DWS | `billiards_dws` | 按业务维度聚合的汇总统计表 |
| INDEX | `billiards_dws` | 基于 DWD/DWS 数据计算的自定义业务指数 |
---
## 文档索引
| 文档 | 说明 |
|------|------|
| [BaseTask 公共机制](base_task_mechanism.md) | 任务基类模板方法、TaskContext、时间窗口、注册表、管道执行 |
| [ODS 层任务](ods_tasks.md) | 23 个通用 ODS 任务的架构、配置结构、API 端点、目标表 |
| [DWD 层任务](dwd_tasks.md) | DWD_LOAD_FROM_ODS 核心装载、SCD2 处理、质量校验 |
| [DWS 层任务](dws_tasks.md) | 助教业绩、会员分析、财务统计、运维任务共 15 个 DWS 任务 |
| [INDEX 层任务](index_tasks.md) | WBI/NCI/RS 指数算法 + ML 手动台账导入 |
| [工具类任务](utility_tasks.md) | Schema 初始化、手动入库、归档、截止检查、完整性校验 |
---
## 任务清单
### ODS 层(操作数据存储)
#### 通用 ODS 任务OdsTaskSpec 动态注册)
| 任务代码 | Python 类 | 目标表 | 简要说明 | 详情 |
|----------|-----------|--------|----------|------|
| `ODS_ASSISTANT_ACCOUNT` | `OdsAssistantAccountsTask` | `billiards_ods.assistant_accounts_master` | 助教账号档案 | [查看](ods_tasks.md) |
| `ODS_ASSISTANT_LEDGER` | `OdsAssistantLedgerTask` | `billiards_ods.assistant_service_records` | 助教服务流水 | [查看](ods_tasks.md) |
| `ODS_ASSISTANT_ABOLISH` | `OdsAssistantAbolishTask` | `billiards_ods.assistant_cancellation_records` | 助教废除记录 | [查看](ods_tasks.md) |
| `ODS_INVENTORY_CHANGE` | `OdsInventoryChangeTask` | `billiards_ods.goods_stock_movements` | 库存变化记录 | [查看](ods_tasks.md) |
| `ODS_INVENTORY_STOCK` | `OdsInventoryStockTask` | `billiards_ods.goods_stock_summary` | 库存汇总 | [查看](ods_tasks.md) |
| `ODS_GROUP_PACKAGE` | `OdsPackageTask` | `billiards_ods.group_buy_packages` | 团购套餐定义 | [查看](ods_tasks.md) |
| `ODS_GROUP_BUY_REDEMPTION` | `OdsGroupBuyRedemptionTask` | `billiards_ods.group_buy_redemption_records` | 团购套餐核销 | [查看](ods_tasks.md) |
| `ODS_MEMBER` | `OdsMemberTask` | `billiards_ods.member_profiles` | 会员档案 | [查看](ods_tasks.md) |
| `ODS_MEMBER_BALANCE` | `OdsMemberBalanceTask` | `billiards_ods.member_balance_changes` | 会员余额变动 | [查看](ods_tasks.md) |
| `ODS_MEMBER_CARD` | `OdsMemberCardTask` | `billiards_ods.member_stored_value_cards` | 会员储值卡 | [查看](ods_tasks.md) |
| `ODS_PAYMENT` | `OdsPaymentTask` | `billiards_ods.payment_transactions` | 支付流水 | [查看](ods_tasks.md) |
| `ODS_REFUND` | `OdsRefundTask` | `billiards_ods.refund_transactions` | 退款流水 | [查看](ods_tasks.md) |
| `ODS_PLATFORM_COUPON` | `OdsCouponVerifyTask` | `billiards_ods.platform_coupon_redemption_records` | 平台/团购券核销 | [查看](ods_tasks.md) |
| `ODS_RECHARGE_SETTLE` | `OdsRechargeSettleTask` | `billiards_ods.recharge_settlements` | 充值结算 | [查看](ods_tasks.md) |
| `ODS_TABLE_USE` | `OdsTableUseTask` | `billiards_ods.table_fee_transactions` | 台费计费流水 | [查看](ods_tasks.md) |
| `ODS_TABLES` | `OdsTablesTask` | `billiards_ods.site_tables_master` | 台桌维表 | [查看](ods_tasks.md) |
| `ODS_GOODS_CATEGORY` | `OdsGoodsCategoryTask` | `billiards_ods.stock_goods_category_tree` | 库存商品分类 | [查看](ods_tasks.md) |
| `ODS_STORE_GOODS` | `OdsStoreGoodsTask` | `billiards_ods.store_goods_master` | 门店商品档案 | [查看](ods_tasks.md) |
| `ODS_TABLE_FEE_DISCOUNT` | `OdsTableDiscountTask` | `billiards_ods.table_fee_discount_records` | 台费折扣/调账 | [查看](ods_tasks.md) |
| `ODS_STORE_GOODS_SALES` | `OdsGoodsLedgerTask` | `billiards_ods.store_goods_sales_records` | 门店商品销售流水 | [查看](ods_tasks.md) |
| `ODS_TENANT_GOODS` | `OdsTenantGoodsTask` | `billiards_ods.tenant_goods_master` | 租户商品档案 | [查看](ods_tasks.md) |
| `ODS_SETTLEMENT_TICKET` | `OdsSettlementTicketTask` | `billiards_ods.settlement_ticket_details` | 结账小票详情 | [查看](ods_tasks.md) |
| `ODS_SETTLEMENT_RECORDS` | `OdsOrderSettleTask` | `billiards_ods.settlement_records` | 结账记录 | [查看](ods_tasks.md) |
### DWD 层(明细数据)
| 任务代码 | Python 类 | 简要说明 | 详情 |
|----------|-----------|----------|------|
| `DWD_LOAD_FROM_ODS` | `DwdLoadTask` | 核心装载:遍历 TABLE_MAP维度走 SCD2事实走增量 | [查看](dwd_tasks.md) |
| `DWD_QUALITY_CHECK` | `DwdQualityTask` | ODS 与 DWD 行数/金额核对,输出 JSON 报表 | [查看](dwd_tasks.md) |
### DWS 层(数据服务)
#### 助教业绩域
| 任务代码 | Python 类 | 目标表 | 粒度 | 详情 |
|----------|-----------|--------|------|------|
| `DWS_ASSISTANT_DAILY` | `AssistantDailyTask` | `dws_assistant_daily_detail` | 日期+助教 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_MONTHLY` | `AssistantMonthlyTask` | `dws_assistant_monthly_summary` | 月份+助教 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_CUSTOMER` | `AssistantCustomerTask` | `dws_assistant_customer_stats` | 日期+助教+会员 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_SALARY` | `AssistantSalaryTask` | `dws_assistant_salary_calc` | 月份+助教 | [查看](dws_tasks.md) |
| `DWS_ASSISTANT_FINANCE` | `AssistantFinanceTask` | `dws_assistant_finance_analysis` | 日期+助教 | [查看](dws_tasks.md) |
#### 会员分析域
| 任务代码 | Python 类 | 目标表 | 粒度 | 详情 |
|----------|-----------|--------|------|------|
| `DWS_MEMBER_CONSUMPTION` | `MemberConsumptionTask` | `dws_member_consumption_summary` | 日期+会员 | [查看](dws_tasks.md) |
| `DWS_MEMBER_VISIT` | `MemberVisitTask` | `dws_member_visit_detail` | 日期+会员+结账单 | [查看](dws_tasks.md) |
#### 财务统计域
| 任务代码 | Python 类 | 目标表 | 粒度 | 详情 |
|----------|-----------|--------|------|------|
| `DWS_FINANCE_DAILY` | `FinanceDailyTask` | `dws_finance_daily_summary` | 日期 | [查看](dws_tasks.md) |
| `DWS_FINANCE_RECHARGE` | `FinanceRechargeTask` | `dws_finance_recharge_summary` | 日期 | [查看](dws_tasks.md) |
| `DWS_FINANCE_INCOME_STRUCTURE` | `FinanceIncomeStructureTask` | `dws_finance_income_structure` | 日期+收入类型 | [查看](dws_tasks.md) |
| `DWS_FINANCE_DISCOUNT_DETAIL` | `FinanceDiscountDetailTask` | `dws_finance_discount_detail` | 日期+折扣类型 | [查看](dws_tasks.md) |
#### 运维任务
| 任务代码 | Python 类 | 简要说明 | 详情 |
|----------|-----------|----------|------|
| `DWS_BUILD_ORDER_SUMMARY` | `DwsBuildOrderSummaryTask` | 构建订单汇总中间表 | [查看](dws_tasks.md) |
| `DWS_RETENTION_CLEANUP` | `DwsRetentionCleanupTask` | 按时间分层清理历史数据 | [查看](dws_tasks.md) |
| `DWS_MV_REFRESH_FINANCE_DAILY` | `DwsMvRefreshFinanceDailyTask` | 刷新财务日报物化视图 | [查看](dws_tasks.md) |
| `DWS_MV_REFRESH_ASSISTANT_DAILY` | `DwsMvRefreshAssistantDailyTask` | 刷新助教日报物化视图 | [查看](dws_tasks.md) |
### INDEX 层(指数算法)
| 任务代码 | Python 类 | 目标表 | 指数类型 | 详情 |
|----------|-----------|--------|----------|------|
| `DWS_WINBACK_INDEX` | `WinbackIndexTask` | `dws_member_winback_index` | WBI回流指数 | [查看](index_tasks.md) |
| `DWS_NEWCONV_INDEX` | `NewconvIndexTask` | `dws_member_newconv_index` | NCI新客转化指数 | [查看](index_tasks.md) |
| `DWS_RELATION_INDEX` | `RelationIndexTask` | `dws_relation_index` | RS关系指数 | [查看](index_tasks.md) |
| `DWS_ML_MANUAL_IMPORT` | `MlManualImportTask` | `dws_ml_manual_ledger` | ML手动台账导入 | [查看](index_tasks.md) |
### 工具类 / 校验类
| 任务代码 | Python 类 | 类型 | 简要说明 | 详情 |
|----------|-----------|------|----------|------|
| `INIT_ODS_SCHEMA` | `InitOdsSchemaTask` | utility | 执行 ODS + etl_admin DDL创建必要目录 | [查看](utility_tasks.md) |
| `INIT_DWD_SCHEMA` | `InitDwdSchemaTask` | utility | 执行 DWD DDL | [查看](utility_tasks.md) |
| `INIT_DWS_SCHEMA` | `InitDwsSchemaTask` | utility | 执行 DWS DDL | [查看](utility_tasks.md) |
| `MANUAL_INGEST` | `ManualIngestTask` | utility | 从本地 JSON 文件手动入库到 ODS | [查看](utility_tasks.md) |
| `ODS_JSON_ARCHIVE` | `OdsJsonArchiveTask` | utility | 归档 ODS JSON 文件 | [查看](utility_tasks.md) |
| `CHECK_CUTOFF` | `CheckCutoffTask` | utility | 检查数据截止时间 | [查看](utility_tasks.md) |
| `SEED_DWS_CONFIG` | `SeedDwsConfigTask` | utility | 初始化 DWS 配置种子数据 | [查看](utility_tasks.md) |
| `DATA_INTEGRITY_CHECK` | `DataIntegrityTask` | verification | 数据完整性校验 | [查看](utility_tasks.md) |
---
## 管道类型
管道Pipeline定义了多层任务的执行顺序。通过 `--pipeline` 参数指定,系统自动解析对应层并按顺序执行该层的所有已注册任务。
| 管道类型 | 包含层 | 说明 |
|----------|--------|------|
| `api_ods` | ODS | 仅从 API 抓取数据到 ODS |
| `api_ods_dwd` | ODS → DWD | 抓取数据并清洗装载到 DWD |
| `api_full` | ODS → DWD → DWS → INDEX | 全流程:抓取 → 清洗 → 汇总 → 指数 |
| `ods_dwd` | DWD | 仅执行 ODS → DWD 清洗装载(不抓取) |
| `dwd_dws` | DWS | 仅执行 DWD → DWS 汇总计算 |
| `dwd_dws_index` | DWS → INDEX | 汇总计算 + 指数算法 |
| `dwd_index` | INDEX | 仅执行指数算法 |
> 管道定义位于 `orchestration/pipeline_runner.py` 的 `PipelineRunner.PIPELINE_LAYERS`。
---
## 处理模式
通过 `--processing-mode` 参数指定,控制管道的执行行为。
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| `increment_only` | 仅增量处理(默认) | 日常定时调度,只处理新增/变更数据 |
| `verify_only` | 仅校验并修复,跳过增量 ETL | 数据质量巡检、手动修复不一致 |
| `increment_verify` | 先增量处理,再校验并修复 | 需要确保数据一致性的关键批次 |
**补充参数:**
- `--fetch-before-verify`:仅在 `verify_only` 模式下有效,校验前先从 API 获取最新数据
- `--verify-tables`:指定仅校验的表名(逗号分隔),用于单表验证
---
## 数据源模式
通过 `--data-source` 参数指定,控制 ODS 层的数据来源。
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| `online` | 仅在线抓取(从 API 获取数据) | 正常运行,网络可用 |
| `offline` | 仅本地入库(从 JSON 文件读取) | 离线环境、JSON 回放 |
| `hybrid` | 抓取 + 入库(默认) | 同时从 API 抓取并处理本地文件 |
> 旧参数 `--pipeline-flow``FULL` / `FETCH_ONLY` / `INGEST_ONLY`)已弃用,请使用 `--data-source`。
---
## CLI 参数速查表
入口命令:`python -m cli.main`
### 基本参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--store-id` | int | — | 门店 ID |
| `--tasks` | str | — | 任务列表,逗号分隔(传统模式) |
| `--dry-run` | flag | `false` | 试运行,不提交数据库事务 |
### 管道与模式参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--pipeline` | choice | — | 管道类型(见[管道类型](#管道类型) |
| `--processing-mode` | choice | `increment_only` | 处理模式(见[处理模式](#处理模式) |
| `--data-source` | choice | `hybrid` | 数据源模式(见[数据源模式](#数据源模式) |
| `--fetch-before-verify` | flag | `false` | 校验前先从 API 获取数据(仅 `verify_only` |
| `--verify-tables` | str | — | 仅校验指定表(逗号分隔) |
### 时间窗口参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--window-start` | datetime | — | 固定时间窗口开始(优先级高于游标) |
| `--window-end` | datetime | — | 固定时间窗口结束 |
| `--force-window-override` | flag | `false` | 强制使用 window_start/window_end不走 MAX(fetched_at) 兜底 |
| `--window-split` | choice | `none` | 时间窗口切分:`none` / `day` / `week` / `month` |
| `--window-split-unit` | str | 配置值 | 窗口切分单位(`day`/`week`/`month`/`none` |
| `--window-split-days` | int | 配置值 | 按天切分的天数(`1`/`10`/`30` |
| `--window-compensation-hours` | int | 配置值 | 窗口前后补偿小时数 |
| `--lookback-hours` | int | `24` | 回溯小时数 |
| `--overlap-seconds` | int | `3600` | 冗余秒数(默认 1 小时) |
### 数据库参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--pg-dsn` | str | — | PostgreSQL DSN 连接串 |
| `--pg-host` | str | — | PostgreSQL 主机 |
| `--pg-port` | int | — | PostgreSQL 端口 |
| `--pg-name` | str | — | PostgreSQL 数据库名 |
| `--pg-user` | str | — | PostgreSQL 用户名 |
| `--pg-password` | str | — | PostgreSQL 密码 |
### API 参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--api-base` | str | — | API 基础 URL |
| `--api-token` | str | — | API 令牌Bearer Token |
| `--api-timeout` | int | — | API 超时(秒) |
| `--api-page-size` | int | — | 分页大小 |
| `--api-retry-max` | int | — | API 重试最大次数 |
### 目录与运行参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--export-root` | str | — | 导出根目录 |
| `--log-root` | str | — | 日志根目录 |
| `--fetch-root` | str | — | 抓取 JSON 输出根目录 |
| `--ingest-source` | str | — | 本地清洗入库源目录 |
| `--write-pretty-json` | flag | `false` | 抓取 JSON 美化输出 |
| `--idle-start` | str | — | 闲时窗口开始HH:MM |
| `--idle-end` | str | — | 闲时窗口结束HH:MM |
| `--allow-empty-advance` | flag | `false` | 允许空结果推进窗口 |
### 已弃用参数
| 参数 | 替代方案 | 说明 |
|------|----------|------|
| `--pipeline-flow` | `--data-source` | `FULL``hybrid``FETCH_ONLY``online``INGEST_ONLY``offline` |
---
## 常见命令示例
```bash
# 全流程 ETLAPI 抓取 → ODS → DWD → DWS → INDEX
python -m cli.main --pipeline api_full --pg-dsn "$PG_DSN" --store-id 1 --api-token "$TOKEN"
# 仅抓取 ODS 数据
python -m cli.main --pipeline api_ods --store-id 1
# ODS → DWD 清洗装载(不抓取 API
python -m cli.main --pipeline ods_dwd
# 仅执行 DWS 汇总
python -m cli.main --pipeline dwd_dws
# 仅执行指数算法
python -m cli.main --pipeline dwd_index
# 指定时间窗口
python -m cli.main --pipeline api_ods --window-start "2026-02-01" --window-end "2026-02-02"
# 按天切分时间窗口
python -m cli.main --pipeline api_ods --window-start "2026-01-01" --window-end "2026-02-01" --window-split day
# 传统模式:指定任务列表
python -m cli.main --tasks ODS_PAYMENT,ODS_MEMBER,ODS_SETTLEMENT_RECORDS --store-id 1
# 校验并修复(跳过增量)
python -m cli.main --pipeline api_full --processing-mode verify_only
# 校验前先从 API 获取数据
python -m cli.main --pipeline api_full --processing-mode verify_only --fetch-before-verify
# 增量 + 校验
python -m cli.main --pipeline api_full --processing-mode increment_verify
# 仅校验指定表
python -m cli.main --pipeline api_full --processing-mode verify_only --verify-tables "dim_member,fact_payment"
# 试运行(不提交)
python -m cli.main --dry-run --tasks DWD_LOAD_FROM_ODS
# Schema 初始化
python -m cli.main --tasks INIT_ODS_SCHEMA,INIT_DWD_SCHEMA,INIT_DWS_SCHEMA
# 手动入库(离线模式)
python -m cli.main --tasks MANUAL_INGEST --data-source offline --ingest-source ./data/json
# DWS 配置种子数据初始化
python -m cli.main --tasks SEED_DWS_CONFIG
# 数据完整性校验
python -m cli.main --tasks DATA_INTEGRITY_CHECK
```
---
> 最后更新日期2026-02-14

View File

@@ -0,0 +1,435 @@
# BaseTask 公共机制与执行参数
> 本文档说明飞球 ETL 系统中所有任务共享的基类机制、运行期上下文、时间窗口计算、任务注册以及管道执行流程。
> 面向开发者,便于在开发新任务时遵循统一模式。
---
## 1. BaseTask 模板方法流程
`BaseTask`(位于 `tasks/base_task.py`)采用经典的**模板方法模式**,定义了 ETL 任务的标准执行骨架。所有具体任务ODS / DWD / DWS / INDEX均继承此基类。
### 1.1 核心方法签名
```python
class BaseTask:
def __init__(self, config, db_connection, api_client, logger): ...
def get_task_code(self) -> str: ... # 子类必须实现
def extract(self, context: TaskContext): ... # 子类必须实现
def transform(self, extracted, context: TaskContext): ... # 默认直接返回 extracted
def load(self, transformed, context: TaskContext) -> dict: ... # 子类必须实现
def execute(self, cursor_data: dict | None = None) -> dict: ... # 主入口
```
### 1.2 execute() 执行流程
`execute()` 是任务的统一入口由调度器TaskExecutor调用。完整流程如下
```
execute(cursor_data)
├─ 1. _build_context(cursor_data) → 构建 TaskContext含时间窗口计算
├─ 2. build_window_segments(...) → 按配置切分时间窗口为多段
│ 若无切分配置 → 退化为单段 [(window_start, window_end)]
├─ 3. 遍历每个窗口段 (window_start, window_end)
│ │
│ ├─ _build_context_for_window(...) → 为当前段构建独立 TaskContext
│ │
│ ├─ extract(context) → 从数据源提取数据
│ │
│ ├─ transform(extracted, context) → 清洗/转换数据
│ │
│ ├─ load(transformed, context) → 写入目标表,返回统计 counts
│ │
│ ├─ db.commit() → 提交事务
│ │
│ └─ _accumulate_counts(...) → 累加各段统计
└─ 4. 构建并返回结果字典
{status, counts, window: {start, end, minutes}, segments: [...]}
```
**关键行为:**
- 每个窗口段独立执行 E/T/L 三步,段内失败会 `db.rollback()` 并抛出异常
- 多段执行时,日志会输出进度信息(已处理天数 / 总天数)
- `transform()` 默认实现为直接返回 `extracted`,子类可选择性覆盖
- 统计累加逻辑数值类型int/float求和其他类型取首次出现的值
---
## 2. TaskContext 字段含义
`TaskContext` 是一个不可变数据类(`frozen=True`),在 E/T/L 各阶段间传递运行期信息。
```python
@dataclass(frozen=True)
class TaskContext:
store_id: int # 门店 ID
window_start: datetime # 时间窗口起始(含时区)
window_end: datetime # 时间窗口结束(含时区)
window_minutes: int # 窗口时长(分钟)
cursor: dict | None = None # 游标数据(来自上次运行记录)
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `store_id` | `int` | 当前门店标识,从 `config.get("app.store_id")` 读取 |
| `window_start` | `datetime` | 本次抽取的时间窗口起点,带时区信息 |
| `window_end` | `datetime` | 本次抽取的时间窗口终点,带时区信息 |
| `window_minutes` | `int` | `window_end - window_start` 的分钟数(最小为 1 |
| `cursor` | `dict \| None` | 调度器传入的游标字典,通常包含 `last_end`(上次窗口终点)等字段;首次运行时为 `None` |
---
## 3. 时间窗口计算逻辑
时间窗口由 `_get_time_window(cursor_data)` 方法计算,决定了本次任务抽取的数据范围。优先级从高到低:
### 3.1 优先级一手动覆盖Manual Override
当配置中同时存在 `run.window_override.start``run.window_override.end` 时,直接使用用户指定的窗口。
- 对应 CLI 参数:`--window-start` / `--window-end`
- 支持字符串自动解析(通过 `dateutil.parser.parse`
- 无时区信息时自动附加 `app.timezone`(默认 `Asia/Shanghai`
- 校验:两者必须同时提供,且 `end > start`
```
window_start = run.window_override.start
window_end = run.window_override.end
window_minutes = (end - start) 的分钟数
```
### 3.2 优先级二游标续跑Cursor Resume
当存在游标数据且包含 `last_end` 字段时,从上次结束位置回退一段重叠时间开始:
```
window_start = cursor["last_end"] - overlap_seconds
window_end = now
```
- `overlap_seconds` 来自 `config.get("run.overlap_seconds", 600)`,默认 600 秒10 分钟)
- 重叠设计目的:防止因时钟偏差或事务延迟导致数据遗漏
### 3.3 优先级三闲忙时段默认值Idle/Busy Window
当既无手动覆盖也无游标时,根据当前时间是否处于闲时窗口,选择不同的默认窗口长度:
```
闲时idlewindow_minutes = run.window_minutes.default_idle (默认 180 分钟)
忙时busywindow_minutes = run.window_minutes.default_busy (默认 30 分钟)
window_start = now - window_minutes
window_end = now
```
闲时判断逻辑:比较当前时间的 `HH:MM` 字符串是否在 `[idle_start, idle_end]` 区间内。
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `run.idle_window.start` | `"04:00"` | 闲时开始 |
| `run.idle_window.end` | `"16:00"` | 闲时结束 |
| `run.window_minutes.default_idle` | `180` | 闲时默认窗口(分钟) |
| `run.window_minutes.default_busy` | `30` | 忙时默认窗口(分钟) |
| `run.overlap_seconds` | `600` | 游标重叠秒数 |
### 3.4 流程图
```
_get_time_window(cursor_data)
├─ run.window_override.start/end 都存在?
│ ├─ 是 → 解析并返回 (override_start, override_end)
│ └─ 否 ↓
├─ 判断闲忙时段 → 确定 window_minutes
├─ cursor_data 存在且含 last_end
│ ├─ 是 → window_start = last_end - overlap_seconds
│ └─ 否 → window_start = now - window_minutes
└─ window_end = now → 返回 (start, end, minutes)
```
---
## 4. 窗口分段build_window_segments
当时间窗口跨度较大时(如回溯一个月的数据),系统支持将窗口切分为多个小段逐段执行,避免单次请求数据量过大。
### 4.1 切分入口
`build_window_segments()` 位于 `utils/windowing.py`,由 `BaseTask.execute()` 调用:
```python
def build_window_segments(
cfg, # 配置对象
start: datetime, # 窗口起点
end: datetime, # 窗口终点
*,
tz: ZoneInfo | None, # 时区
override_only: bool, # 是否仅在手动覆盖时才切分
) -> List[Tuple[datetime, datetime]]:
```
### 4.2 切分策略
**`override_only=True`(默认行为):**
- 仅当 `run.window_override.start/end` 都存在时,才按配置切分
- 否则强制 `split_unit="none"`,返回单段(即不切分)
- 设计意图:自动游标模式下窗口通常较短,无需切分;手动指定大范围时才需要
**切分配置项:**
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `run.window_split.unit` | `"month"` | 切分单位:`none` / `day` / `week` / `month` |
| `run.window_split.days` | `1` | 按天切分时的步长1 / 10 / 30 |
| `run.window_split.compensation_hours` | `0` | 窗口前后补偿小时数 |
### 4.3 底层切分函数 split_window()
```python
def split_window(start, end, *, tz, split_unit, compensation_hours, split_days=None):
```
**补偿机制:**`compensation_hours > 0`,会在切分前将窗口向两端各扩展指定小时数,用于覆盖跨段边界的数据。
**各切分模式:**
| split_unit | 行为 |
|------------|------|
| `none` / `off` / `false` / `""` | 不切分,返回 `[(start, end)]` |
| `day` / `daily` | 按 `split_days` 天为步长切分(默认 1 天一段) |
| `week` / `weekly` | 按 7 天为步长切分 |
| `month` / `monthly` | 按自然月边界切分(每段从当前位置到下月 1 日 00:00 |
**示例:** 窗口 `2026-01-15 ~ 2026-03-10``split_unit=month`
```
段 1: 2026-01-15 ~ 2026-02-01
段 2: 2026-02-01 ~ 2026-03-01
段 3: 2026-03-01 ~ 2026-03-10
```
---
## 5. TaskRegistry 注册方式与 TaskMeta 元数据
### 5.1 TaskMeta 数据结构
```python
@dataclass
class TaskMeta:
task_class: type # 任务类BaseTask 的子类)
requires_db_config: bool = True # 是否需要数据库配置(游标/运行记录)
layer: str | None = None # 所属层:"ODS" / "DWD" / "DWS" / "INDEX" / None
task_type: str = "etl" # 任务类型:"etl" / "utility" / "verification"
```
| 字段 | 说明 |
|------|------|
| `task_class` | 任务的 Python 类引用,用于 `create_task()` 时实例化 |
| `requires_db_config` | `True` 表示需要游标管理和运行记录;`False` 表示工具类/校验类任务,不走游标 |
| `layer` | 标识任务所属数据层,用于 `get_tasks_by_layer()` 按层查询 |
| `task_type` | 区分 ETL 任务、工具类任务和校验类任务 |
### 5.2 注册方式
`TaskRegistry` 提供 `register()` 方法,所有任务在 `orchestration/task_registry.py` 模块加载时完成注册:
```python
class TaskRegistry:
def register(self, task_code, task_class, requires_db_config=True, layer=None, task_type="etl"):
self._tasks[task_code.upper()] = TaskMeta(...)
```
**注册示例:**
```python
# ODS 层 ETL 任务(默认 requires_db_config=True, task_type="etl"
default_registry.register("ORDERS", OrdersTask, layer="ODS")
# 工具类任务(不需要游标)
default_registry.register("INIT_ODS_SCHEMA", InitOdsSchemaTask, requires_db_config=False, task_type="utility")
# 校验类任务
default_registry.register("DATA_INTEGRITY_CHECK", DataIntegrityTask, requires_db_config=False, task_type="verification")
# 通用 ODS 任务(由 ODS_TASK_CLASSES 字典动态注册)
for code, task_cls in ODS_TASK_CLASSES.items():
default_registry.register(code, task_cls, layer="ODS")
```
### 5.3 TaskRegistry 核心方法
| 方法 | 说明 |
|------|------|
| `register(task_code, task_class, ...)` | 注册任务类及元数据 |
| `create_task(task_code, config, db, api, logger)` | 根据任务代码创建实例 |
| `get_metadata(task_code) -> TaskMeta` | 查询任务元数据 |
| `get_tasks_by_layer(layer) -> list[str]` | 获取指定层的所有任务代码 |
| `is_utility_task(task_code) -> bool` | 判断是否为工具类任务(`requires_db_config=False` |
| `get_all_task_codes() -> list[str]` | 获取所有已注册任务代码 |
### 5.4 当前注册任务统计
| 层 | 数量 | 说明 |
|----|------|------|
| ODS | 14 + N | 14 个独立任务 + N 个通用 ODS 任务(由 `ODS_TASK_CLASSES` 动态生成) |
| DWD | 5 | 含核心装载任务 `DWD_LOAD_FROM_ODS` 和质量检查 |
| DWS | 15 | 助教业绩、会员分析、财务统计、运维任务 |
| INDEX | 4 | 回流指数、新客转化指数、关系指数、手动台账导入 |
| 工具类 | 7 | Schema 初始化、手动入库、归档、校验等 |
| 校验类 | 1 | 数据完整性校验 |
---
## 6. PipelineRunner 管道执行流程
`PipelineRunner`(位于 `orchestration/pipeline_runner.py`)负责编排多层 ETL 任务的执行顺序,并可选地运行后置校验。
### 6.1 管道定义
系统预定义了 7 种管道,每种管道包含一组数据层:
```python
PIPELINE_LAYERS = {
"api_ods": ["ODS"],
"api_ods_dwd": ["ODS", "DWD"],
"api_full": ["ODS", "DWD", "DWS", "INDEX"],
"ods_dwd": ["DWD"],
"dwd_dws": ["DWS"],
"dwd_dws_index": ["DWS", "INDEX"],
"dwd_index": ["INDEX"],
}
```
| 管道 | 包含层 | 典型场景 |
|------|--------|----------|
| `api_ods` | ODS | 仅从 API 抓取数据到 ODS |
| `api_ods_dwd` | ODS → DWD | 抓取并清洗到 DWD |
| `api_full` | ODS → DWD → DWS → INDEX | 全流程 ETL |
| `ods_dwd` | DWD | 仅执行 ODS→DWD 清洗(假设 ODS 已有数据) |
| `dwd_dws` | DWS | 仅执行 DWD→DWS 汇总 |
| `dwd_dws_index` | DWS → INDEX | 汇总 + 指数计算 |
| `dwd_index` | INDEX | 仅执行指数计算 |
### 6.2 处理模式
| 模式 | 说明 |
|------|------|
| `increment_only` | 仅执行增量 ETL不做校验默认 |
| `verify_only` | 跳过增量 ETL仅执行后置校验并自动修复 |
| `increment_verify` | 先执行增量 ETL再执行后置校验并修复 |
### 6.3 run() 执行流程
```
PipelineRunner.run(pipeline, processing_mode, ...)
├─ 校验管道名称合法性
├─ 设置默认时间窗口(未指定时:过去 24 小时)
├─ 根据 processing_mode 分支:
│ ┌─ verify_only ─────────────────────────────────┐
│ │ ├─ fetch_before_verify
│ │ │ ├─ 是 → 先执行 ODS 任务获取 API 数据 │
│ │ │ └─ 否 → 跳过 │
│ │ └─ _run_verification(...) │
│ └────────────────────────────────────────────────┘
│ ┌─ increment_only / increment_verify ───────────┐
│ │ ├─ _resolve_tasks(layers) → 解析任务列表 │
│ │ ├─ task_executor.run_tasks(tasks) │
│ │ └─ increment_verify
│ │ └─ 是 → _run_verification(...) │
│ └────────────────────────────────────────────────┘
└─ 汇总计数 → 返回结果字典
```
### 6.4 任务解析_resolve_tasks
`_resolve_tasks(layers)` 根据层列表解析出具体的任务代码:
| 层 | 解析逻辑 |
|----|----------|
| ODS | 优先使用 `config.run.ods_tasks`,否则从 TaskRegistry 按层查询 |
| DWD | 固定返回 `["DWD_LOAD_FROM_ODS"]` |
| DWS | 优先使用 `config.run.dws_tasks`,否则从 TaskRegistry 按层查询 |
| INDEX | 优先使用 `config.run.index_tasks`,否则从 TaskRegistry 按层查询 |
### 6.5 数据源模式
| 模式 | 说明 |
|------|------|
| `online` | 仅从上游 API 在线抓取 |
| `offline` | 仅从本地 JSON 文件入库 |
| `hybrid` | 先抓取再入库(默认) |
---
## 7. 校验框架概述
校验框架(`tasks/verification/`)提供各层数据的批量后置校验和自动补齐功能。
### 7.1 架构
```
BaseVerifier基类
├─ OdsVerifier — 主键 + content_hash 对比,批量 UPSERT
├─ DwdVerifier — 维度 SCD2 / 事实主键对比,批量 UPSERT
├─ DwsVerifier — 聚合对比,批量重算 UPSERT
└─ IndexVerifier — 实体覆盖对比,批量重算 UPSERT
```
### 7.2 核心接口
- `get_verifier_for_layer(layer, db_connection, logger, **kwargs)` — 工厂函数,根据层名返回对应校验器实例
- `verifier.verify_and_backfill(window_start, window_end, auto_backfill, split_unit, tables)` — 执行校验并自动补齐
### 7.3 校验结果模型
| 类 | 说明 |
|----|------|
| `VerificationResult` | 单表校验结果 |
| `VerificationSummary` | 层级汇总total_tables / consistent_tables / total_backfilled / error_tables |
| `VerificationStatus` | 校验状态枚举 |
| `WindowSegment` | 校验时间段 |
### 7.4 在管道中的触发方式
校验由 `PipelineRunner._run_verification()` 触发,支持:
- 按层逐一校验
- 按表名过滤(`verify_tables` 参数)
- 时间窗口切分(`window_split` 参数)
- ODS 层支持 API 数据对比或本地 JSON 对比两种模式
---
## 8. 相关配置速查
| 配置路径 | 默认值 | 说明 |
|----------|--------|------|
| `app.store_id` | — | 门店 ID |
| `app.timezone` | `"Asia/Shanghai"` | 系统时区 |
| `run.window_override.start` | — | 手动窗口起点 |
| `run.window_override.end` | — | 手动窗口终点 |
| `run.idle_window.start` | `"04:00"` | 闲时开始 |
| `run.idle_window.end` | `"16:00"` | 闲时结束 |
| `run.window_minutes.default_idle` | `180` | 闲时默认窗口(分钟) |
| `run.window_minutes.default_busy` | `30` | 忙时默认窗口(分钟) |
| `run.overlap_seconds` | `600` | 游标重叠秒数 |
| `run.window_split.unit` | `"month"` | 切分单位 |
| `run.window_split.days` | `1` | 按天切分步长 |
| `run.window_split.compensation_hours` | `0` | 窗口补偿小时数 |
| `run.ods_tasks` | `[]` | ODS 层任务列表覆盖 |
| `run.dws_tasks` | `[]` | DWS 层任务列表覆盖 |
| `run.index_tasks` | `[]` | INDEX 层任务列表覆盖 |

View File

@@ -0,0 +1,554 @@
# 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` |
| 数据来源 | `billiards_ods.*`ODS 层各表) |
| 数据目标 | `billiards_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 表名为 keyODS 表名为 value。
主表与扩展表(`_ex`)共享同一 ODS 源表,通过 `FACT_MAPPINGS` 中的列映射区分写入哪些字段。
#### 维度表映射
| DWD 表 | ODS 源表 | 说明 |
|--------|----------|------|
| `billiards_dwd.dim_site` | `billiards_ods.table_fee_transactions` | 门店维度(从台费流水的 siteprofile 快照提取) |
| `billiards_dwd.dim_site_ex` | `billiards_ods.table_fee_transactions` | 门店扩展灯控、WiFi、客服等 |
| `billiards_dwd.dim_table` | `billiards_ods.site_tables_master` | 台桌维度 |
| `billiards_dwd.dim_table_ex` | `billiards_ods.site_tables_master` | 台桌扩展(台布使用时间等) |
| `billiards_dwd.dim_assistant` | `billiards_ods.assistant_accounts_master` | 助教维度 |
| `billiards_dwd.dim_assistant_ex` | `billiards_ods.assistant_accounts_master` | 助教扩展(简介、分组、灯控设备等) |
| `billiards_dwd.dim_member` | `billiards_ods.member_profiles` | 会员维度 |
| `billiards_dwd.dim_member_ex` | `billiards_ods.member_profiles` | 会员扩展(注册来源、组织等) |
| `billiards_dwd.dim_member_card_account` | `billiards_ods.member_stored_value_cards` | 会员储值卡维度 |
| `billiards_dwd.dim_member_card_account_ex` | `billiards_ods.member_stored_value_cards` | 储值卡扩展(电费抵扣、冻结余额等) |
| `billiards_dwd.dim_tenant_goods` | `billiards_ods.tenant_goods_master` | 租户商品维度 |
| `billiards_dwd.dim_tenant_goods_ex` | `billiards_ods.tenant_goods_master` | 租户商品扩展(条码、备注等) |
| `billiards_dwd.dim_store_goods` | `billiards_ods.store_goods_master` | 门店商品维度 |
| `billiards_dwd.dim_store_goods_ex` | `billiards_ods.store_goods_master` | 门店商品扩展(库存、安全库存等) |
| `billiards_dwd.dim_goods_category` | `billiards_ods.stock_goods_category_tree` | 商品分类维度(含子类展开) |
| `billiards_dwd.dim_groupbuy_package` | `billiards_ods.group_buy_packages` | 团购套餐维度 |
| `billiards_dwd.dim_groupbuy_package_ex` | `billiards_ods.group_buy_packages` | 团购套餐扩展 |
#### 事实表映射
| DWD 表 | ODS 源表 | 说明 |
|--------|----------|------|
| `billiards_dwd.dwd_settlement_head` | `billiards_ods.settlement_records` | 结算头(订单结算主记录) |
| `billiards_dwd.dwd_settlement_head_ex` | `billiards_ods.settlement_records` | 结算头扩展(支付方式、撤单、促销等) |
| `billiards_dwd.dwd_table_fee_log` | `billiards_ods.table_fee_transactions` | 台费流水 |
| `billiards_dwd.dwd_table_fee_log_ex` | `billiards_ods.table_fee_transactions` | 台费流水扩展(销售员、消费类型等) |
| `billiards_dwd.dwd_table_fee_adjust` | `billiards_ods.table_fee_discount_records` | 台费调整/折扣 |
| `billiards_dwd.dwd_table_fee_adjust_ex` | `billiards_ods.table_fee_discount_records` | 台费调整扩展 |
| `billiards_dwd.dwd_store_goods_sale` | `billiards_ods.store_goods_sales_records` | 商品销售记录 |
| `billiards_dwd.dwd_store_goods_sale_ex` | `billiards_ods.store_goods_sales_records` | 商品销售扩展 |
| `billiards_dwd.dwd_assistant_service_log` | `billiards_ods.assistant_service_records` | 助教服务记录 |
| `billiards_dwd.dwd_assistant_service_log_ex` | `billiards_ods.assistant_service_records` | 助教服务扩展 |
| `billiards_dwd.dwd_assistant_trash_event` | `billiards_ods.assistant_cancellation_records` | 助教取消/废单事件 |
| `billiards_dwd.dwd_assistant_trash_event_ex` | `billiards_ods.assistant_cancellation_records` | 助教取消扩展 |
| `billiards_dwd.dwd_member_balance_change` | `billiards_ods.member_balance_changes` | 会员余额变动 |
| `billiards_dwd.dwd_member_balance_change_ex` | `billiards_ods.member_balance_changes` | 会员余额变动扩展 |
| `billiards_dwd.dwd_groupbuy_redemption` | `billiards_ods.group_buy_redemption_records` | 团购核销记录 |
| `billiards_dwd.dwd_groupbuy_redemption_ex` | `billiards_ods.group_buy_redemption_records` | 团购核销扩展 |
| `billiards_dwd.dwd_platform_coupon_redemption` | `billiards_ods.platform_coupon_redemption_records` | 平台优惠券核销 |
| `billiards_dwd.dwd_platform_coupon_redemption_ex` | `billiards_ods.platform_coupon_redemption_records` | 平台优惠券核销扩展 |
| `billiards_dwd.dwd_recharge_order` | `billiards_ods.recharge_settlements` | 充值订单 |
| `billiards_dwd.dwd_recharge_order_ex` | `billiards_ods.recharge_settlements` | 充值订单扩展 |
| `billiards_dwd.dwd_payment` | `billiards_ods.payment_transactions` | 支付记录 |
| `billiards_dwd.dwd_refund` | `billiards_ods.refund_transactions` | 退款记录 |
| `billiards_dwd.dwd_refund_ex` | `billiards_ods.refund_transactions` | 退款扩展 |
> 共计 **17 对维度映射**(含 `_ex`+ **23 对事实映射**(含 `_ex`= **40 对**映射。
---
### 维度/事实分流逻辑
`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 billiards_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 billiards_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 billiards_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 billiards_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 billiards_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 billiards_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 "billiards_dwd"."<dwd_table>"
-- ODS 端
SELECT COUNT(1) AS cnt FROM "billiards_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()` 将全限定名(如 `billiards_dwd.dim_member`)拆分为 `(schema, table)`
若表名不含 `.`,则使用默认 schemaDWD 端默认 `billiards_dwd`ODS 端默认 `billiards_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 "billiards_dwd"."<dwd_table>"
-- ODS 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "billiards_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": "billiards_dwd.dim_member",
"ods_table": "billiards_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": "billiards_dwd.dwd_payment",
"ods_table": "billiards_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`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,731 @@
# INDEX 层任务详解
> 本文档说明飞球 ETL 系统中 INDEX指数算法层的所有任务。
> INDEX 层基于 DWD/DWS 层数据,通过自定义算法计算业务指数,
> 服务于会员运营(回流挽回、新客转化)和助教管理(关系归属、付费关联)等场景。
---
## 概述
INDEX 层共有 4 个已注册任务:
| 任务代码 | Python 类 | 目标表 | 指数类型 | 更新策略 |
|----------|-----------|--------|----------|----------|
| `DWS_WINBACK_INDEX` | `WinbackIndexTask` | `dws_member_winback_index` | WBI回流指数 | delete-before-insert按门店全量刷新 |
| `DWS_NEWCONV_INDEX` | `NewconvIndexTask` | `dws_member_newconv_index` | NCI新客转化指数 | delete-before-insert按门店全量刷新 |
| `DWS_RELATION_INDEX` | `RelationIndexTask` | `dws_member_assistant_relation_index` | RS/OS/MS/ML关系指数 | delete-before-insert按门店全量刷新 |
| `DWS_ML_MANUAL_IMPORT` | `MlManualImportTask` | `dws_ml_manual_order_source` / `dws_ml_manual_order_alloc` | ML手动台账导入 | 按 scope 先删后写 |
> 注册位置:`orchestration/task_registry.py`,所有 INDEX 任务的 `requires_db_config=False`、`layer="INDEX"`。
---
## BaseIndexTask 公共机制
`BaseIndexTask`(位于 `tasks/dws/index/base_index_task.py`)继承自 `BaseDwsTask`,为所有指数任务提供统一的算法基础设施。
### 继承层次
```
BaseTask
└── BaseDwsTask
└── BaseIndexTask
├── MemberIndexBaseTask ← WBI / NCI 共享的会员特征提取
│ ├── WinbackIndexTask
│ └── NewconvIndexTask
├── RelationIndexTask ← RS/OS/MS/ML 四合一
└── MlManualImportTask ← ML 人工台账导入
```
### 子类必须实现的抽象方法
```python
def get_index_type(self) -> str:
"""返回指数类型标识,如 'WBI''NCI''RS'"""
```
### 核心能力
#### 1. 半衰期时间衰减函数
所有指数共享的时间权重模型,核心思想是"越近越重要"
```
decay(d; h) = exp(-ln(2) × d / h)
```
| 参数 | 含义 | 示例 |
|------|------|------|
| `d` | 事件距今天数≥0 | 7 天 |
| `h` | 半衰期(>0单位天 | 7 天 |
| 返回值 | 衰减权重,范围 (0, 1] | 0.5 |
`d = h` 时权重恰好衰减到 0.5`d = 0` 时权重为 1.0。
#### 2. 分位数计算与截断Winsorize
用于消除极端值对归一化的影响:
1. 计算 P5 和 P95 分位点
2. 将所有 Raw Score 截断到 [P5, P95] 范围内
```python
calculate_percentiles(scores, lower=5, upper=95) (P5, P95)
winsorize(value, lower, upper) clipped_value
```
#### 3. 0-10 归一化映射
将 Raw Score 映射到 0-10 分的 Display Score便于业务理解和排序
```
映射流程Raw Score → [可选压缩] → Winsorize(P5, P95) → MinMax(0, 10)
```
压缩模式(由 `compression_mode` 参数控制):
| compression_mode | 方式 | 公式 | 适用场景 |
|------------------|------|------|----------|
| 0 | 无压缩 | `y = x` | 分布较均匀时 |
| 1 | log1p | `y = ln(1 + x)` | 右偏分布(默认) |
| 2 | asinh | `y = asinh(x)` | 含负值或极端右偏 |
当所有分数几乎相同(`max - min < ε`)时,返回中间值 5.0。
#### 4. 算法参数加载
`billiards_dws.cfg_index_parameters` 表按 `index_type` 加载参数:
-`effective_from` 降序取最新生效的参数值
- 支持按 `index_type` 隔离的内存缓存TTL = 300 秒)
- 子类可通过 `get_param(name, default)` 获取单个参数
```python
load_index_parameters(index_type=None) Dict[str, float]
get_param(name, default=0.0, index_type=None) float
```
#### 5. 分位点历史管理EWMA 平滑)
为避免分位点在不同批次间剧烈波动,支持 EWMA指数加权移动平均平滑
```
Q_t = (1 - α) × Q_{t-1} + α × Q_now
```
| 参数 | 含义 | 默认值 |
|------|------|--------|
| `α`ewma_alpha | 平滑系数,越大越跟随当前值 | 0.2 |
| `Q_{t-1}` | 上一次平滑后的分位点 | 从 `dws_index_percentile_history` 表读取 |
| `Q_now` | 当前批次计算的分位点 | 实时计算 |
首次计算时无历史记录,直接使用当前分位点(不平滑)。每次计算后将原始分位点和平滑分位点保存到 `dws_index_percentile_history` 表。
#### 6. 统计工具方法
| 方法 | 功能 |
|------|------|
| `calculate_median(values)` | 中位数 |
| `calculate_mad(values)` | MAD中位绝对偏差比标准差更稳健 |
| `safe_log(value)` | 安全对数value ≤ 0 时返回默认值) |
| `safe_ln1p(value)` | 安全的 `ln(1+x)` |
---
## MemberIndexBaseTask 会员指数共享基类
`MemberIndexBaseTask`(位于 `tasks/dws/index/member_index_base.py`)继承自 `BaseIndexTask`,为 WBI 和 NCI 提供共享的会员活动特征提取逻辑。
### 会员活动特征MemberActivityData
从 DWD 层提取并计算的会员特征数据结构:
| 字段 | 类型 | 含义 |
|------|------|------|
| `member_id` | int | 会员 ID |
| `site_id` / `tenant_id` | int | 门店 / 租户 ID |
| `member_create_time` | datetime | 会员建档时间 |
| `first_visit_time` | datetime | 首次到店时间 |
| `last_visit_time` | datetime | 最近到店时间 |
| `last_recharge_time` | datetime | 最近充值时间 |
| `t_v` | float | 距最近到店天数(截断到 recency 窗口) |
| `t_r` | float | 距最近充值天数(截断到 recency 窗口) |
| `t_a` | float | `min(t_v, t_r)`,综合活跃度 |
| `visits_14d` / `visits_60d` / `visits_total` | int | 近 14 天 / 60 天 / 总到店次数 |
| `spend_30d` / `spend_180d` | float | 近 30 天 / 180 天消费金额 |
| `sv_balance` | float | 储值卡余额 |
| `recharge_60d_amt` | float | 近 60 天充值金额 |
| `intervals` | List[float] | 到店间隔天数序列 |
| `interval_ages_days` | List[int] | 每个间隔对应的"年龄"(距今天数) |
| `recharge_unconsumed` | int | 充值后是否未回访1=是) |
### 数据来源
| 数据 | 来源表 | 提取方式 |
|------|--------|----------|
| 到店记录 | `billiards_dwd.dwd_settlement_head` | 按天去重仅计入正常结账settle_type=1和激励课结账settle_type=3 且关联 BONUS 技能) |
| 充值记录 | `billiards_dwd.dwd_recharge_order` | settle_type=5近 recency_days 天 |
| 会员建档时间 | `billiards_dwd.dim_member` | scd2_is_current=1 |
| 首次到店时间 | `billiards_dwd.dwd_settlement_head` | 全量 MIN(pay_time) |
| 储值卡余额 | `billiards_dwd.dim_member_card_account` | 按 card_type_id 筛选现金卡 |
> 会员 ID 规范化:优先使用 `member_id`,若为 0 则通过 `dim_member_card_account` 关联取 `tenant_member_id`。
### 会员分群classify_segment
WBI 和 NCI 共享的三分群逻辑,决定会员进入哪个指数的计算范围:
| 分群 | 条件 | 进入指数 |
|------|------|----------|
| **STOP** | `t_a ≥ recency_days`(默认 60 天无活动) | 不参与评分(除 STOP_HIGH_BALANCE 例外) |
| **NEW** | 满足以下任一:到店 ≤ 2 次、首访 ≤ 30 天、近期充值未回访 | NCI |
| **OLD** | 不满足 STOP 和 NEW 条件 | WBI |
STOP_HIGH_BALANCE 例外:当 `enable_stop_high_balance_exception=1` 且储值余额 ≥ `high_balance_threshold`(默认 1000 元STOP 会员仍参与 WBI 评分。
---
## DWS_WINBACK_INDEX — 老客挽回指数WBI
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_WINBACK_INDEX` |
| Python 类 | `WinbackIndexTask``tasks/dws/index/winback_index_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MemberIndexBaseTask → WinbackIndexTask` |
| 目标表 | `billiards_dws.dws_member_winback_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `WBI` |
| 更新策略 | 按门店全量刷新(先 DELETE WHERE site_id = %s再 INSERT |
### 业务含义
WBI 衡量老客的"挽回紧急度"——分数越高,表示该会员越需要运营人员主动触达。适用于已有多次到店记录但近期活跃度下降的老客群体。
### 计算范围
仅对 `segment = "OLD"``status = "STOP_HIGH_BALANCE"` 的会员计算。
### 算法概要
WBI Raw Score 由 4 个分项加权求和,再乘以近期抑制系数:
```
WBI_raw = suppression × (w_over × Overdue + w_drop × Drop + w_re × Recharge + w_value × Value)
```
#### 分项 1超期紧急性Overdue
基于会员个人历史到店间隔的加权经验 CDF衡量当前缺席天数的异常程度
1. 收集会员历史到店间隔序列 `{interval_i, age_i}`
2. 计算加权 CDF`P(interval ≤ t_v)`,权重按间隔年龄半衰期衰减
3. 对小样本混合等权分布与加权分布(`λ = min(1, N / blend_min_samples)`
4. `Overdue = P^α`α 默认 2.0,放大高概率区间的紧急性)
同时计算理想到店间隔(加权中位数),用于推算 `ideal_next_visit_date`
#### 分项 2降频分Drop
检测近期到店频率是否低于历史均值:
```
expected_14d = visits_60d × 14 / 60
Drop = clip((expected_14d - visits_14d) / (expected_14d + 1), 0, 1)
```
#### 分项 3充值未回访压力Recharge
若会员充值后未回访(`recharge_unconsumed = 1`),按充值距今天数衰减:
```
Recharge = decay(t_r, h_recharge) # h_recharge 默认 7 天
```
#### 分项 4价值分Value
综合消费金额和储值余额的对数压缩:
```
Value = w_spend × ln(1 + spend_180d / M0) + w_bal × ln(1 + sv_balance / B0)
```
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `M0` (amount_base_M0) | 300 | 消费金额压缩基准 |
| `B0` (balance_base_B0) | 500 | 余额压缩基准 |
#### 近期抑制Suppression
防止刚到店的会员获得高分,使用 Sigmoid 门控:
```
suppression = σ((t_v - gate_days) / slope_days)
```
-`t_v < hard_floor_days`(默认 14 天)时,`suppression = 0`(完全抑制)
-`t_v` 远大于 `gate_days` 时,`suppression → 1`(不抑制)
### 默认权重
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_over` | 2.0 | 超期紧急性权重 |
| `w_drop` | 1.0 | 降频权重 |
| `w_re` | 0.4 | 充值压力权重 |
| `w_value` | 1.2 | 价值权重 |
---
## DWS_NEWCONV_INDEX — 新客转化指数NCI
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_NEWCONV_INDEX` |
| Python 类 | `NewconvIndexTask``tasks/dws/index/newconv_index_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MemberIndexBaseTask → NewconvIndexTask` |
| 目标表 | `billiards_dws.dws_member_newconv_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `NCI` |
| 更新策略 | 按门店全量刷新(先 DELETE WHERE site_id = %s再 INSERT |
### 业务含义
NCI 衡量新客的"转化紧迫度"——分数越高,表示该新客越需要及时跟进以促成二访/三访。适用于首次到店不久或到店次数较少的新客群体。
### 计算范围
仅对 `segment = "NEW"` 的会员计算。
### 算法概要
NCI 由两部分组成欢迎建联分Welcome和转化召回分Convert合并为总分
```
NCI_raw = raw_score_welcome + raw_score_convert
```
#### 欢迎建联分Welcome
针对首访后 3 天内的会员,鼓励立即触达:
```
welcome_new = clip(1 - t_v / welcome_window_days, 0, 1) # 仅 visits_total ≤ 1 时生效
raw_score_welcome = w_welcome × welcome_new
```
#### 转化召回分Convert
由 4 个分项加权组成,并受活跃度抑制:
```
raw_score_convert = active_multiplier × (
w_need × (Need × Salvage) + w_re × Recharge × touch_multiplier + w_value × Value × touch_multiplier
)
```
##### 分项 1紧迫度Need
衡量距二访目标窗口的紧迫程度:
```
Need = clip((t_v - no_touch_days) / (2 × t2_target_days - no_touch_days), 0, 1)
```
- `no_touch_days`(默认 3 天):免打扰窗口,首访后短期内不催促
- `t2_target_days`(默认 7 天):二访目标天数
##### 分项 2挽救系数Salvage
30-60 天线性衰减,超过 60 天视为流失:
```
Salvage = clip((salvage_end - t_a) / (salvage_end - salvage_start), 0, 1)
```
##### 分项 3充值未回访压力Recharge
与 WBI 相同:`Recharge = decay(t_r, h_recharge)`
##### 分项 4价值分Value
与 WBI 相同:`Value = w_spend × ln(1 + spend_180d / M0) + w_bal × ln(1 + sv_balance / B0)`
#### 活跃新客抑制
近期高频到店的新客不需要催促,降低其排名权重:
```
若 visits_14d ≥ active_new_visit_threshold_14d 且 t_v ≤ active_new_recency_days:
active_multiplier = active_new_penalty (默认 0.2)
否则:
active_multiplier = 1.0
```
#### 免打扰窗口乘数
价值分和充值分在进入免打扰窗口后才逐步生效:
```
touch_multiplier = clip(t_v / no_touch_days, 0, 1)
```
### Display Score 归一化
NCI 产出 3 个 Display Score
- `display_score`:总分归一化(使用 EWMA 平滑)
- `display_score_welcome`:欢迎分归一化(不平滑)
- `display_score_convert`:转化分归一化(不平滑)
### 默认权重
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_welcome` | 1.0 | 欢迎建联权重 |
| `w_need` | 1.6 | 紧迫度权重 |
| `w_re` | 0.8 | 充值压力权重 |
| `w_value` | 1.0 | 价值权重 |
---
## DWS_RELATION_INDEX — 关系指数RS/OS/MS/ML
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_RELATION_INDEX` |
| Python 类 | `RelationIndexTask``tasks/dws/index/relation_index_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → RelationIndexTask` |
| 目标表 | `billiards_dws.dws_member_assistant_relation_index` |
| 主键 | `site_id, member_id, assistant_id` |
| 指数类型 | RS / OS / MS / ML单任务产出四个子指数 |
| 更新策略 | 按门店全量刷新(先 DELETE WHERE site_id = %s再 INSERT |
### 业务含义
关系指数以"会员-助教"关系对为粒度,一次执行产出 4 个子指数:
| 子指数 | 全称 | 含义 |
|--------|------|------|
| **RS** | Relation Strength | 关系强度——衡量会员与助教之间的服务关系紧密程度 |
| **OS** | Ownership Share | 归属份额——确定会员的"主责助教"归属关系 |
| **MS** | Momentum Score | 升温动量——衡量关系是在升温还是降温 |
| **ML** | Money Link | 付费关联——基于人工台账的付费归因强度 |
### 数据来源
| 数据 | 来源表 | 说明 |
|------|--------|------|
| 服务记录 | `billiards_dwd.dwd_assistant_service_log` | RS/MS 的核心数据源 |
| 助教维度 | `billiards_dwd.dim_assistant` | 通过 user_id 关联获取 assistant_id |
| 人工台账 | `billiards_dws.dws_ml_manual_order_alloc` | ML 的唯一数据源 |
### 会话合并
服务记录按 `(member_id, assistant_id)` 分组后,相邻服务间隔 ≤ `session_merge_hours`(默认 4 小时的记录合并为一个会话ServiceSession。合并后保留
- 会话起止时间、总时长
- 课程权重(激励课 `course_weight = incentive_weight`,默认 1.5;普通课 = 1.0
- 是否包含激励课标记
### 子指数 1RS关系强度
```
RS_raw = (w_f × F + w_d × D) × Gate(R)
```
| 分项 | 公式 | 含义 |
|------|------|------|
| F频次 | `Σ course_weight × decay(days_ago, halflife_session)` | 加权会话频次 |
| D时长 | `Σ √(duration_min / 60) × course_weight × decay(days_ago, halflife_session)` | 加权服务时长 |
| R近期性 | `decay(days_since_last_session, halflife_last)` | 最近一次服务的时间衰减 |
| Gate | `R^gate_alpha` | 近期性门控,无近期服务则整体压低 |
默认参数:`halflife_session=14`, `halflife_last=10`, `w_f=1.0`, `w_d=0.7`, `gate_alpha=0.6`
### 子指数 2OS归属份额
OS 不是独立计算的 Raw Score而是基于 RS_raw 的份额分配:
1. 筛选 `rs_raw ≥ min_rs_raw_for_ownership`(默认 0.05)的关系对
2. 计算份额:`os_share = rs_raw / Σ rs_raw`(同一会员下所有合格助教)
3.`Σ rs_raw < min_total_rs_raw`(默认 0.10),标记为 `UNASSIGNED`
归属标签判定规则:
| 标签 | 条件 |
|------|------|
| `MAIN` | 第一名份额 ≥ `ownership_main_threshold`0.60)且与第二名差距 ≥ `ownership_gap_threshold`0.15 |
| `COMANAGE` | 份额 ≥ `ownership_comanage_threshold`0.35)但不满足 MAIN 条件 |
| `POOL` | 其余合格关系对 |
| `UNASSIGNED` | 总 RS 不足,无法形成稳定归属 |
### 子指数 3MS升温动量
衡量关系是在升温MS > 0还是降温MS ≈ 0
```
f_short = Σ course_weight × decay(days_ago, halflife_short) # 短期半衰期 7 天
f_long = Σ course_weight × decay(days_ago, halflife_long) # 长期半衰期 30 天
MS_raw = max(0, ln(f_short + ε) / (f_long + ε))
```
短期频次高于长期频次时 MS 为正,表示关系在升温。
### 子指数 4ML付费关联
以人工台账窄表(`dws_ml_manual_order_alloc`)为唯一数据源:
```
ML_raw = Σ ln(1 + allocated_amount / amount_base) × decay(days_ago, halflife_recharge)
```
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `amount_base` | 500 | 金额压缩基准 |
| `halflife_recharge` | 21 天 | 充值半衰期 |
若某 `(member_id, assistant_id)` 对仅在台账中出现而无服务记录,会自动创建关系对。
### Display Score 归一化
RS、MS、ML 各自独立归一化到 0-10 分,分位历史按 `index_type` 隔离(分别记录 RS/MS/ML 的分位点。OS 不做归一化,直接输出份额和标签。
---
## DWS_ML_MANUAL_IMPORT — ML 人工台账导入
| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ML_MANUAL_IMPORT` |
| Python 类 | `MlManualImportTask``tasks/dws/index/ml_manual_import_task.py` |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MlManualImportTask` |
| 目标表 | `billiards_dws.dws_ml_manual_order_source`(宽表)+ `billiards_dws.dws_ml_manual_order_alloc`(窄表) |
| 主键 | 宽表:`site_id, external_id, import_scope_key, row_no`;窄表:`site_id, external_id, assistant_id` |
| 指数类型 | `ML` |
| 更新策略 | 按 scope 先删后写DAY 或 P30 批次覆盖) |
### 业务含义
ML 人工台账导入是一个工具型任务,用于将运营人员手工整理的订单-助教归因数据导入系统。导入后的数据作为 `DWS_RELATION_INDEX` 任务中 ML 子指数的唯一数据源。
该任务不依赖时间窗口,由调度器以工具任务方式直接触发。
### 文件路径解析
按以下优先级查找台账文件:
1. 配置项 `run.ml_manual_ledger_file`
2. 配置项 `run.ml_manual_file`
3. 环境变量 `ML_MANUAL_LEDGER_FILE`
### Excel 模板格式
台账文件为 `.xlsx` 格式,第一行为表头,第二行起为数据。模板列定义:
| 列名 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `site_id` | int | 否(默认取配置) | 门店 ID |
| `biz_date` | date | 是 | 业务日期 |
| `external_id` | string | 是 | 外部订单 ID唯一标识 |
| `member_id` | int | 否 | 会员 ID |
| `pay_time` | datetime | 否(默认取 biz_date | 支付时间 |
| `order_amount` | decimal | 否 | 订单金额 |
| `currency` | string | 否(默认 CNY | 币种 |
| `assistant_id_1` ~ `assistant_id_5` | int | 否 | 助教 ID最多 5 个) |
| `assistant_name_1` ~ `assistant_name_5` | string | 否 | 助教姓名 |
| `remark` | string | 否 | 备注 |
### 导入逻辑
#### 1. 读取与规范化
- 使用 `openpyxl` 读取 Excel跳过空行
- 每行规范化:类型转换、缺省值填充、助教列表提取
- `external_id` 为必填,缺失则抛出 `ValueError`
#### 2. 助教分摊
同一订单支持最多 5 个助教归因,默认均分:
```
share_ratio = 1 / N
allocated_amount = order_amount × share_ratio
```
#### 3. 覆盖策略ImportScope
根据 `biz_date` 与当前日期的距离,采用不同的覆盖粒度:
| 条件 | scope_type | 覆盖范围 | 说明 |
|------|------------|----------|------|
| `today - biz_date ≤ 30 天` | `DAY` | 单日 | 按 `site_id + biz_date` 日覆盖 |
| `today - biz_date > 30 天` | `P30` | 30 天批次 | 以固定纪元2026-01-01为锚点按 30 天分桶 |
P30 分桶算法:
```
bucket_index = (biz_date - EPOCH_ANCHOR).days // 30
bucket_start = EPOCH_ANCHOR + bucket_index × 30 天
bucket_end = bucket_start + 29 天
```
#### 4. 写入流程
1. 按 scope 删除旧数据(宽表 + 窄表)
2. 插入宽表(`dws_ml_manual_order_source`
3. Upsert 窄表(`dws_ml_manual_order_alloc`),冲突键为 `(site_id, external_id, assistant_id)`
4. 提交事务
#### 5. 导入批次号
格式:`MLM_<YYYYMMDDHHmmss>_<uuid8>`,如 `MLM_20260215143022_a1b2c3d4`
导入用户按优先级取:环境变量 `ETL_OPERATOR``USERNAME``USER``"system"`
---
## cfg_index_parameters 配置表
所有指数任务的算法参数统一存储在 `billiards_dws.cfg_index_parameters` 表中,支持按时间生效和历史追溯。
### 表结构
| 字段 | 类型 | 说明 |
|------|------|------|
| `param_id` | SERIAL PK | 自增主键 |
| `index_type` | VARCHAR(50) NOT NULL | 指数类型:`RS` / `OS` / `MS` / `ML` / `NCI` / `WBI` |
| `param_name` | VARCHAR(100) NOT NULL | 参数名称 |
| `param_value` | NUMERIC(14,6) NOT NULL | 参数值 |
| `description` | TEXT | 参数说明 |
| `effective_from` | DATE NOT NULL | 生效起始日期(默认当天) |
| `effective_to` | DATE | 生效截止日期NULL = 永久有效) |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
唯一约束:`(index_type, param_name, effective_from)`
索引:`idx_cfg_index_params_type`index_type`idx_cfg_index_params_effective`effective_from, effective_to
### 参数加载逻辑
```sql
SELECT param_name, param_value
FROM billiards_dws.cfg_index_parameters
WHERE index_type = %s
AND effective_from <= CURRENT_DATE
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY effective_from DESC
```
同一 `param_name` 若有多条生效记录,取 `effective_from` 最新的一条(代码中通过 `seen` 集合去重)。
### 参数调优方式
新增一条 `effective_from` 为新日期的记录即可覆盖旧参数,旧记录自动失效(无需删除)。如需回滚,将新记录的 `effective_to` 设为过去日期即可。
### WBI 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days_recency` | 60 | 近期活跃判定窗口(天) |
| `visit_lookback_days` | 180 | 到店记录回溯窗口(天) |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 0 | 压缩模式0=无/1=log1p/2=asinh |
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
| `ewma_alpha` | 0.2 | EWMA 平滑系数 |
| `new_visit_threshold` | 2 | 新客到店次数阈值 |
| `new_days_threshold` | 30 | 新客首访天数阈值 |
| `recharge_recent_days` | 14 | 近期充值窗口(天) |
| `new_recharge_max_visits` | 10 | 充值新客最大到店次数 |
| `overdue_alpha` | 2.0 | 超期 CDF 幂指数 |
| `overdue_weight_halflife_days` | 30 | 超期加权 CDF 间隔半衰期(天) |
| `overdue_weight_blend_min_samples` | 8 | 加权 CDF 最小样本数 |
| `h_recharge` | 7 | 充值衰减半衰期(天) |
| `amount_base_M0` | 300 | 消费金额压缩基准 |
| `balance_base_B0` | 500 | 余额压缩基准 |
| `value_w_spend` / `value_w_bal` | 1.0 / 1.0 | 价值分中消费/余额权重 |
| `w_over` / `w_drop` / `w_re` / `w_value` | 2.0 / 1.0 / 0.4 / 1.2 | 四分项权重 |
| `recency_hard_floor_days` | 14 | 近期硬抑制天数 |
| `recency_gate_days` | 14 | Sigmoid 门控中心(天) |
| `recency_gate_slope_days` | 3 | Sigmoid 门控斜率(天) |
| `enable_stop_high_balance_exception` | 0 | 是否启用 STOP 高余额例外 |
| `high_balance_threshold` | 1000 | 高余额阈值(元) |
### NCI 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days_recency` | 60 | 近期活跃判定窗口(天) |
| `visit_lookback_days` | 180 | 到店记录回溯窗口(天) |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 0 | 压缩模式 |
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
| `ewma_alpha` | 0.2 | EWMA 平滑系数 |
| `no_touch_days_new` | 3 | 免打扰窗口(天) |
| `t2_target_days` | 7 | 二访目标天数 |
| `salvage_start` / `salvage_end` | 30 / 60 | 挽救系数衰减区间(天) |
| `welcome_window_days` | 3 | 欢迎建联窗口(天) |
| `active_new_visit_threshold_14d` | 2 | 活跃新客 14 天到店阈值 |
| `active_new_recency_days` | 7 | 活跃新客近期天数 |
| `active_new_penalty` | 0.2 | 活跃新客抑制系数 |
| `h_recharge` | 7 | 充值衰减半衰期(天) |
| `amount_base_M0` / `balance_base_B0` | 300 / 500 | 价值分压缩基准 |
| `value_w_spend` / `value_w_bal` | 1.0 / 0.8 | 价值分权重 |
| `w_welcome` / `w_need` / `w_re` / `w_value` | 1.0 / 1.6 / 0.8 / 1.0 | 分项权重 |
### RS 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 60 | 服务行为回溯窗口(天) |
| `session_merge_hours` | 4 | 会话合并阈值(小时) |
| `incentive_weight` | 1.5 | 激励课权重 |
| `halflife_session` | 14 | 会话半衰期(天) |
| `halflife_last` | 10 | 最近服务半衰期(天) |
| `weight_f` / `weight_d` | 1.0 / 0.7 | 频次/时长权重 |
| `gate_alpha` | 0.6 | 近期性门控指数 |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式(默认 log1p |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |
### OS 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `min_rs_raw_for_ownership` | 0.05 | 参与归属计算的最小 RS_raw |
| `min_total_rs_raw` | 0.10 | 形成稳定归属的最小 sum_rs |
| `ownership_main_threshold` | 0.60 | 主责份额阈值 |
| `ownership_comanage_threshold` | 0.35 | 共管份额阈值 |
| `ownership_gap_threshold` | 0.15 | 主责与次席差距阈值 |
| `eps` | 0.000001 | 数值稳定项 |
### MS 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 60 | 服务行为回溯窗口(天) |
| `session_merge_hours` | 4 | 会话合并阈值(小时) |
| `incentive_weight` | 1.5 | 激励课权重 |
| `halflife_short` / `halflife_long` | 7 / 30 | 短期/长期半衰期(天) |
| `eps` | 0.000001 | 数值稳定项 |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式(默认 log1p |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |
### ML 参数清单
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 60 | 充值行为回溯窗口(天) |
| `amount_base` | 500 | 金额压缩基准 |
| `halflife_recharge` | 21 | 充值半衰期(天) |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式(默认 log1p |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |
> 种子数据脚本:`database/seed_index_parameters.sql`
> DDL 定义:`database/schema_dws.sql`(第 21 节)

View File

@@ -0,0 +1,240 @@
# ODS 层任务详解
> 本文档说明飞球 ETL 系统中 ODS操作数据存储层的所有任务。
> ODS 层负责从上游 SaaS API 抽取原始业务数据并落地到 PostgreSQL`billiards_ods` schema保留源 payload 便于回溯。
---
## 概述
ODS 层采用**声明式配置**驱动的通用任务模式:由 `BaseOdsTask` + `OdsTaskSpec` 配置驱动,通过 `ODS_TASK_CLASSES` 字典动态注册,共 23 个任务。
所有 ODS 任务写入 `billiards_ods.*` 表,原始 API 响应以 JSON 格式存入 `payload` 列,元数据列(`fetched_at``source_file``content_hash` 等)自动填充。
> **历史说明**:早期版本曾有 14 个独立 ODS 任务ORDERS、PAYMENTS、MEMBERS 等),写入不存在的 `billiards.*` schema。
> 这些任务已于 2026-02-14 废弃删除,全部由下述通用 ODS 任务替代。
### 任务总览
| 任务代码 | 动态类名 | API 端点 | 目标 ODS 表 | 说明 |
|----------|----------|----------|-------------|------|
| `ODS_ASSISTANT_ACCOUNT` | `OdsAssistantAccountsTask` | `/PersonnelManagement/SearchAssistantInfo` | `assistant_accounts_master` | 助教账号档案 |
| `ODS_SETTLEMENT_RECORDS` | `OdsOrderSettleTask` | `/Site/GetAllOrderSettleList` | `settlement_records` | 结账记录 |
| `ODS_TABLE_USE` | `OdsTableUseTask` | `/Site/GetSiteTableOrderDetails` | `table_fee_transactions` | 台费计费流水 |
| `ODS_ASSISTANT_LEDGER` | `OdsAssistantLedgerTask` | `/AssistantPerformance/GetOrderAssistantDetails` | `assistant_service_records` | 助教服务流水 |
| `ODS_ASSISTANT_ABOLISH` | `OdsAssistantAbolishTask` | `/AssistantPerformance/GetAbolitionAssistant` | `assistant_cancellation_records` | 助教废除记录 |
| `ODS_STORE_GOODS_SALES` | `OdsGoodsLedgerTask` | `/TenantGoods/GetGoodsSalesList` | `store_goods_sales_records` | 门店商品销售流水 |
| `ODS_PAYMENT` | `OdsPaymentTask` | `/PayLog/GetPayLogListPage` | `payment_transactions` | 支付流水 |
| `ODS_REFUND` | `OdsRefundTask` | `/Order/GetRefundPayLogList` | `refund_transactions` | 退款流水 |
| `ODS_PLATFORM_COUPON` | `OdsCouponVerifyTask` | `/Promotion/GetOfflineCouponConsumePageList` | `platform_coupon_redemption_records` | 平台/团购券核销 |
| `ODS_MEMBER` | `OdsMemberTask` | `/MemberProfile/GetTenantMemberList` | `member_profiles` | 会员档案 |
| `ODS_MEMBER_CARD` | `OdsMemberCardTask` | `/MemberProfile/GetTenantMemberCardList` | `member_stored_value_cards` | 会员储值卡 |
| `ODS_MEMBER_BALANCE` | `OdsMemberBalanceTask` | `/MemberProfile/GetMemberCardBalanceChange` | `member_balance_changes` | 会员余额变动 |
| `ODS_RECHARGE_SETTLE` | `OdsRechargeSettleTask` | `/Site/GetRechargeSettleList` | `recharge_settlements` | 充值结算 |
| `ODS_GROUP_PACKAGE` | `OdsPackageTask` | `/PackageCoupon/QueryPackageCouponList` | `group_buy_packages` | 团购套餐定义 |
| `ODS_GROUP_BUY_REDEMPTION` | `OdsGroupBuyRedemptionTask` | `/Site/GetSiteTableUseDetails` | `group_buy_redemption_records` | 团购套餐核销 |
| `ODS_INVENTORY_STOCK` | `OdsInventoryStockTask` | `/TenantGoods/GetGoodsStockReport` | `goods_stock_summary` | 库存汇总 |
| `ODS_INVENTORY_CHANGE` | `OdsInventoryChangeTask` | `/GoodsStockManage/QueryGoodsOutboundReceipt` | `goods_stock_movements` | 库存变化记录 |
| `ODS_TABLES` | `OdsTablesTask` | `/Table/GetSiteTables` | `site_tables_master` | 台桌维表 |
| `ODS_GOODS_CATEGORY` | `OdsGoodsCategoryTask` | `/TenantGoodsCategory/QueryPrimarySecondaryCategory` | `stock_goods_category_tree` | 库存商品分类树 |
| `ODS_STORE_GOODS` | `OdsStoreGoodsTask` | `/TenantGoods/GetGoodsInventoryList` | `store_goods_master` | 门店商品档案 |
| `ODS_TABLE_FEE_DISCOUNT` | `OdsTableDiscountTask` | `/Site/GetTaiFeeAdjustList` | `table_fee_discount_records` | 台费折扣/调账 |
| `ODS_TENANT_GOODS` | `OdsTenantGoodsTask` | `/TenantGoods/QueryTenantGoods` | `tenant_goods_master` | 租户商品档案 |
| `ODS_SETTLEMENT_TICKET` | `OdsSettlementTicketTask` | `/Order/GetOrderSettleTicketNew` | `settlement_ticket_details` | 结账小票详情 |
> 所有目标表均位于 `billiards_ods` schema 下。
---
## 通用 ODS 任务架构BaseOdsTask + OdsTaskSpec 模式)
通用 ODS 任务采用**声明式配置**驱动:开发者只需定义一个 `OdsTaskSpec` 数据类实例,由 `BaseOdsTask` 提供统一的 `execute()` 流程,再通过 `_build_task_class()` 工厂函数动态生成 Python 类,最终在 `ODS_TASK_CLASSES` 字典中注册。
核心优势:
- **零代码新增任务**:只需添加一条 `OdsTaskSpec` 配置即可接入新的 API 端点
- **Schema-aware 写入**:运行时从 `information_schema` 读取目标表结构,自动匹配列名和类型,无需手写字段映射
- **统一去重与冲突处理**:通过 `content_hash``ON CONFLICT` 策略保证幂等性
### OdsTaskSpec 配置结构
`OdsTaskSpec` 是一个不可变数据类(`@dataclass(frozen=True)`),定义了单个 ODS 任务的全部配置。
#### 核心字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | `str` | 任务代码,如 `ODS_PAYMENT`,用于注册和调度 |
| `class_name` | `str` | 动态生成的 Python 类名,如 `OdsPaymentTask` |
| `table_name` | `str` | 目标 ODS 表全限定名,如 `billiards_ods.payment_transactions` |
| `endpoint` | `str` | 上游 API 端点路径,如 `/PayLog/GetPayLogListPage` |
| `description` | `str` | 任务描述(中文),用于日志和文档 |
#### 分页与数据提取字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `data_path` | `Tuple[str, ...]` | `("data",)` | API 响应中数据的 JSON 路径,逐层深入 |
| `list_key` | `str \| None` | `None` | 数据列表在 `data_path` 下的键名(如 `"settleList"`),为 `None` 时直接取 `data_path` 下的列表 |
#### 主键与列定义字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `pk_columns` | `Tuple[ColumnSpec, ...]` | `()` | 业务主键列定义,用于冲突检测(通常为 `id` |
| `extra_columns` | `Tuple[ColumnSpec, ...]` | `()` | 额外列定义,用于从嵌套 JSON 中提取特定字段 |
| `conflict_columns_override` | `Tuple[str, ...] \| None` | `None` | 覆盖默认冲突列(默认使用表的 PRIMARY KEY |
#### 时间窗口字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `requires_window` | `bool` | `True` | 是否需要时间窗口参数(事实表为 `True`,维度快照表为 `False` |
| `time_fields` | `Tuple[str, str] \| None` | `("startTime", "endTime")` | API 请求中时间窗口参数的键名对 |
| `include_site_id` | `bool` | `True` | 是否在请求中传 `siteId` 参数 |
| `extra_params` | `Dict[str, Any]` | `{}` | 额外的固定请求参数 |
#### 快照与软删除字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `snapshot_full_table` | `bool` | `False` | 全表快照模式API 返回全量数据,不在返回集中的记录标记为已删除 |
| `snapshot_window_columns` | `Tuple[str, ...] \| None` | `None` | 窗口快照模式:指定用于限定软删除范围的时间列 |
> **快照模式说明**:当 `snapshot_full_table=True` 或 `snapshot_window_columns` 非空时,任务会在每个分段结束后调用 `_mark_missing_as_deleted()`,将 API 未返回但数据库中存在的记录的 `is_delete` 标记为 `1`。此行为还需配合运行时配置 `run.snapshot_missing_delete=True` 才会生效。
#### 元数据控制字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `include_source_file` | `bool` | `True` | 是否写入 `source_file` 列 |
| `include_source_endpoint` | `bool` | `True` | 是否写入 `source_endpoint` 列 |
| `include_fetched_at` | `bool` | `True` | 是否写入 `fetched_at` 列 |
| `include_record_index` | `bool` | `False` | 是否写入 `record_index` 列 |
| `include_site_column` | `bool` | `True` | 是否写入 `site_id` / `store_id` 列 |
### ColumnSpec 列映射定义
`ColumnSpec` 是不可变数据类,定义单个列的映射规则:
| 字段 | 类型 | 说明 |
|------|------|------|
| `column` | `str` | 目标数据库列名 |
| `sources` | `Tuple[str, ...]` | 源 JSON 字段名列表,按优先级回退(支持点号路径) |
| `required` | `bool` | 是否为必填字段(缺失时跳过整条记录) |
| `default` | `Any` | 默认值(源字段全部为空时使用) |
| `transform` | `Callable \| None` | 类型转换函数 |
代码中提供了三个快捷构造函数:
| 函数 | 用途 | transform |
|------|------|-----------|
| `_int_col(name, *sources)` | 整数列 | `TypeParser.parse_int` |
| `_decimal_col(name, *sources)` | 金额列(保留 2 位小数) | `TypeParser.parse_decimal(v, 2)` |
| `_bool_col(name, *sources)` | 布尔列 | 自定义 `_to_bool` |
---
### BaseOdsTask 通用 execute 流程
所有通用 ODS 任务共享 `BaseOdsTask.execute()` 方法,流程如下:
```
execute(cursor_data)
├── 1. 解析时间窗口_resolve_window
│ ├── 优先级:用户手动覆盖 > 游标 + MAX(fetched_at) 兜底 > 默认窗口
│ └── 若游标推进但表未实际入库,回退到 MAX(fetched_at) 作为起点
├── 2. 窗口分段build_window_segments
│ └── 按闲忙时段或配置拆分为多个子窗口
├── 3. 准备运行参数
│ ├── 读取 store_id、page_size
│ ├── 解析快照模式配置
│ ├── 获取表主键列_get_table_pk_columns
│ └── 检查表是否有 is_delete 列
├── 4. 逐段执行for seg_start, seg_end in segments
│ ├── 4a. 构建 API 请求参数_build_params
│ ├── 4b. 分页抓取api.iter_paginated→ _insert_records_schema_aware 写入
│ ├── 4c. 软删除标记(若快照模式启用)
│ └── 4d. 提交事务db.commit
├── 5. 汇总结果
│ └── 返回 {status, counts, window, segments, request_params}
└── 异常处理
└── db.rollback + 记录错误日志 + 重新抛出
```
#### Schema-aware 写入(`_insert_records_schema_aware`
核心写入方法,运行时动态适配表结构:
1. **读取表结构**:从 `information_schema.columns` 获取目标表的所有列名、数据类型
2. **读取主键**:从 `information_schema.table_constraints` 获取 PRIMARY KEY 列
3. **记录合并**`_merge_record_layers()` 将嵌套 JSON 展平为单层字典
4. **is_delete 标准化**:统一为 `0/1`
5. **content_hash 计算**:对记录内容计算 SHA-256 哈希
6. **content_hash 去重**:与数据库中同一业务主键的最新 `content_hash` 比对,相同则跳过
7. **值映射**:逐列匹配,特殊列(`payload``source_file``fetched_at``content_hash`)自动填充
8. **冲突处理**:根据 `run.ods_conflict_mode` 配置:
| 模式 | SQL 行为 | 说明 |
|------|----------|------|
| `update` | `ON CONFLICT ... DO UPDATE SET ... WHERE IS DISTINCT FROM` | 全字段对比,仅在有变化时更新 |
| `backfill` | `ON CONFLICT ... DO UPDATE SET COALESCE(existing, new) WHERE ... IS NULL` | 仅回填 NULL 列 |
| `nothing` | `ON CONFLICT ... DO NOTHING` | 跳过已存在记录 |
9. **批量写入**:使用 `psycopg2.extras.execute_values` 分块写入,通过 `RETURNING (xmax = 0)` 区分插入和更新
#### 软删除标记(`_mark_missing_as_deleted`
当快照模式启用时,任务在每个分段结束后执行软删除:
- **全表快照**`snapshot_full_table=True`):将数据库中所有 `is_delete != 1` 且不在本次 API 返回集中的记录标记为 `is_delete=1`
- **窗口快照**`snapshot_window_columns` 非空):仅在指定时间列的窗口范围内执行软删除
---
### content_hash 去重机制
`content_hash` 是通用 ODS 任务的核心去重手段:
1. **计算**:排除元数据字段后,对剩余字段按 key 排序后 JSON 序列化,计算 SHA-256 哈希
2. **比对**:从数据库中按业务主键取最新一条记录的 `content_hash`
3. **跳过**:若新记录的 `content_hash` 与数据库中最新记录相同,则跳过写入
> 仅在目标表包含 `content_hash` 列且有 `fetched_at` 列时生效。
---
### 各任务详细配置
| 任务代码 | 需要窗口 | 快照模式 | 特殊说明 |
|----------|----------|----------|----------|
| `ODS_ASSISTANT_ACCOUNT` | 是 | 全表快照 | 助教账号档案,全量抓取后标记离职/删除 |
| `ODS_SETTLEMENT_RECORDS` | 是 | — | 结账记录,按时间窗口增量抓取 |
| `ODS_TABLE_USE` | 否 | 窗口(`create_time` | 台费计费流水 |
| `ODS_ASSISTANT_LEDGER` | 是 | 窗口(`create_time` | 助教服务流水 |
| `ODS_ASSISTANT_ABOLISH` | 是 | — | 助教废除记录 |
| `ODS_STORE_GOODS_SALES` | 否 | 窗口(`create_time` | 门店商品销售流水 |
| `ODS_PAYMENT` | 否 | — | 支付流水 |
| `ODS_REFUND` | 否 | 窗口(`pay_time` | 退款流水 |
| `ODS_PLATFORM_COUPON` | 否 | 窗口(`consume_time` | 平台/团购券核销 |
| `ODS_MEMBER` | 否 | — | 会员档案 |
| `ODS_MEMBER_CARD` | 否 | 全表快照 | 会员储值卡 |
| `ODS_MEMBER_BALANCE` | 否 | 窗口(`create_time` | 会员余额变动 |
| `ODS_RECHARGE_SETTLE` | 是 | — | 充值结算 |
| `ODS_GROUP_PACKAGE` | 否 | 全表快照 | 团购套餐定义 |
| `ODS_GROUP_BUY_REDEMPTION` | 否 | 窗口(`create_time` | 团购套餐核销 |
| `ODS_INVENTORY_STOCK` | 否 | — | 库存汇总 |
| `ODS_INVENTORY_CHANGE` | 是 | — | 库存变化记录 |
| `ODS_TABLES` | 否 | — | 台桌维表 |
| `ODS_GOODS_CATEGORY` | 否 | — | 库存商品分类树 |
| `ODS_STORE_GOODS` | 否 | 全表快照 | 门店商品档案 |
| `ODS_TABLE_FEE_DISCOUNT` | 否 | 窗口(`create_time` | 台费折扣/调账 |
| `ODS_TENANT_GOODS` | 否 | 全表快照 | 租户商品档案 |
| `ODS_SETTLEMENT_TICKET` | 否 | — | 结账小票详情(专用实现,见下文) |
> **特殊任务**`ODS_SETTLEMENT_TICKET` 虽然在 `ODS_TASK_SPECS` 中声明,但其 `ODS_TASK_CLASSES` 条目被 `OdsSettlementTicketTask` 专用实现覆盖。该任务不走标准分页抓取流程,而是先从 `payment_transactions` 表或支付 API 收集 `orderSettleId`,再逐个调用小票接口获取详情。

View File

@@ -0,0 +1,591 @@
# 工具类任务详解
> 本文档说明飞球 ETL 系统中所有工具类Utility和校验类Verification任务。
> 这些任务不属于 ODS/DWD/DWS/INDEX 四层业务管线,而是为系统初始化、
> 数据灌入、归档、截止时间检查和完整性校验等运维场景服务。
---
## 概述
工具类任务共 8 个(含 1 个校验类任务),注册于 `orchestration/task_registry.py`
| 任务代码 | Python 类 | 用途 | task_type | requires_db_config |
|----------|-----------|------|-----------|-------------------|
| `INIT_ODS_SCHEMA` | `InitOdsSchemaTask` | 执行 ODS + etl_admin 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 + etl_admin Schema 初始化
| 属性 | 值 |
|------|-----|
| 任务代码 | `INIT_ODS_SCHEMA` |
| Python 类 | `tasks.utility.init_schema_task.InitOdsSchemaTask` |
| 继承 | `BaseTask` |
| 用途 | 创建 ODS 层和 etl_admin 调度元数据的数据库结构,并准备运行时目录 |
### 执行流程
```
extract()
├── 读取 DDL 文件路径schema_ODS_doc.sql、schema_etl_admin.sql
├── 收集需创建的目录列表
└── 返回 SQL 文本 + 目录列表
load()
├── 创建必要目录log_root、export_root、fetch_root、ingest_dir
├── 执行 etl_admin DDLschema_etl_admin.sql
└── 执行 ODS DDLschema_ODS_doc.sql清洗后
```
### 执行的 DDL 文件
| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| `database/schema_etl_admin.sql` | `etl_admin` | `etl_task`(任务注册表)、`etl_cursor`(游标表)、`etl_run`(运行记录表) |
| `database/schema_ODS_doc.sql` | `billiards_ods` | 20+ 张 ODS 原始表member_profiles、settlement_records、payment_transactions 等) |
### 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` | ODS DDL 文件路径 |
| `schema.etl_admin_file` | `database/schema_etl_admin.sql` | etl_admin 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 billiards_dwd CASCADE
└── 执行 DWD DDLschema_dwd_doc.sql
```
### 执行的 DDL 文件
| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| `database/schema_dwd_doc.sql` | `billiards_dwd` | 维度表dim_*,含 SCD2 约束、事实表dwd_*、fact_*)、扩展表(*_ex |
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` | 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 billiards_dws CASCADE
└── 执行 DWS DDLschema_dws.sql
```
### 执行的 DDL 文件
| DDL 文件 | 创建的 Schema | 主要内容 |
|----------|--------------|----------|
| `database/schema_dws.sql` | `billiards_dws` | 配置表5 张 cfg_*、助教域5 张、会员域2 张、财务域7 张、订单汇总1 张) |
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` | 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 配置表插入初始数据(绩效档位、等级定价、奖金规则等) |
### 前置条件
- `billiards_dws` schema 已创建(需先执行 `INIT_DWS_SCHEMA`
- 配置表(`cfg_*`)已存在
### 执行流程
```
extract()
└── 读取 seed_dws_config.sql 文件内容
load()
└── 执行 SQLTRUNCATE + 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` | 种子数据 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` | `billiards_ods.member_profiles` |
| `member_balance_changes` | `billiards_ods.member_balance_changes` |
| `member_stored_value_cards` | `billiards_ods.member_stored_value_cards` |
| `recharge_settlements` | `billiards_ods.recharge_settlements` |
| `settlement_records` | `billiards_ods.settlement_records` |
| `assistant_cancellation_records` | `billiards_ods.assistant_cancellation_records` |
| `assistant_accounts_master` | `billiards_ods.assistant_accounts_master` |
| `assistant_service_records` | `billiards_ods.assistant_service_records` |
| `site_tables_master` | `billiards_ods.site_tables_master` |
| `table_fee_discount_records` | `billiards_ods.table_fee_discount_records` |
| `table_fee_transactions` | `billiards_ods.table_fee_transactions` |
| `goods_stock_movements` | `billiards_ods.goods_stock_movements` |
| `stock_goods_category_tree` | `billiards_ods.stock_goods_category_tree` |
| `goods_stock_summary` | `billiards_ods.goods_stock_summary` |
| `payment_transactions` | `billiards_ods.payment_transactions` |
| `refund_transactions` | `billiards_ods.refund_transactions` |
| `platform_coupon_redemption_records` | `billiards_ods.platform_coupon_redemption_records` |
| `group_buy_redemption_records` | `billiards_ods.group_buy_redemption_records` |
| `group_buy_packages` | `billiards_ods.group_buy_packages` |
| `settlement_ticket_details` | `billiards_ods.settlement_ticket_details` |
| `store_goods_master` | `billiards_ods.store_goods_master` |
| `tenant_goods_master` | `billiards_ods.tenant_goods_master` |
| `store_goods_sales_records` | `billiards_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. 查询 etl_admin 游标截止时间
│ ├── 关联 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
```
### 校验逻辑
- **游标截止时间**:从 `etl_admin.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
```