# DWS 层任务详解

> 本文档说明飞球 ETL 系统中 DWS（数据服务层）的所有任务。
> DWS 层负责从 DWD 层读取明细数据，按业务维度聚合计算后写入汇总表，
> 服务于助教业绩、会员分析、财务统计、指数算法等业务场景。

---

## 概述

DWS 层共有 21 个已注册任务（含 DWS_MAINTENANCE），按业务域分为六组：

### 助教业绩域（6 个）

| 任务代码 | Python 类 | 目标表 | 粒度 | 更新策略 |
|----------|-----------|--------|------|----------|
| `DWS_ASSISTANT_DAILY` | `AssistantDailyTask` | `dws_assistant_daily_detail` | 日期+助教 | delete-before-insert |
| `DWS_ASSISTANT_MONTHLY` | `AssistantMonthlyTask` | `dws_assistant_monthly_summary` | 月份+助教 | delete-before-insert |
| `DWS_ASSISTANT_CUSTOMER` | `AssistantCustomerTask` | `dws_assistant_customer_stats` | 日期+助教+会员 | delete-before-insert |
| `DWS_ASSISTANT_SALARY` | `AssistantSalaryTask` | `dws_assistant_salary_calc` | 月份+助教 | delete-before-insert |
| `DWS_ASSISTANT_FINANCE` | `AssistantFinanceTask` | `dws_assistant_finance_analysis` | 日期+助教 | delete-before-insert |
| `DWS_ASSISTANT_ORDER_CONTRIBUTION` | `AssistantOrderContributionTask` | `dws_assistant_order_contribution` | 日期+助教 | delete-before-insert |

### 会员分析域（2 个）

| 任务代码 | Python 类 | 目标表 | 粒度 | 更新策略 |
|----------|-----------|--------|------|----------|
| `DWS_MEMBER_CONSUMPTION` | `MemberConsumptionTask` | `dws_member_consumption_summary` | 日期+会员 | delete-before-insert |
| `DWS_MEMBER_VISIT` | `MemberVisitTask` | `dws_member_visit_detail` | 日期+会员+结账单 | delete-before-insert |

### 项目标签域（2 个）

| 任务代码 | Python 类 | 目标表 | 粒度 | 更新策略 |
|----------|-----------|--------|------|----------|
| `DWS_ASSISTANT_PROJECT_TAG` | `AssistantProjectTagTask` | `dws_assistant_project_tag` | 助教+时间窗口+项目 | 全量删除重建（按 site_id） |
| `DWS_MEMBER_PROJECT_TAG` | `MemberProjectTagTask` | `dws_member_project_tag` | 会员+时间窗口+项目 | 全量删除重建（按 site_id） |

### 财务统计域（6 个）

| 任务代码 | Python 类 | 目标表 | 粒度 | 更新策略 |
|----------|-----------|--------|------|----------|
| `DWS_FINANCE_DAILY` | `FinanceDailyTask` | `dws_finance_daily_summary` | 日期 | delete-before-insert |
| `DWS_FINANCE_RECHARGE` | `FinanceRechargeTask` | `dws_finance_recharge_summary` | 日期 | delete-before-insert |
| `DWS_FINANCE_INCOME_STRUCTURE` | `FinanceIncomeStructureTask` | `dws_finance_income_structure` | 日期+收入类型 | delete-before-insert |
| `DWS_FINANCE_DISCOUNT_DETAIL` | `FinanceDiscountDetailTask` | `dws_finance_discount_detail` | 日期+折扣类型 | delete-before-insert |
| `DWS_FINANCE_AREA_DAILY` | `FinanceAreaDailyTask` | `dws_finance_area_daily` | 日期+区域 | delete-before-insert |
| `DWS_FINANCE_BOARD_CACHE` | `FinanceBoardCacheTask` | `dws_finance_board_cache` | 时间范围+区域 | upsert（指纹对比） |

### 库存汇总域（3 个）

| 任务代码 | Python 类 | 目标表 | 粒度 | 更新策略 |
|----------|-----------|--------|------|----------|
| `DWS_GOODS_STOCK_DAILY` | `GoodsStockDailyTask` | `dws_goods_stock_daily_summary` | 日期+商品 | upsert |
| `DWS_GOODS_STOCK_WEEKLY` | `GoodsStockWeeklyTask` | `dws_goods_stock_weekly_summary` | ISO周+商品 | upsert |
| `DWS_GOODS_STOCK_MONTHLY` | `GoodsStockMonthlyTask` | `dws_goods_stock_monthly_summary` | 月份+商品 | upsert |

### 运维任务（2 个）

| 任务代码 | Python 类 | 继承 | 说明 | 更新策略 |
|----------|-----------|------|------|----------|
| `DWS_BUILD_ORDER_SUMMARY` | `DwsBuildOrderSummaryTask` | `BaseTask` | 构建订单汇总中间表 | delete-before-insert |
| `DWS_MAINTENANCE` | `DwsMaintenanceTask` | `BaseDwsTask` | 统一维护：物化视图刷新 + 历史数据清理 | REFRESH / DELETE |

> 注册位置：`orchestration/task_registry.py`
> 除 `DWS_BUILD_ORDER_SUMMARY` 继承 `BaseTask` 外，其余任务均继承 `BaseDwsTask`（或其子类）。

---

## BaseDwsTask 公共机制

`BaseDwsTask`（位于 `tasks/dws/base_dws_task.py`）继承自 `BaseTask`，为所有 DWS 层业务任务提供统一的基础设施。核心能力包括：

1. **默认 extract/load 模板方法** — 子类声明 `DATE_COL` + 实现 `_do_extract()` 即可复用默认实现
2. **时间分层与窗口计算** — 按业务需要选取不同跨度的数据范围
3. **配置缓存** — 从 DWS 配置表加载业绩档位、等级定价、奖金规则等，带 TTL 缓存
4. **幂等更新（delete-before-insert）** — 先删后插，保证重跑结果一致
5. **批量写入（bulk_insert / upsert）** — 两种落库方式，适配不同场景
6. **DWD 数据读取** — 分批迭代或直接查询 DWD 层数据
7. **SCD2 维度 as-of 取值** — 按历史生效期获取维度快照
8. **滚动窗口统计与排名计算** — 多窗口聚合和考虑并列的排名
9. **公共辅助函数** — `dws_helpers.py` 提供 `mask_mobile()`、`calc_days_since()`、`parse_id_list()`、`safe_division()` 等

### 默认模板方法（DATE_COL + _do_extract）

大多数 DWS 子类只需声明 `DATE_COL` 类属性并实现 `_do_extract()` 和 `transform()`，即可复用基类的默认 `extract()` 和 `load()` 实现：

```python
class BaseDwsTask(BaseTask):
    DATE_COL: str | None = None  # 子类声明日期列名

    def _do_extract(self, context: TaskContext) -> list[dict]:
        """子类实现：返回从 DWD 提取的原始行列表。"""
        raise NotImplementedError

    def extract(self, context: TaskContext) -> dict:
        """默认实现：调用 _do_extract 并包装为标准字典。"""
        rows = self._do_extract(context)
        return {"rows": rows, "start_date": ..., "end_date": ..., "site_id": ...}

    def load(self, transformed, context: TaskContext) -> dict:
        """默认实现：delete_existing_data(date_col=DATE_COL) + bulk_insert()。"""
        ...
```

有自定义逻辑的子类（如 `AssistantSalaryTask` 的月度删除）可覆盖默认实现。

### 子类必须实现的抽象方法

```python
def get_target_table(self) -> str:
    """返回目标表名（不含 schema），如 'dws_assistant_daily_detail'"""

def get_primary_keys(self) -> List[str]:
    """返回主键字段列表，如 ['site_id', 'assistant_id', 'stat_date']"""
```

这两个方法被 `delete_existing_data()`、`bulk_insert()`、`upsert()` 内部调用，用于定位目标表和构建冲突检测条件。

---

### 1. 时间分层（TimeLayer）

`TimeLayer` 是一个枚举类，定义了 5 个数据筛选层级。DWS 任务根据业务需要选择合适的层级来确定查询范围。

```python
class TimeLayer(Enum):
    LAST_2_DAYS   = "LAST_2_DAYS"      # 近 2 天
    LAST_1_MONTH  = "LAST_1_MONTH"     # 近 1 月（30 天）
    LAST_3_MONTHS = "LAST_3_MONTHS"    # 近 3 月（90 天）
    LAST_6_MONTHS = "LAST_6_MONTHS"    # 近 6 月（不含本月）
    ALL           = "ALL"               # 全量（从 2000-01-01 起）
```

#### 各层级的日期范围计算规则

| 层级 | 起始日期 | 结束日期 | 说明 |
|------|----------|----------|------|
| `LAST_2_DAYS` | `base_date - 1天` | `base_date` | 昨天 + 今天 |
| `LAST_1_MONTH` | `base_date - 30天` | `base_date` | 固定 30 天窗口 |
| `LAST_3_MONTHS` | `base_date - 90天` | `base_date` | 固定 90 天窗口 |
| `LAST_6_MONTHS` | 6 个月前月初 | 上月末 | **不含本月**，按自然月偏移 |
| `ALL` | `2000-01-01` | `base_date` | 全量回溯 |

> `base_date` 默认为 `date.today()`，可由调用方指定。

#### TimeWindow（财务报表专用）

除 `TimeLayer` 外，还提供 `TimeWindow` 枚举用于财务报表场景，支持更精细的时间口径：

| 窗口类型 | 说明 | 口径 |
|----------|------|------|
| `THIS_WEEK` | 本周 | 周一起始 |
| `LAST_WEEK` | 上周 | 上周一 ~ 上周日 |
| `THIS_MONTH` | 本月 | 月初 ~ 今天 |
| `LAST_MONTH` | 上月 | 上月初 ~ 上月末 |
| `LAST_3_MONTHS_EXCL_CURRENT` | 前 3 个月（不含本月） | 3 个月前月初 ~ 上月末 |
| `LAST_3_MONTHS_INCL_CURRENT` | 前 3 个月（含本月） | 2 个月前月初 ~ 今天 |
| `THIS_QUARTER` | 本季度 | 季度首月 1 日 ~ 今天 |
| `LAST_QUARTER` | 上季度 | 上季度首月 1 日 ~ 上季度末 |
| `LAST_6_MONTHS` | 最近半年 | 不含本月，同 TimeLayer |

#### 环比计算

`get_comparison_range(time_range)` 方法自动计算上一个等长区间，用于环比分析：

```
当前区间: [start, end]  →  天数 = end - start + 1
环比区间: [start - 天数, start - 1]
```

---

### 2. 配置缓存（ConfigCache）

DWS 层的业务计算依赖多张配置表（绩效档位、等级定价、奖金规则等）。`ConfigCache` 数据类将这些配置统一加载并缓存，避免每次计算都查库。

#### ConfigCache 数据结构

```python
@dataclass
class ConfigCache:
    performance_tiers: List[Dict]     # 绩效档位配置
    level_prices: List[Dict]          # 等级定价配置
    bonus_rules: List[Dict]           # 奖金规则配置
    area_categories: Dict[str, Dict]  # 区域分类映射
    skill_types: Dict[int, Dict]      # 技能类型映射
    loaded_at: datetime               # 加载时间
```

#### 缓存机制

- **类级别共享**：`_config_cache` 为类变量，同一进程内所有 DWS 任务实例共享同一份缓存
- **TTL 过期**：`_config_cache_ttl = 300`（5 分钟），超时后下次访问自动重新加载
- **强制刷新**：调用 `load_config_cache(force_reload=True)` 可跳过 TTL 检查
- **加载入口**：`load_config_cache()` 方法，内部依次调用 5 个私有加载方法

#### 配置表来源

| 配置项 | 来源表 | 用途 |
|--------|--------|------|
| `performance_tiers` | `dws.cfg_performance_tier` | 绩效档位（小时阈值 → 抽成/休假） |
| `level_prices` | `dws.cfg_assistant_level_price` | 助教等级单价（基础课/附加课） |
| `bonus_rules` | `dws.cfg_bonus_rules` | 奖金规则（冲刺奖金/Top 排名奖金） |
| `area_categories` | `dws.cfg_area_category` | 台桌级分类映射（台桌精确匹配/兜底） |
| `skill_types` | `dws.cfg_skill_type` | 技能 → 课程类型映射（BASE/BONUS/ROOM） |

#### 生效期过滤

所有配置项均支持 `effective_from` / `effective_to` 生效期字段。`_filter_by_effective_date(items, effective_date)` 方法按指定日期过滤，确保历史月份使用当时生效的配置版本。

#### 配置应用方法

| 方法 | 功能 | 匹配逻辑 |
|------|------|----------|
| `get_performance_tier(hours, is_new_hire, date)` | 按有效小时数匹配绩效档位 | 遍历档位，找 `min_hours ≤ hours < max_hours` 的首个匹配 |
| `get_performance_tier_by_id(tier_id, date)` | 按档位 ID 直接获取 | 精确匹配 `tier_id` |
| `get_level_price(level_code, date)` | 获取助教等级单价 | 按 `level_code` 匹配 |
| `get_course_type(skill_id)` | 技能 → 课程类型 | 查 `skill_types` 映射，默认 `BASE` |
| `get_area_category(area_name, table_name)` | 台桌 → 分类 | 台桌级精确匹配 → 兜底 `OTHER`（2026-03-09 改版，无 LIKE） |
| `calculate_sprint_bonus(hours, date)` | 冲刺奖金 | 不累计，取满足阈值的最高档 |
| `calculate_top_rank_bonus(rank, date)` | Top 排名奖金 | 第 1/2/3 名分别对应配置金额，>3 返回 0 |

---

### 3. 幂等更新策略（delete-before-insert）

DWS 层的主要更新策略是 **delete-before-insert**：在写入新数据前，先按日期范围和门店 ID 删除已有数据，再批量插入。这保证了任务重跑（幂等）时不会产生重复数据。

#### delete_existing_data()

```python
def delete_existing_data(
    self,
    context: TaskContext,
    date_col: str = "stat_date",
    extra_conditions: Optional[Dict[str, Any]] = None
) -> int:
```

**执行逻辑：**

1. 从 `get_target_table()` 获取目标表名，拼接 `dws.` schema 前缀
2. 构建 WHERE 条件：
   - `site_id = {context.store_id}`（门店隔离）
   - `{date_col} >= {window_start}` AND `{date_col} <= {window_end}`（日期范围）
   - 可选的 `extra_conditions`（如按助教 ID 过滤）
3. 执行 `DELETE FROM ... WHERE ...`
4. 返回删除行数

**典型调用模式（子类 load 方法中）：**

```python
def load(self, transformed, context):
    # 1. 先删除当前窗口内的旧数据
    deleted = self.delete_existing_data(context, date_col="stat_date")
    # 2. 再批量插入新数据
    inserted = self.bulk_insert(transformed)
    return {"deleted": deleted, "inserted": inserted}
```

---

### 4. 批量写入方法

BaseDwsTask 提供两种写入方法，子类根据场景选择：

#### bulk_insert() — 纯插入

```python
def bulk_insert(
    self,
    rows: List[Dict[str, Any]],
    columns: Optional[List[str]] = None
) -> int:
```

- 目标表由 `get_target_table()` 确定，自动拼接 `dws.` 前缀
- 若 `columns` 为 `None`，从第一行的 keys 自动推断
- 逐行执行 `INSERT INTO ... VALUES (...)`
- 返回插入行数
- **适用场景**：配合 `delete_existing_data()` 使用，先删后插

#### upsert() — 插入或更新

```python
def upsert(
    self,
    rows: List[Dict[str, Any]],
    columns: Optional[List[str]] = None,
    update_columns: Optional[List[str]] = None
) -> Tuple[int, int]:
```

- 利用 PostgreSQL 的 `INSERT ... ON CONFLICT (...) DO UPDATE SET ...` 语法
- 冲突检测键由 `get_primary_keys()` 提供
- 若 `update_columns` 为 `None`，自动排除主键列和 `created_at` 后取剩余列
- 更新时自动追加 `updated_at = NOW()`
- 返回 `(inserted, updated)` 元组
- **适用场景**：不适合先删后插的场景（如需保留 `created_at` 等元数据）

#### 两种策略对比

| 特性 | delete-before-insert + bulk_insert | upsert |
|------|-----------------------------------|--------|
| 幂等性 | ✅ 先删后插，天然幂等 | ✅ ON CONFLICT 保证幂等 |
| 性能 | 批量删除 + 批量插入，适合大范围重算 | 逐行判断冲突，适合小批量增量 |
| 元数据保留 | ❌ 删除后 `created_at` 会重置 | ✅ 仅更新指定列 |
| 主要使用者 | 大多数 DWS 汇总任务 | 少数需要保留历史元数据的场景 |

---

### 5. DWD 数据读取

#### iter_dwd_rows() — 分批迭代

```python
def iter_dwd_rows(
    self, table_name, columns, start_date, end_date,
    date_col="created_at", where_clause="", order_by="", batch_size=1000
) -> Iterator[List[Dict[str, Any]]]:
```

- 按 `LIMIT/OFFSET` 分批读取 `dwd.{table_name}`
- 默认按 `date_col ASC` 排序，每批 1000 行
- 自动构建日期范围 WHERE 条件，支持追加自定义 `where_clause`
- 以生成器方式 yield 每批数据，适合处理大数据量

#### query_dwd() — 直接查询

```python
def query_dwd(self, sql, params=None) -> List[Dict[str, Any]]:
```

- 直接执行任意 SQL，返回字典列表
- 适合复杂聚合查询或多表 JOIN 场景

---

### 6. SCD2 维度 as-of 取值

DWS 汇总计算涉及历史月份时，不能直接使用维度表的"当前版本"，需要按生效期取历史快照。

#### get_assistant_level_asof(assistant_id, asof_date)

查询 `dwd.dim_assistant` 表，按 `scd2_start_time ≤ asof_date` 且 `scd2_end_time IS NULL 或 > asof_date` 条件取最近一条记录，返回助教在指定日期的等级信息（`level_code`、`level_name`）。

#### get_member_card_balance_asof(member_id, asof_date)

查询 `dwd.dim_member_card_account` 表，按 SCD2 生效期取会员在指定日期的卡余额，区分现金卡（`card_type_id = 2793249295533893`）和赠送卡（台费卡/活动抵用券/酒水卡），返回 `cash_balance`、`gift_balance`、`total_balance`。

---

### 7. 辅助计算方法

#### 滚动窗口统计

`calculate_rolling_stats(base_date, entity_id, entity_type, stat_sql, windows)` 按预定义的窗口天数列表（默认 `[7, 10, 15, 30, 60, 90]`）执行统计 SQL，返回各窗口的聚合结果，键名格式为 `{指标}_{天数}d`。

#### 排名计算（考虑并列）

`calculate_rank_with_ties(values)` 接收 `(entity_id, score)` 列表，按分数降序排名。并列时共享同一排名，下一名跳过（如 2 个第 1 名，下一个是第 3 名）。返回 `(entity_id, rank, dense_rank)` 元组列表。

#### 其他工具方法

| 方法 | 功能 |
|------|------|
| `is_new_hire_in_month(hire_date, stat_month)` | 判断是否为当月新入职（月 1 日后入职） |
| `is_guest(member_id)` | 判断是否为散客（`member_id` 为 0 或 None） |
| `safe_decimal(value, default)` | 安全转换为 `Decimal`，异常返回默认值 |
| `safe_int(value, default)` | 安全转换为 `int`，异常返回默认值 |
| `seconds_to_hours(seconds)` | 秒 → 小时（`Decimal` 精度） |
| `hours_to_seconds(hours)` | 小时 → 秒 |
| `get_month_first_day(dt)` | 获取月第一天 |
| `get_month_last_day(dt)` | 获取月最后一天 |
| `get_comparison_range(time_range)` | 计算环比区间 |

---

## 助教业绩域

助教业绩域包含 5 个任务，围绕助教的日度服务明细、月度汇总与排名、客户关系、工资计算、收支分析展开。数据流向为：

```
dwd_assistant_service_log ──┬──► DWS_ASSISTANT_DAILY（日度明细）
dwd_assistant_service_log_ex ┘         │
                                      ▼
                              DWS_ASSISTANT_MONTHLY（月度汇总+档位+排名）
                                      │
                                      ▼
                              DWS_ASSISTANT_SALARY（工资计算）
                                      │
dwd_assistant_service_log ────► DWS_ASSISTANT_FINANCE（收支分析）◄── dws_assistant_salary_calc
dwd_assistant_service_log ────► DWS_ASSISTANT_CUSTOMER（客户关系统计）
```

---

### DWS_ASSISTANT_ORDER_CONTRIBUTION — 助教订单流水四项统计

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_ORDER_CONTRIBUTION` |
| Python 类 | `AssistantOrderContributionTask`（`tasks/dws/assistant_order_contribution_task.py`） |
| 目标表 | `dws.dws_assistant_order_contribution` |
| 主键 | `site_id`, `assistant_id`, `stat_date` |
| 粒度 | 日期 + 助教 |
| 更新策略 | delete-before-insert（按日期窗口） |
| 更新频率 | 每日更新 |
| 依赖 | `DWD_LOAD_FROM_ODS` |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结算主表（订单级信息） |
| `dwd_table_fee_log` | `dwd` | 台费明细（台桌使用时长、台费金额） |
| `dwd_assistant_service_log` | `dwd` | 助教服务记录（服务时长、流水、分成） |

#### 聚合维度与输出字段

按 `(assistant_id, stat_date)` 聚合，输出以下字段：

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `assistant_id`, `assistant_nickname`, `stat_date` | 门店、助教、日期 |
| 四项统计 | `order_gross_revenue` | 订单总流水：台费 + 酒水食品 + 所有助教服务费 |
| | `order_net_revenue` | 订单净流水：订单总流水 - 所有助教服务分成 |
| | `time_weighted_revenue` | 时效贡献流水：按服务时长折算的个人贡献 |
| | `time_weighted_net_revenue` | 时效净贡献：时效贡献流水 - 个人服务分成 |
| 辅助 | `order_count`, `total_service_seconds` | 参与订单数、总服务时长秒数 |

#### 核心业务逻辑

1. **订单总流水（order_gross_revenue）**：助教参与订单的全部流水（台费 + 酒水食品 + 所有助教服务费），每个参与助教获得相同值
2. **订单净流水（order_net_revenue）**：订单总流水 - 该订单所有助教的服务分成总额，每个参与助教获得相同值
3. **时效贡献流水（time_weighted_revenue）**：台费按助教在各台桌的服务时长占比分摊 + 个人服务费 + 酒水食品按总时长比例均分
4. **时效净贡献（time_weighted_net_revenue）**：时效贡献流水 - 该助教个人的服务分成
5. **超休/打赏课特殊处理**：`course_type = BONUS` 的助教不参与订单级分摊，四项统计均等于个人服务流水和分成
6. **台费分摊公式**：`billable_seconds = MAX(SUM(助教服务时长), 台桌使用时长)`，各助教按 `service_seconds / billable_seconds` 比例分摊

---

### DWS_ASSISTANT_DAILY — 助教日度业绩明细

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_DAILY` |
| Python 类 | `AssistantDailyTask`（`tasks/dws/assistant_daily_task.py`） |
| 目标表 | `dws.dws_assistant_daily_detail` |
| 主键 | `site_id`, `assistant_id`, `stat_date` |
| 粒度 | 日期 + 助教 |
| 更新策略 | delete-before-insert（按日期窗口） |
| 更新频率 | 每小时增量更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（主数据源） |
| `dwd_assistant_service_log_ex` | `dwd` | 扩展表（`is_trash` 标记废除记录） |
| `dim_assistant` | `dwd` | 助教维度（SCD2，获取当日等级） |
| `cfg_skill_type` | `dws` | 技能 → 课程类型映射 |

#### 聚合维度与输出字段

按 `(assistant_id, service_date)` 聚合，输出以下字段：

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `assistant_id`, `assistant_nickname`, `stat_date` | 门店、助教、日期 |
| 等级 | `assistant_level_code`, `assistant_level_name` | SCD2 as-of 取值（`level_code`），`level_name` 由 code 静态映射得出 |
| 服务次数 | `total_service_count`, `base_service_count`, `bonus_service_count`, `room_service_count` | 总/基础课/附加课/包厢课 |
| 计费秒数 | `total_seconds`, `base_seconds`, `bonus_seconds`, `room_seconds` | 原始秒数 |
| 计费小时 | `total_hours`, `base_hours`, `bonus_hours`, `room_hours` | 秒数 ÷ 3600，`Decimal` 精度 |
| 计费金额 | `total_ledger_amount`, `base_ledger_amount`, `bonus_ledger_amount`, `room_ledger_amount` | 台账金额 |
| 去重统计 | `unique_customers`, `unique_tables` | 去重客户数（排除散客）、去重台桌数 |
| 废除统计 | `trashed_seconds`, `trashed_count` | 被废除的秒数和次数 |
| 惩罚检测 | `penalty_minutes`, `penalty_reason`, `is_exempt`, `per_hour_contribution` | 惩罚分钟数（公式：`actual_minutes × (1 - per_hour_contribution / 24)`）、惩罚原因、是否豁免、每小时贡献金额（= `base_ledger_amount / base_hours / overlap_count`） |

#### 核心业务逻辑

1. **课程类型分类**：通过 `skill_id` 查询 `cfg_skill_type` 映射，分为 `BASE`（基础课）、`BONUS`（附加课）、`ROOM`（包厢课），未匹配默认 `BASE`
2. **废除记录排除**：通过 JOIN `dwd_assistant_service_log_ex` 的 `is_trash = 1` 标记识别废除记录（`dwd_assistant_trash_event` 已于 2026-02-22 废弃），被废除的服务记录不计入有效业绩（服务次数、时长、金额），但单独统计 `trashed_seconds` 和 `trashed_count`
3. **助教等级 SCD2 取值**：调用 `get_assistant_level_asof(assistant_id, service_date)` 获取统计日当日生效的等级版本，而非当前最新版本
4. **散客过滤**：`unique_customers` 统计时排除 `member_id` 为 0 或 None 的散客
5. **客户/台桌去重**：无论服务记录是否被废除，客户和台桌均参与去重统计
6. **定档折算惩罚检测**：聚合完成后，检测同一台桌多名助教重叠挂台的违规情况（规则2）。计算 `per_hour_contribution = base_ledger_amount / base_hours / overlap_count`，若低于阈值（默认 24 元/小时）则按比例扣减 `penalty_minutes`。豁免助教（`is_exempt = True`）不参与惩罚计算。

---

### DWS_ASSISTANT_MONTHLY — 助教月度业绩汇总

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_MONTHLY` |
| Python 类 | `AssistantMonthlyTask`（`tasks/dws/assistant_monthly_task.py`） |
| 目标表 | `dws.dws_assistant_monthly_summary` |
| 主键 | `site_id`, `assistant_id`, `stat_month` |
| 粒度 | 月份 + 助教 |
| 更新策略 | delete-before-insert（按月份） |
| 更新频率 | 每日更新当月数据 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dws_assistant_daily_detail` | `dws` | 日度明细（按月聚合） |
| `dwd_assistant_service_log` | `dwd` | 月度去重客户/台桌（直接从 DWD 去重，避免日度求和失真） |
| `dim_assistant` | `dwd` | 助教维度（入职日期、当前等级） |
| `cfg_performance_tier` | `dws` | 绩效档位配置 |

#### 聚合维度与输出字段

按 `(assistant_id, stat_month)` 聚合，输出以下字段：

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `assistant_id`, `assistant_nickname`, `stat_month` | 门店、助教、月份 |
| 等级 | `assistant_level_code`, `assistant_level_name` | 月末 SCD2 as-of 取值 |
| 入职 | `hire_date`, `is_new_hire` | 入职日期、是否当月新入职 |
| 工作天数 | `work_days` | `COUNT(DISTINCT stat_date)` |
| 服务次数 | `total_service_count`, `base_service_count`, `bonus_service_count`, `room_service_count` | 月度累计 |
| 时长 | `total_hours`, `base_hours`, `bonus_hours`, `room_hours`, `effective_hours`, `trashed_hours` | 月度累计小时数 |
| 金额 | `total_ledger_amount`, `base_ledger_amount`, `bonus_ledger_amount`, `room_ledger_amount` | 月度累计金额 |
| 去重统计 | `unique_customers`, `unique_tables` | 月度去重（从 DWD 直接去重） |
| 平均时长 | `avg_service_seconds` | 总秒数 ÷ 总服务次数 |
| 档位 | `tier_id`, `tier_code`, `tier_name` | 匹配的绩效档位 |
| 排名 | `rank_by_hours`, `rank_with_ties` | 按有效业绩小时数排名（考虑并列） |

#### 核心业务逻辑

**1. 有效业绩计算**

```
effective_hours = total_hours - trashed_hours
```

其中 `trashed_hours` 由日度明细的 `trashed_seconds` 累加后转换为小时。

**2. 新入职判断**

调用 `is_new_hire_in_month(hire_date, stat_month)`：入职日期在当月 1 日 0 点之后即视为新入职。

**3. 档位匹配**

- 正常助教：以 `effective_hours` 匹配 `cfg_performance_tier`，找 `min_hours ≤ hours < max_hours` 的首个档位
- 新入职助教：先按日均折算 30 天（`effective_hours / work_days × 30`），再匹配档位
- **新人封顶规则**：当同时满足以下条件时，档位不超过配置的最大等级（默认 2 档）：
  - 统计月份 ≥ 封顶规则生效月（配置项 `dws.monthly.new_hire_cap_effective_from`，默认 `2026-03-01`）
  - 入职日期晚于封顶日（配置项 `dws.monthly.new_hire_cap_day`，默认当月 25 日）

**4. 排名逻辑**

按 `effective_hours` 降序排名，使用 `calculate_rank_with_ties()` 方法：
- 并列时共享同一排名，下一名跳过（如 2 个第 1 名，下一个是第 3 名）
- `rank_by_hours` 和 `rank_with_ties` 均使用此排名结果
- 排名结果用于后续 `DWS_ASSISTANT_SALARY` 的 Top3 奖金计算

**5. 月度去重客户/台桌**

`unique_customers` 和 `unique_tables` 优先使用从 `dwd_assistant_service_log` 直接按月去重的结果（`_extract_monthly_uniques`），而非日度明细的简单求和，避免跨日重复计数导致失真。

**6. 月份过滤调度**

- 默认仅处理当月；月初前 N 天（配置项 `dws.monthly.prev_month_grace_days`，默认 5）可同时处理上月
- 配置 `dws.monthly.allow_history = True` 可处理全部历史月份
- 配置 `dws.monthly.history_months` 可指定回溯月数

---

### DWS_ASSISTANT_CUSTOMER — 助教-客户关系统计

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_CUSTOMER` |
| Python 类 | `AssistantCustomerTask`（`tasks/dws/assistant_customer_task.py`） |
| 目标表 | `dws.dws_assistant_customer_stats` |
| 主键 | `site_id`, `assistant_id`, `member_id`, `stat_date` |
| 粒度 | 统计日期 + 助教 + 会员 |
| 更新策略 | delete-before-insert（按统计日期） |
| 更新频率 | 每日更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（全量，用于累计和滚动窗口统计） |
| `dim_member` | `dwd` | 会员维度（昵称、手机号） |
| `dim_assistant` | `dwd` | 助教维度（当前有效记录） |

#### 聚合维度与输出字段

按 `(assistant_id, member_id)` 聚合，以 `stat_date`（窗口结束日期）为统计基准日，输出以下字段：

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `assistant_id`, `assistant_nickname`, `member_id`, `member_nickname`, `member_mobile`, `stat_date` | 助教、会员、统计日期 |
| 全量累计 | `first_service_date`, `last_service_date`, `total_service_count`, `total_service_hours`, `total_service_amount` | 首次/最近服务日期、累计次数/时长/金额 |
| 滚动窗口 | `service_count_{N}d`, `service_hours_{N}d`, `service_amount_{N}d`（N = 7/10/15/30/60/90） | 各窗口的服务次数、时长、金额 |
| 活跃度 | `days_since_last`, `is_active_7d`, `is_active_30d` | 距最近服务天数、近 7/30 天是否活跃 |

#### 核心业务逻辑

1. **散客排除**：`member_id` 为 0 或 None 的散客不进入此表统计
2. **滚动窗口**：在单条 SQL 中通过 `CASE WHEN service_date >= stat_date - INTERVAL '{N-1} days'` 实现 6 个窗口（7/10/15/30/60/90 天）的并行计算
3. **活跃度判定**：`is_active_7d` = 近 7 天服务次数 > 0；`is_active_30d` = 近 30 天服务次数 > 0
4. **HAVING 过滤**：仅保留最近 90 天内有服务记录的助教-客户对，避免输出过多历史冷数据
5. **手机号脱敏**：`member_mobile` 输出时中间 4 位替换为 `****`（如 `138****1234`）

---

### DWS_ASSISTANT_SALARY — 助教工资计算

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_SALARY` |
| Python 类 | `AssistantSalaryTask`（`tasks/dws/assistant_salary_task.py`） |
| 目标表 | `dws.dws_assistant_salary_calc` |
| 主键 | `site_id`, `assistant_id`, `salary_month` |
| 粒度 | 月份 + 助教 |
| 更新策略 | delete-before-insert（按月份） |
| 更新频率 | 月初计算上月工资 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dws_assistant_monthly_summary` | `dws` | 月度业绩汇总（有效小时数、档位、排名） |
| `dws_assistant_recharge_commission` | `dws` | 充值提成（Excel 导入） |
| `cfg_performance_tier` | `dws` | 绩效档位（抽成比例、假期天数） |
| `cfg_assistant_level_price` | `dws` | 等级定价（客户支付价格） |
| `cfg_bonus_rules` | `dws` | 奖金规则（冲刺奖金、Top 排名奖金） |

#### 输出字段

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `assistant_id`, `assistant_nickname`, `salary_month` | 门店、助教、工资月份 |
| 等级与档位 | `assistant_level_code`, `assistant_level_name`, `hire_date`, `is_new_hire`, `tier_id`, `tier_code`, `tier_name`, `rank_with_ties` | 等级、档位、排名 |
| 时长 | `effective_hours`, `base_hours`, `bonus_hours`, `room_hours` | 有效小时数（来自月度汇总） |
| 定价信息 | `base_course_price`, `bonus_course_price`, `base_deduction`, `bonus_deduction_ratio` | 客户支付价格、球房抽成 |
| 收入明细 | `base_income`, `bonus_income`, `room_income`, `total_course_income` | 各课程类型收入 |
| 奖金明细 | `sprint_bonus`, `top_rank_bonus`, `recharge_commission`, `other_bonus`, `total_bonus` | 各类奖金 |
| 应发工资 | `gross_salary` | 课时收入 + 奖金合计 |
| 假期 | `vacation_days`, `vacation_unlimited` | 档位对应的假期天数 |
| 备注 | `calc_notes` | 计算备注（新入职、档位、奖金等） |

#### 工资计算公式

```
应发工资 = 课时收入 + 奖金合计

课时收入 = 基础课收入 + 附加课收入 + 包厢课收入
奖金合计 = 冲刺奖金 + Top3排名奖金 + 充值提成 + 其他奖金
```

**基础课收入**

```
基础课收入 = base_hours × (base_course_price - base_deduction)
```

- `base_course_price`：客户支付价格，按助教等级区分（初级 98 / 中级 108 / 高级 118 / 星级 138 元/小时）
- `base_deduction`：专业课抽成（元/小时），由档位配置决定，球房从每小时扣除
- 示例：中级助教 170 小时，3 档（抽成 13 元）→ 170 × (108 - 13) = 16,150 元

**附加课收入**

```
附加课收入 = bonus_hours × bonus_course_price × (1 - bonus_deduction_ratio)
```

- `bonus_course_price`：附加课客户支付价格（固定 190 元/小时）
- `bonus_deduction_ratio`：打赏课抽成比例，由档位配置决定
- 示例：15 小时，3 档（抽成比例 0.35）→ 15 × 190 × (1 - 0.35) = 1,852.5 元

**包厢课收入**

```
包厢课收入 = room_hours × (room_course_price - base_deduction)
```

- `room_course_price`：包厢课统一价格（配置项 `dws.salary.room_course_price`，默认 138 元/小时）

**冲刺奖金**

调用 `calculate_sprint_bonus(effective_hours, salary_month)`，按 `cfg_bonus_rules` 配置表匹配：
- 不累计，取满足有效小时数阈值的最高档奖金

**Top3 排名奖金**

调用 `calculate_top_rank_bonus(rank, salary_month)`，仅排名前 3 的助教获得：
- 第 1 名：1,000 元
- 第 2 名：600 元
- 第 3 名：400 元
- 并列排名均可获得对应奖金

**充值提成**

从 `dws_assistant_recharge_commission` 表读取，按 `assistant_id` 汇总当月提成金额。

**SCD2 口径**

等级定价使用 `get_level_price(level_code, salary_month)` 按月份取历史生效值，确保历史月份使用当时的定价版本。

#### 运行调度

- 默认仅在月初前 N 天运行（配置项 `dws.salary.run_days`，默认 5），超过则跳过
- 配置 `dws.salary.allow_out_of_cycle = True` 可强制运行
- 工资月份判定：月初（day ≤ 5）计算上月工资，否则计算当月（调整场景）

---

### DWS_ASSISTANT_FINANCE — 助教收支分析

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_FINANCE` |
| Python 类 | `AssistantFinanceTask`（`tasks/dws/assistant_finance_task.py`） |
| 目标表 | `dws.dws_assistant_finance_analysis` |
| 主键 | `site_id`, `stat_date`, `assistant_id` |
| 粒度 | 日期 + 助教 |
| 更新策略 | delete-before-insert（按日期） |
| 更新频率 | 每日更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（日度收入） |
| `cfg_skill_type` | `dws` | 技能 → 课程类型映射（收入分类） |
| `dws_assistant_salary_calc` | `dws` | 工资计算结果（月度成本） |
| `dws_assistant_daily_detail` | `dws` | 日度明细（计算月度工作天数） |

#### 输出字段

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `stat_date`, `assistant_id`, `assistant_nickname` | 门店、日期、助教 |
| 收入 | `revenue_total`, `revenue_base`, `revenue_bonus`, `revenue_room` | 日度总收入及按课程类型拆分 |
| 成本 | `cost_daily` | 日均成本 |
| 利润 | `gross_profit`, `gross_margin` | 毛利润、毛利率 |
| 服务量 | `service_count`, `service_hours`, `room_service_count`, `room_service_hours`, `unique_customers` | 服务次数、时长、包厢服务、去重客户数 |

#### 核心业务逻辑

**1. 日度收入计算**

从 `dwd_assistant_service_log` 按 `(DATE(start_use_time), site_assistant_id)` 聚合：
- `revenue_total`：`SUM(ledger_amount)`
- `revenue_base` / `revenue_bonus` / `revenue_room`：按 `cfg_skill_type.course_type_code` 分类汇总
- `service_hours`：`SUM(income_seconds) / 3600.0`
- `unique_customers`：`COUNT(DISTINCT tenant_member_id)`（排除 ≤ 0）

**2. 日均成本计算**

```
cost_daily = gross_salary / work_days
```

- `gross_salary`：从 `dws_assistant_salary_calc` 取对应月份的应发工资
- `work_days`：从 `dws_assistant_daily_detail` 按月统计 `COUNT(DISTINCT stat_date)`，默认 20 天

**3. 毛利润与毛利率**

```
gross_profit = revenue_total - cost_daily
gross_margin = gross_profit / revenue_total    （revenue_total > 0 时）
             = 0                                （revenue_total = 0 时）
```

**4. 依赖关系**

此任务依赖 `DWS_ASSISTANT_SALARY` 和 `DWS_ASSISTANT_DAILY` 的输出数据，应在这两个任务完成后运行。

---

## 会员分析域

会员分析域包含 2 个任务，围绕会员的消费行为汇总和到店明细展开。数据流向为：

```
dwd_settlement_head ──────────┬──► DWS_MEMBER_CONSUMPTION（消费汇总+分层）
dim_member ───────────────────┤
dim_member_card_account ──────┘

dwd_settlement_head ──────────┬──► DWS_MEMBER_VISIT（到店明细）
dwd_assistant_service_log ────┤
dwd_table_fee_log ────────────┤
dim_member ───────────────────┤
dim_table ────────────────────┘
```

---

### DWS_MEMBER_CONSUMPTION — 会员消费汇总

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_MEMBER_CONSUMPTION` |
| Python 类 | `MemberConsumptionTask`（`tasks/dws/member_consumption_task.py`） |
| 目标表 | `dws.dws_member_consumption_summary` |
| 主键 | `site_id`, `member_id`, `stat_date` |
| 粒度 | 统计日期 + 会员 |
| 更新策略 | delete-before-insert（按统计日期） |
| 更新频率 | 每日更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结账单头表（消费金额、台费、商品、助教费用） |
| `dim_member` | `dwd` | 会员维度（SCD2 当前版本，昵称、手机号、卡等级、注册日期、累计充值） |
| `dim_member_card_account` | `dwd` | 会员卡账户（SCD2 当前版本，卡余额） |

#### 聚合维度与输出字段

按 `(member_id)` 聚合全量消费记录，以 `stat_date`（窗口结束日期）为统计基准日，输出以下字段：

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `member_id`, `stat_date` | 门店、会员、统计日期 |
| 会员信息 | `member_nickname`, `member_mobile`, `card_grade_name`, `register_date` | 昵称、脱敏手机号、卡等级、注册日期 |
| 全量累计 | `first_consume_date`, `last_consume_date`, `total_visit_count`, `total_consume_amount`, `total_recharge_amount`, `total_table_fee`, `total_goods_amount`, `total_assistant_amount` | 首次/最近消费日期、累计到店次数、累计消费金额、累计充值金额、累计台费、累计商品金额、累计助教费用 |
| 滚动窗口（次数） | `visit_count_7d`, `visit_count_10d`, `visit_count_15d`, `visit_count_30d`, `visit_count_60d`, `visit_count_90d` | 各窗口到店次数 |
| 滚动窗口（金额） | `consume_amount_7d`, `consume_amount_10d`, `consume_amount_15d`, `consume_amount_30d`, `consume_amount_60d`, `consume_amount_90d` | 各窗口消费金额 |
| 充值窗口（笔数） | `recharge_count_30d`, `recharge_count_60d`, `recharge_count_90d` | 近 30/60/90 天充值笔数（来源：dwd_recharge_order） |
| 充值窗口（金额） | `recharge_amount_30d`, `recharge_amount_60d`, `recharge_amount_90d` | 近 30/60/90 天充值金额（仅 `pay_amount` 现金部分，不含 `point_amount` 赠送） |
| 次均消费 | `avg_ticket_amount` | total_consume_amount / MAX(total_visit_count, 1) |
| 卡余额 | `cash_card_balance`, `gift_card_balance`, `total_card_balance` | 储值卡（现金卡）余额、赠送卡余额、总余额 |
| 活跃度 | `days_since_last`, `is_active_7d`, `is_active_30d`, `is_active_90d` | 距最近消费天数、近 7/30/90 天是否活跃 |
| 客户分层 | `customer_tier` | 分层标签（高价值/中等/低活跃/流失） |

#### 核心业务逻辑

**1. 散客排除**

`member_id` 为 0 或 None 的散客不进入此表统计。SQL 层面和 transform 阶段均做过滤，同时通过 `settle_type IN (1, 3)` 仅保留台桌结账和商城订单（排除退货/退款）。

**2. 消费统计来源**

从 `dwd_settlement_head` 按 `member_id` 聚合，消费金额使用 `items_sum` 口径拆分为：
- `items_sum`：消费项目合计（= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`）
- `table_charge_money`：台费
- `goods_money`：商品金额
- `assistant_pd_money + assistant_cx_money`：助教费用（陪打 + 超休合计）

> ⚠️ 不使用 `consume_money`（三种历史口径混合），详见 `docs/reports/DWD-DOC/consume/consume-money-caliber.md`

**3. 滚动窗口**

在单条 SQL 中通过 `CASE WHEN consume_date >= stat_date - INTERVAL '{N-1} days'` 实现 6 个窗口（7/10/15/30/60/90 天）的并行计算，同时统计到店次数和消费金额。

**4. 卡余额区分**

从 `dim_member_card_account` 按 `card_type_id` 区分卡类型：

| 卡类型 | card_type_id | 归入字段 |
|--------|-------------|----------|
| 储值卡（现金卡） | `2793249295533893` | `cash_card_balance` |
| 台费卡 | `2791990152417157` | `gift_card_balance` |
| 活动抵用券 | `2793266846533445` | `gift_card_balance` |
| 酒水卡 | `2794699703437125` | `gift_card_balance` |
| 年卡 | `2791987095408517` | ⚠️ 未统计（遗漏） |
| 月卡 | `2793306611533637` | ⚠️ 未统计（遗漏） |

`total_card_balance = cash_card_balance + gift_card_balance`

仅取 SCD2 当前版本（`scd2_is_current = 1`）且未删除（`is_delete = 0`）的记录。同一会员可能有多张卡，余额按类型累加。

**5. 活跃度判定**

- `days_since_last`：`stat_date - last_consume_date` 的天数差，无消费记录时为 `NULL`
- `is_active_7d`：近 7 天到店次数 > 0
- `is_active_30d`：近 30 天到店次数 > 0
- `is_active_90d`：近 90 天到店次数 > 0

**6. 客户分层规则**

按以下优先级判定 `customer_tier`：

| 分层 | 条件 | 说明 |
|------|------|------|
| `高价值` | 90 天内消费 ≥ 3 次 **且** 消费金额 ≥ 1000 元 | 高频高额客户 |
| `中等` | 30 天内有消费 | 近期活跃客户 |
| `低活跃` | 90 天内有消费但 30 天内无消费 | 有消费但频率下降 |
| `流失` | 90 天内无消费 | 长期未到店 |

判定顺序为从上到下，命中即返回。

**7. 手机号脱敏**

`member_mobile` 输出时中间 4 位替换为 `****`（如 `138****1234`），长度不足 7 位时原样输出。

---

### DWS_MEMBER_VISIT — 会员到店明细

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_MEMBER_VISIT` |
| Python 类 | `MemberVisitTask`（`tasks/dws/member_visit_task.py`） |
| 目标表 | `dws.dws_member_visit_detail` |
| 主键 | `site_id`, `member_id`, `order_settle_id` |
| 粒度 | 会员 + 结账单（每次到店一条记录） |
| 更新策略 | delete-before-insert（按 `visit_date` 日期窗口） |
| 更新频率 | 每日增量更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结账单头表（消费金额、支付方式） |
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（服务时长、金额） |
| `dwd_table_fee_log` | `dwd` | 台费流水（真实台桌使用秒数） |
| `dim_member` | `dwd` | 会员维度（SCD2 当前版本，昵称、手机号、生日） |
| `dim_table` | `dwd` | 台桌维度（SCD2 当前版本，台桌名称、区域名称） |
| `cfg_area_category` | `dws` | 区域分类映射（通过 ConfigCache 加载） |

#### 聚合维度与输出字段

以每笔结账单（`order_settle_id`）为粒度，输出以下字段：

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `member_id`, `order_settle_id`, `visit_date`, `visit_time` | 门店、会员、结账单号、到店日期、到店时间 |
| 会员信息 | `member_nickname`, `member_mobile`, `member_birthday` | 昵称、脱敏手机号、生日 |
| 台桌信息 | `table_id`, `table_name`, `area_name`, `area_category` | 台桌 ID、台桌名称、区域名称、区域分类 |
| 消费金额 | `table_fee`, `goods_amount`, `assistant_amount`, `total_consume`, `total_discount`, `actual_pay` | 台费、商品金额、助教费用、总消费、总优惠、实付金额 |
| 支付方式 | `cash_pay`, `balance_pay`, `recharge_card_pay`, `gift_card_pay`, `groupbuy_pay` | 现金/在线支付、储值卡总支付、现金充值卡支付、赠送卡支付、团购券支付 |
| 时长 | `table_duration_min`, `assistant_duration_min` | 台桌使用时长（分钟）、助教服务时长（分钟） |
| 助教服务 | `assistant_services` | JSON 格式的助教服务明细 |

#### 核心业务逻辑

**1. 散客排除**

SQL 层面通过 `member_id IS NOT NULL AND member_id != 0` 过滤，同时通过 `settle_type IN (1, 3)` 仅保留台桌结账和商城订单（排除退货/退款），transform 阶段通过 `is_guest()` 二次过滤。

**2. 消费金额拆分**

从 `dwd_settlement_head` 直接读取各金额字段：
- `table_fee`：`table_charge_money`（台费）
- `goods_amount`：`goods_money`（商品金额）
- `assistant_amount`：`assistant_pd_money + assistant_cx_money`（陪打 + 超休助教费用合计）
- `total_consume`：`items_sum`（= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`，不使用 `consume_money`）
- `actual_pay`：`pay_amount`（实付金额）

**3. 总优惠计算**

```
total_discount = adjust_amount + member_discount_amount + rounding_amount
```

- `adjust_amount`：手动调整金额
- `member_discount_amount`：会员折扣金额
- `rounding_amount`：抹零金额

**4. 支付方式拆分**

| 字段 | 来源字段 | 说明 |
|------|----------|------|
| `cash_pay` | `pay_amount` | 现金/在线支付 |
| `balance_pay` | `balance_amount` | 储值卡总支付（= recharge_card_pay + gift_card_pay） |
| `recharge_card_pay` | `recharge_card_amount` | 现金充值卡支付（balance_pay 的子项） |
| `gift_card_pay` | `gift_card_amount` | 赠送卡支付 |
| `groupbuy_pay` | `coupon_amount` | 团购券支付 |

**5. 台桌使用时长**

从 `dwd_table_fee_log` 按 `order_settle_id` 聚合 `SUM(real_table_use_seconds)` 获取真实台费秒数，转换为分钟（整除 60）。仅取未删除记录（`is_delete = 0`）。

**6. 助教服务时长**

从 `dwd_assistant_service_log` 按 `order_settle_id` 关联，汇总所有助教的 `income_seconds` 后转换为分钟（整除 60）。仅取未删除记录（`is_delete = 0`）。

**7. 助教服务明细（JSON）**

每笔结账单关联的助教服务以 JSON 数组格式存储在 `assistant_services` 字段中，每个元素包含：

```json
[
  {
    "assistant_id": 12345,
    "nickname": "张教练",
    "duration_min": 60,
    "amount": 108.00
  }
]
```

| JSON 字段 | 来源 | 说明 |
|-----------|------|------|
| `assistant_id` | `site_assistant_id` | 助教 ID |
| `nickname` | `nickname` | 助教昵称 |
| `duration_min` | `income_seconds // 60` | 服务时长（分钟） |
| `amount` | `ledger_amount` | 台账金额 |

无助教服务时 `assistant_services` 为 `NULL`。

**8. 区域分类**

通过 `ConfigCache` 加载 `cfg_area_category` 配置，调用 `get_area_category(area_name, table_name)` 将台桌映射为分类标签。匹配逻辑：台桌级精确匹配（`source_area_name` + `source_table_name`）→ 兜底 `OTHER`（2026-03-09 改版，已移除 LIKE 模糊匹配和区域级映射）。

**9. 手机号脱敏**

与 `DWS_MEMBER_CONSUMPTION` 相同，中间 4 位替换为 `****`。

---

## 财务统计域

财务统计域包含 4 个任务，围绕门店的日度财务汇总、充值统计、收入结构分析和优惠明细展开。数据流向为：

```
dwd_settlement_head ──────────┬──► DWS_FINANCE_DAILY（财务日报）
dwd_groupbuy_redemption ──────┤
dwd_recharge_order ───────────┤
dwd_member_balance_change ────┤
dws_finance_expense_summary ──┤
dws_platform_settlement ──────┘

dwd_recharge_order ───────────┬──► DWS_FINANCE_RECHARGE（充值统计）
dim_member_card_account ──────┘

dwd_settlement_head ──────────┬──► DWS_FINANCE_INCOME_STRUCTURE（收入结构）
dwd_table_fee_log ────────────┤
dwd_assistant_service_log ────┤
dim_table ────────────────────┤
cfg_area_category ────────────┘

dwd_settlement_head ──────────┬──► DWS_FINANCE_DISCOUNT_DETAIL（折扣明细）
dwd_groupbuy_redemption ──────┤
dwd_member_balance_change ────┘
```

---

### DWS_FINANCE_DAILY — 财务日报

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_FINANCE_DAILY` |
| Python 类 | `FinanceDailyTask`（`tasks/dws/finance_daily_task.py`） |
| 目标表 | `dws.dws_finance_daily_summary` |
| 主键 | `site_id`, `stat_date` |
| 粒度 | 日期 |
| 更新策略 | delete-before-insert（按日期窗口） |
| 更新频率 | 每小时更新当日数据 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结账单头表（发生额、支付、优惠） |
| `dwd_groupbuy_redemption` | `dwd` | 团购核销（团购实付金额） |
| `dwd_recharge_order` | `dwd` | 充值订单（首充/续充、现金/赠送） |
| `dwd_member_balance_change` | `dwd` | 余额变动（赠送卡消费） |
| `dws_finance_expense_summary` | `dws` | 支出汇总（Excel 导入，按月分摊到日） |
| `dws_platform_settlement` | `dws` | 平台回款/服务费（Excel 导入） |

#### 输出字段

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `stat_date` | 门店、统计日期 |
| 发生额 | `gross_amount`, `table_fee_amount`, `goods_amount`, `assistant_pd_amount`, `assistant_cx_amount` | 正价总额及按类型拆分（台费/商品/专业课/陪练课） |
| 优惠 | `discount_total`, `discount_groupbuy`, `discount_vip`, `discount_gift_card`, `discount_manual`, `discount_rounding`, `discount_other` | 优惠合计及按类型拆分（discount_manual=大客户优惠，discount_other=其他手动调整，两者互斥） |
| 确认收入 | `confirmed_income` | 发生额 - 优惠合计 |
| 现金流入 | `cash_inflow_total`, `cash_pay_amount`, `groupbuy_pay_amount`, `platform_settlement_amount`, `recharge_cash_inflow` | 现金流入合计及来源拆分 |
| 现金流出 | `cash_outflow_total`, `platform_fee_amount` | 现金流出合计（支出 + 平台费用） |
| 现金净变动 | `cash_balance_change` | 流入 - 流出 |
| 卡消费 | `card_consume_total`, `recharge_card_consume`, `gift_card_consume` | 现金充值卡消费（= `recharge_card_amount`）+ 赠送卡消费 |
| 充值统计 | `recharge_count`, `recharge_total`, `recharge_cash`, `recharge_gift`, `first_recharge_count`, `first_recharge_amount`, `renewal_count`, `renewal_amount` | 充值笔数/金额、首充/续充拆分 |
| 订单统计 | `order_count`, `member_order_count`, `guest_order_count`, `avg_order_amount` | 总订单数、会员/散客订单数、客单价 |

#### 核心业务逻辑

**1. 发生额（正价）**

```
gross_amount = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money
```

> 注意：`gross_amount` 为发生额（正价四项），不含 `electricity_money`。完整消费项目合计（`items_sum`）还需加上 `electricity_money`。

从 `dwd_settlement_head` 按 `biz_date(pay_time)` 聚合，通过 `settle_type IN (1, 3)` 仅保留台桌结账和商城订单（排除退货/退款），分别统计台费、商品、陪打（PD）、超休（CX）四类收入。

**2. 团购优惠计算**

```
团购实付金额 = pl_coupon_sale_amount > 0 ? pl_coupon_sale_amount : groupbuy_redemption.ledger_unit_price
团购优惠 = coupon_amount - 团购实付金额
```

- `coupon_amount`：团购抵消台费金额（结账单字段）
- 团购实付金额优先取 `pl_coupon_sale_amount`（平台券销售金额），否则取 `dwd_groupbuy_redemption.ledger_unit_price`
- 团购优惠为负时置 0

**3. 大客户优惠拆分**

手动调整金额（`adjust_amount`）中，通过配置项 `dws.discount.big_customer_member_ids` 和 `dws.discount.big_customer_order_ids` 标记的订单归入大客户优惠，其余归入其他优惠：

```
discount_other = adjust_amount - big_customer_amount    （负值置 0）
```

**4. 赠送卡消费**

从 `dwd_member_balance_change` 提取赠送卡消费（`from_type = 1` 且 `change_amount < 0`），按卡类型过滤：
- 台费卡（`card_type_id = 2791990152417157`）
- 酒水卡（`card_type_id = 2794699703437125`）
- 活动抵用券（`card_type_id = 2793266846533445`）

取 `ABS(change_amount)` 汇总为当日赠送卡消费总额。

**5. 优惠合计与确认收入**

```
discount_total = discount_groupbuy + discount_vip + discount_gift_card + discount_manual + discount_rounding + discount_other
confirmed_income = gross_amount - discount_total
```

> `discount_manual` = 大客户优惠，`discount_other` = 其他手动调整，两者互斥，之和 = adjust_amount。

**6. 现金流计算**

```
cash_inflow_total = cash_pay_amount + platform_inflow + recharge_cash_inflow
cash_outflow_total = expense_amount + platform_fee_amount
cash_balance_change = cash_inflow_total - cash_outflow_total
```

- `platform_inflow`：优先取 `platform_settlement_amount`（平台回款），为 0 时取 `groupbuy_pay_amount`
- `platform_fee_amount`：`commission_amount + service_fee`（平台佣金 + 服务费）
- `expense_amount`：月度支出按日均分摊（月总额 ÷ 当月天数）

**7. 支出分摊逻辑**

支出数据来自 `dws_finance_expense_summary`（Excel 导入），以月为粒度。任务按当月天数均分到每日：

```
daily_expense = expense_amount / days_in_month
```

**8. 卡消费统计**

```
recharge_card_consume = recharge_card_amount                  （现金充值卡支付部分）
gift_card_consume = 赠送卡消费总额                            （来自余额变动）
card_consume_total = recharge_card_consume + gift_card_consume
```

> 注意：`balance_amount = recharge_card_amount + gift_card_amount`（恒等式），因此 `recharge_card_consume` 只取 `recharge_card_amount`，不可再加 `balance_amount`，否则重复计算。

---

### DWS_FINANCE_RECHARGE — 充值统计

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_FINANCE_RECHARGE` |
| Python 类 | `FinanceRechargeTask`（`tasks/dws/finance_recharge_task.py`） |
| 目标表 | `dws.dws_finance_recharge_summary` |
| 主键 | `site_id`, `stat_date` |
| 粒度 | 日期 |
| 更新策略 | delete-before-insert（按日期窗口） |
| 更新频率 | 每日更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_recharge_order` | `dwd` | 充值订单（首充/续充、现金/赠送） |
| `dim_member_card_account` | `dwd` | 会员卡账户（SCD2 当前版本，卡余额快照） |

#### 输出字段

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `tenant_id`, `stat_date` | 门店、统计日期 |
| 充值汇总 | `recharge_count`, `recharge_total`, `recharge_cash`, `recharge_gift` | 充值笔数、总额（现金+赠送）、现金部分、赠送部分 |
| 首充 | `first_recharge_count`, `first_recharge_cash`, `first_recharge_gift`, `first_recharge_total` | 首充笔数、现金、赠送、总额 |
| 续充 | `renewal_count`, `renewal_cash`, `renewal_gift`, `renewal_total` | 续充笔数、现金、赠送、总额 |
| 会员统计 | `recharge_member_count`, `new_member_count` | 当日充值去重会员数、首充新会员数 |
| 卡余额快照 | `total_card_balance`, `cash_card_balance`, `gift_card_balance` | 全店卡余额总计、储值卡余额、赠送卡余额 |

#### 核心业务逻辑

**1. 首充/续充区分**

通过 `dwd_recharge_order.is_first` 字段区分：
- `is_first = 1`：首充（会员首次充值）
- `is_first = 0` 或 `NULL`：续充

每笔充值金额拆分为：
```
充值总额 = pay_amount（现金部分）+ point_amount（赠送部分）
```

**2. 会员去重统计**

- `recharge_member_count`：`COUNT(DISTINCT member_id)`，当日充值的去重会员数
- `new_member_count`：`COUNT(DISTINCT CASE WHEN is_first = 1 THEN member_id END)`，当日首充的去重新会员数

**3. 卡余额快照**

从 `dim_member_card_account` 取 SCD2 当前版本（`scd2_is_current = 1`）且未删除（`is_delete = 0`）的记录，按 `card_type_id` 分类汇总：

| 卡类型 | card_type_id | 归入字段 |
|--------|-------------|----------|
| 储值卡（现金卡） | `2793249295533893` | `cash_card_balance` |
| 台费卡 | `2791990152417157` | `gift_card_balance` |
| 酒水卡 | `2794699703437125` | `gift_card_balance` |
| 活动抵用券 | `2793266846533445` | `gift_card_balance` |
| 年卡 | `2791987095408517` | ⚠️ 未统计（遗漏） |
| 月卡 | `2793306611533637` | ⚠️ 未统计（遗漏） |

```
total_card_balance = cash_card_balance + gift_card_balance
```

> 注意：卡余额为窗口结束日的全量快照，而非按日变化值。窗口内所有日期共享同一份余额数据。

---

### DWS_FINANCE_INCOME_STRUCTURE — 收入结构分析

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_FINANCE_INCOME_STRUCTURE` |
| Python 类 | `FinanceIncomeStructureTask`（`tasks/dws/finance_income_task.py`） |
| 目标表 | `dws.dws_finance_income_structure` |
| 主键 | `site_id`, `stat_date`, `structure_type`, `category_code` |
| 粒度 | 日期 + 结构类型 + 分类代码 |
| 更新策略 | delete-before-insert（按日期窗口） |
| 更新频率 | 每日更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结账单头表（按收入类型汇总） |
| `dwd_table_fee_log` | `dwd` | 台费流水（按区域汇总台费收入） |
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（按区域汇总助教收入） |
| `dim_table` | `dwd` | 台桌维度（SCD2，获取区域名称） |
| `cfg_area_category` | `dws` | 区域分类映射（通过 ConfigCache 加载） |

#### 输出字段

| 字段 | 说明 |
|------|------|
| `site_id`, `tenant_id`, `stat_date` | 门店、统计日期 |
| `structure_type` | 结构类型：`INCOME_TYPE`（按收入类型）或 `AREA`（按区域） |
| `category_code` | 分类代码（见下方分类定义） |
| `category_name` | 分类名称（中文） |
| `income_amount` | 收入金额 |
| `income_ratio` | 收入占比（保留 4 位小数） |
| `order_count` | 关联订单数 |
| `duration_minutes` | 使用时长（分钟），仅 `AREA` 类型有值 |

#### 两种分析维度

**维度 1：按收入类型（`structure_type = 'INCOME_TYPE'`）**

从 `dwd_settlement_head` 按 `pay_time::DATE` 聚合，仅统计已结账订单（`settle_type IN (1, 3)`），每日展开为 4 条记录：

| category_code | category_name | 来源字段 | 说明 |
|---------------|---------------|----------|------|
| `TABLE_FEE` | 台费收入 | `table_charge_money` | 台桌使用费 |
| `GOODS` | 商品收入 | `goods_money` | 商品销售 |
| `ASSISTANT_PD` | 助教陪打 | `assistant_pd_money` | 陪打收入 |
| `ASSISTANT_CX` | 助教超休 | `assistant_cx_money` | 超休收入 |

占比计算：`income_ratio = 该类型金额 / 当日四类收入总和`

**维度 2：按区域（`structure_type = 'AREA'`）**

通过 CTE 合并台费流水和助教服务流水，关联 `dim_table` 获取 `site_table_area_name` 和 `table_name`，再通过 `get_area_category(area_name, table_name)` 映射到分类代码。

区域映射逻辑（与 `DWS_MEMBER_VISIT` 相同）：台桌级精确匹配 → 兜底 `OTHER`（2026-03-09 改版，无 LIKE）。

相同 `category_code` 的不同区域名称会被合并聚合。每条记录额外输出 `duration_minutes`（台费秒数 + 助教服务秒数，转换为分钟）。

占比计算：`income_ratio = 该区域金额 / 当日所有区域收入总和`

#### 核心业务逻辑

**1. 自定义 load 方法**

此任务未使用 `BaseDwsTask.delete_existing_data()` + `bulk_insert()`，而是自行实现 DELETE + INSERT SQL，直接操作 `dws.dws_finance_income_structure` 表，逐行插入并自动设置 `created_at` 和 `updated_at`。

**2. 区域收入来源**

区域维度的收入同时包含台费收入（`dwd_table_fee_log.ledger_amount`）和助教收入（`dwd_assistant_service_log.ledger_amount`），通过 `UNION ALL` 合并后按 `(stat_date, area_name)` 聚合。

---

### DWS_FINANCE_DISCOUNT_DETAIL — 折扣明细统计

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_FINANCE_DISCOUNT_DETAIL` |
| Python 类 | `FinanceDiscountDetailTask`（`tasks/dws/finance_discount_task.py`） |
| 目标表 | `dws.dws_finance_discount_detail` |
| 主键 | `site_id`, `stat_date`, `discount_type_code` |
| 粒度 | 日期 + 折扣类型 |
| 更新策略 | delete-before-insert（按日期窗口） |
| 更新频率 | 每日更新 |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结账单头表（团购、手动调整、会员折扣、抹零） |
| `dwd_groupbuy_redemption` | `dwd` | 团购核销（团购实付金额） |
| `dwd_member_balance_change` | `dwd` | 余额变动（赠送卡消费，按卡类型拆分） |

#### 输出字段

| 字段 | 说明 |
|------|------|
| `site_id`, `tenant_id`, `stat_date` | 门店、统计日期 |
| `discount_type_code` | 折扣类型代码（见下方分类定义） |
| `discount_type_name` | 折扣类型名称（中文） |
| `discount_amount` | 折扣金额 |
| `discount_ratio` | 折扣占比（保留 4 位小数） |
| `usage_count` | 使用次数 |
| `affected_orders` | 影响订单数（当前等于 `usage_count`） |

#### 折扣类型分类

每日展开为最多 8 条记录，覆盖以下折扣类型：

| discount_type_code | discount_type_name | 数据来源 | 计算逻辑 |
|--------------------|--------------------|----------|----------|
| `GROUPBUY` | 团购优惠 | `dwd_settlement_head` + `dwd_groupbuy_redemption` | `coupon_amount - 团购实付金额`（负值置 0） |
| `VIP` | 会员折扣 | `dwd_settlement_head.member_discount_amount` | 直接取绝对值 |
| `ROUNDING` | 抹零 | `dwd_settlement_head.rounding_amount` | 直接取绝对值 |
| `GIFT_CARD_TABLE` | 台费卡抵扣 | `dwd_member_balance_change`（`card_type_id = 2791990152417157`） | 消费金额绝对值 |
| `GIFT_CARD_DRINK` | 酒水卡抵扣 | `dwd_member_balance_change`（`card_type_id = 2794699703437125`） | 消费金额绝对值 |
| `GIFT_CARD_COUPON` | 活动抵用券抵扣 | `dwd_member_balance_change`（`card_type_id = 2793266846533445`） | 消费金额绝对值 |
| `BIG_CUSTOMER` | 大客户优惠 | `dwd_settlement_head.adjust_amount`（配置标记） | 按配置的会员/订单 ID 匹配 |
| `OTHER` | 其他优惠 | `dwd_settlement_head.adjust_amount`（剩余部分） | `adjust_amount - big_customer_amount`（负值置 0） |

#### 核心业务逻辑

**1. 团购优惠计算**

与 `DWS_FINANCE_DAILY` 相同的逻辑：

```
团购实付 = pl_coupon_sale_amount > 0 ? pl_coupon_sale_amount : groupbuy_redemption.ledger_unit_price
团购优惠 = coupon_amount - 团购实付
```

仅统计 `coupon_amount > 0` 的已结账订单（`settle_type IN (1, 3)`）。

**2. 赠送卡消费拆分**

与 `DWS_FINANCE_DAILY` 不同，此任务将赠送卡消费按卡类型拆分为 3 条独立记录（台费卡/酒水卡/活动抵用券），而非合并为一个总额。数据来源相同：`dwd_member_balance_change` 中 `from_type = 1` 且 `change_amount < 0` 的记录。

**3. 大客户优惠拆分**

手动调整金额（`adjust_amount`）通过配置项拆分：
- `dws.discount.big_customer_member_ids`：大客户会员 ID 列表
- `dws.discount.big_customer_order_ids`：大客户订单 ID 列表

匹配到的订单调整金额归入 `BIG_CUSTOMER`，其余归入 `OTHER`。若两个配置项均为空，则所有手动调整归入 `OTHER`。

**4. 占比计算**

```
discount_ratio = 该类型折扣金额 / 当日所有类型折扣金额总和
```

**5. 自定义 load 方法**

与 `DWS_FINANCE_INCOME_STRUCTURE` 类似，此任务自行实现 DELETE + INSERT SQL，逐行插入并自动设置 `created_at` 和 `updated_at`。

---

## 运维任务

运维任务包含 2 个任务，负责订单汇总中间表构建、物化视图刷新和历史数据清理。这些任务不直接产出业务报表，而是为其他 DWS 任务和下游查询提供基础设施支撑。

```
dwd_settlement_head ──────────┐
dwd_table_fee_log ────────────┤
dwd_assistant_service_log ────┼──► DWS_BUILD_ORDER_SUMMARY（订单汇总中间表）
dwd_store_goods_sale ─────────┤
dwd_groupbuy_redemption ──────┤
dwd_refund / dwd_refund_ex ───┘

dws_*（所有 DWS 汇总表）──────► DWS_MAINTENANCE（统一维护：MV 刷新 + 历史清理）
```

---

### DWS_BUILD_ORDER_SUMMARY — 订单汇总中间表构建

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_BUILD_ORDER_SUMMARY` |
| Python 类 | `DwsBuildOrderSummaryTask`（`tasks/utility/dws_build_order_summary_task.py`） |
| 继承 | `BaseTask`（非 `BaseDwsTask`） |
| 目标表 | `dws.dws_order_summary` |
| 主键 | `site_id`, `order_settle_id` |
| 粒度 | 订单（每笔结账单一条记录） |
| 更新策略 | delete-before-insert + `ON CONFLICT DO UPDATE`（upsert） |

#### 用途

构建订单级别的汇总中间表 `dws_order_summary`，将分散在多张 DWD 事实表中的订单信息（台费、助教费、商品、团购、退款等）合并为一张宽表。该中间表可供下游报表查询和指数计算直接使用，避免每次都执行多表 JOIN。

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_settlement_head` | `dwd` | 结账单头表（基础订单信息、支付金额、优惠金额） |
| `dwd_table_fee_log` | `dwd` | 台费流水（真实台费金额） |
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（助教服务金额） |
| `dwd_store_goods_sale` | `dwd` | 商品销售明细（商品数量、金额） |
| `dwd_groupbuy_redemption` | `dwd` | 团购核销（团购金额） |
| `dwd_refund` / `dwd_refund_ex` | `dwd` | 退款记录（退款金额） |

#### 输出字段

| 字段分组 | 字段 | 说明 |
|----------|------|------|
| 标识 | `site_id`, `order_settle_id`, `order_trade_no`, `order_date`, `tenant_id` | 门店、结账单号、交易号、订单日期、租户 |
| 会员 | `member_id`, `member_flag`, `recharge_order_flag` | 会员 ID、是否绑定会员、是否充值订单 |
| 商品 | `item_count`, `total_item_quantity` | 商品种类数、商品总数量 |
| 费用明细 | `table_fee_amount`, `assistant_service_amount`, `goods_amount`, `group_amount` | 台费、助教费、商品金额、团购金额 |
| 优惠 | `total_coupon_deduction`, `member_discount_amount`, `manual_discount_amount` | 团购抵扣、会员折扣、手动调整 |
| 金额汇总 | `order_original_amount`, `order_final_amount` | 订单原价（= `total_paid_amount + total_coupon_deduction + member_discount_amount + manual_discount_amount`）、实付金额 |
| 支付方式 | `stored_card_deduct`, `external_paid_amount`, `total_paid_amount` | 储值卡抵扣（= `balance_amount`）、外部支付、总支付 |
| 台账流水 | `book_table_flow`, `book_assistant_flow`, `book_goods_flow`, `book_group_flow`, `book_order_flow` | 台费/助教/商品/团购/订单台账流水 |
| 有效消费 | `order_effective_consume_cash`, `order_effective_recharge_cash`, `order_effective_flow` | 有效消费现金、有效充值现金（当前硬编码为 0，占位）、有效流水 |
| 退款 | `refund_amount`, `net_income` | 退款金额、净收入 |

#### 核心业务逻辑

**1. SQL CTE 多表合并**

任务通过一条大型 SQL（`SQL_BUILD_SUMMARY`）完成所有计算，使用 6 个 CTE 分别从不同事实表聚合数据，最终通过 `LEFT JOIN` 合并到订单粒度：

- `base`：从 `dwd_settlement_head` 提取订单基础信息
- `table_fee`：从 `dwd_table_fee_log` 按 `order_settle_id` 聚合台费
- `assistant_fee`：从 `dwd_assistant_service_log` 按 `order_settle_id` 聚合助教费
- `goods_fee`：从 `dwd_store_goods_sale` 按 `order_settle_id` 聚合商品数量和金额
- `group_fee`：从 `dwd_groupbuy_redemption` 按 `order_settle_id` 聚合团购金额
- `refunds`：从 `dwd_refund` + `dwd_refund_ex` 按 `relate_id`（关联订单）聚合退款金额

**2. 金额优先级**

台费、助教费、商品金额优先取明细表（`dwd_table_fee_log` / `dwd_assistant_service_log` / `dwd_store_goods_sale`）的聚合值，若明细表无数据则回退到结账单头表（`dwd_settlement_head`）的汇总字段：

```sql
COALESCE(tf.table_fee_amount, b.settle_table_fee_amount)
COALESCE(af.assistant_service_amount, b.settle_assistant_service_amount)
COALESCE(gf.goods_amount, b.settle_goods_amount)
```

**3. 订单原价计算**

```
order_original_amount = total_paid_amount + total_coupon_deduction + member_discount_amount + manual_discount_amount
```

即实付金额加上所有优惠金额，还原订单原始价格。

**4. 外部支付金额**

```
external_paid_amount = MAX(total_paid_amount - stored_card_deduct, 0)
```

总支付减去储值卡抵扣部分，即通过现金/在线支付的金额。

**5. 净收入**

```
net_income = total_paid_amount - refund_amount
```

**6. 充值订单标记**

```
recharge_order_flag = (consume_money = 0 AND pay_amount > 0)
```

消费金额为 0 但有支付金额的订单标记为充值订单。此处 `consume_money` 仅用于零值判断（三种口径在 =0 时等价），不涉及金额聚合。

#### 配置参数

| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `dws.order_summary.full_refresh` | `bool` | `False` | 全量刷新模式（忽略日期窗口，处理全部数据） |
| `dws.order_summary.site_id` | `int/None` | `app.store_id` | 指定门店 ID，设为 `null` 时处理所有门店 |
| `dws.order_summary.start_date` | `date/None` | 窗口起始日期 | 手动指定起始日期（覆盖窗口计算） |
| `dws.order_summary.end_date` | `date/None` | 窗口结束日期 | 手动指定结束日期（覆盖窗口计算） |
| `dws.order_summary.delete_before_insert` | `bool` | `True` | 是否在插入前先删除旧数据 |

#### 执行模式

- **增量模式**（默认）：按时间窗口处理，先 DELETE 窗口内旧数据，再 INSERT ... ON CONFLICT DO UPDATE
- **全量刷新**（`full_refresh=True`）：
  - 若 `site_id` 为 `null`：执行 `TRUNCATE TABLE` 清空全表后重建
  - 若 `site_id` 有值：DELETE 该门店全部数据后重建
- **分段执行**：支持 `build_window_segments` 窗口分段，大时间范围自动拆分为多段依次执行

#### 前置依赖

目标表 `dws.dws_order_summary` 必须已存在，否则抛出 `RuntimeError`。需先运行 `INIT_DWS_SCHEMA` 任务创建表结构。

---

### DWS_MAINTENANCE — 统一维护任务

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_MAINTENANCE` |
| Python 类 | `DwsMaintenanceTask`（`tasks/dws/maintenance_task.py`） |
| 继承 | `BaseDwsTask` |
| 更新策略 | REFRESH MATERIALIZED VIEW + DELETE |

#### 用途

合并原 `DWS_RETENTION_CLEANUP`、`DWS_MV_REFRESH_FINANCE_DAILY`、`DWS_MV_REFRESH_ASSISTANT_DAILY` 三个任务为单一维护任务。在单次执行中按顺序完成：

1. 物化视图刷新（`dws.mv.enabled`）
2. 历史数据清理（`dws.retention.enabled`）

任一子功能可通过配置独立禁用。两个子功能均默认不启用，需通过配置显式开启。

#### 配置参数

| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `dws.mv.enabled` | `bool` | `False` | 是否启用物化视图刷新 |
| `dws.mv.tables` | `str` | `None` | 需要刷新的基表列表（逗号分隔） |
| `dws.mv.layers` | `str` | `None` | 显式指定刷新的层级列表 |
| `dws.mv.table_layers` | `str/dict` | `None` | 表级别的层级覆盖（JSON 格式） |
| `dws.mv.refresh_concurrently` | `bool` | `False` | 是否使用 CONCURRENTLY 刷新 |
| `dws.retention.enabled` | `bool` | `False` | 是否启用历史数据清理 |
| `dws.retention.layer` | `str` | `"ALL"` | 默认清理层级 |
| `dws.retention.tables` | `str` | 全部 14 张表 | 需要清理的表名列表（逗号分隔） |
| `dws.retention.table_layers` | `str/dict` | `{}` | 表级别的清理层级覆盖 |

#### 执行结果

```json
{
  "counts": {
    "refreshed": 6,
    "cleaned": 1500
  }
}
```

- `refreshed`：刷新的物化视图数量
- `cleaned`：清理的历史数据行数


---

## 库存汇总域

库存汇总域包含 3 个任务，围绕商品库存的日度、周度、月度汇总展开。数据流向为：

```
dwd_goods_stock_summary ──┬──► DWS_GOODS_STOCK_DAILY（日度汇总）
                          ├──► DWS_GOODS_STOCK_WEEKLY（周度汇总）
                          └──► DWS_GOODS_STOCK_MONTHLY（月度汇总）
```

三个任务均依赖 `DWD_LOAD_FROM_ODS` 完成后的 `dwd.dwd_goods_stock_summary` 数据。

---

### DWS_GOODS_STOCK_DAILY — 库存日度汇总

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_GOODS_STOCK_DAILY` |
| Python 类 | `GoodsStockDailyTask`（`tasks/dws/goods_stock_daily_task.py`） |
| 目标表 | `dws.dws_goods_stock_daily_summary` |
| 主键 | `site_id`, `stat_date`, `site_goods_id` |
| 粒度 | 日期 + 门店 + 商品 |
| 更新策略 | upsert（ON CONFLICT DO UPDATE） |
| 更新频率 | 每日更新 |
| 依赖 | `DWD_LOAD_FROM_ODS` |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_goods_stock_summary` | `dwd` | 库存汇总明细（按 fetched_at 日期聚合） |

#### 核心业务逻辑

- 按 `fetched_at` 的日期部分分组，同一天同一商品可能有多条 DWD 记录
- 数值指标（入库/出库/销售等）取 SUM 聚合
- `range_start_stock` 取当日第一条记录的值（期初快照）
- `range_end_stock` / `current_stock` 取当日最后一条记录的值（期末快照）
- `stat_period = 'daily'`

---

### DWS_GOODS_STOCK_WEEKLY — 库存周度汇总

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_GOODS_STOCK_WEEKLY` |
| Python 类 | `GoodsStockWeeklyTask`（`tasks/dws/goods_stock_weekly_task.py`） |
| 目标表 | `dws.dws_goods_stock_weekly_summary` |
| 主键 | `site_id`, `stat_date`, `site_goods_id` |
| 粒度 | ISO 周 + 门店 + 商品 |
| 更新策略 | upsert（ON CONFLICT DO UPDATE） |
| 更新频率 | 每周更新 |
| 依赖 | `DWD_LOAD_FROM_ODS` |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_goods_stock_summary` | `dwd` | 库存汇总明细（按 ISO 周聚合） |

#### 核心业务逻辑

- 按 ISO 周分组（isocalendar），`stat_date` = 该周的周一日期
- 同一周同一商品可能有多条 DWD 记录
- 数值指标取 SUM 聚合
- `range_start_stock` 取该周第一条记录的值（期初快照）
- `range_end_stock` / `current_stock` 取该周最后一条记录的值（期末快照）
- `stat_period = 'weekly'`

---

### DWS_GOODS_STOCK_MONTHLY — 库存月度汇总

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_GOODS_STOCK_MONTHLY` |
| Python 类 | `GoodsStockMonthlyTask`（`tasks/dws/goods_stock_monthly_task.py`） |
| 目标表 | `dws.dws_goods_stock_monthly_summary` |
| 主键 | `site_id`, `stat_date`, `site_goods_id` |
| 粒度 | 自然月 + 门店 + 商品 |
| 更新策略 | upsert（ON CONFLICT DO UPDATE） |
| 更新频率 | 每日更新当月数据 |
| 依赖 | `DWD_LOAD_FROM_ODS` |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_goods_stock_summary` | `dwd` | 库存汇总明细（按自然月聚合） |

#### 核心业务逻辑

- 按自然月分组，`stat_date` = 该月的第一天（如 2026-01-01 代表 2026 年 1 月）
- 同一月同一商品可能有多条 DWD 记录
- 数值指标取 SUM 聚合
- `range_start_stock` 取该月第一条记录的值（期初快照）
- `range_end_stock` / `current_stock` 取该月最后一条记录的值（期末快照）
- `stat_period = 'monthly'`


---

## 项目标签域

项目标签域包含 2 个任务，按时间窗口计算助教和客户在四大项目类型（BILLIARD/SNOOKER/MAHJONG/KTV）的时长占比，占比≥25% 则分配标签。数据流向为：

```
dwd_assistant_service_log (income_seconds) ──┐
                                             ├──► dim_table (site_table_id JOIN)
dwd_table_fee_log (ledger_count) ────────────┘         │
                                                       ▼
                                              cfg_area_category (get_area_category)
                                                       │
                                    ┌──────────────────┴──────────────────┐
                                    ▼                                      ▼
                    DWS_ASSISTANT_PROJECT_TAG              DWS_MEMBER_PROJECT_TAG
                    （助教项目标签，6 个时间窗口）           （客户项目标签，2 个时间窗口）
```

### 公共逻辑

1. 数据链路走 `dim_table`（通过 `site_table_id` JOIN，`scd2_is_current=1`），获取 `area_name` 和 `table_name`
2. 通过 `get_area_category(area_name, table_name)` 映射到 `category_code`
3. 只保留四大项目（BILLIARD/SNOOKER/MAHJONG/KTV），排除 SPECIAL/OTHER
4. 标签阈值：`TAG_THRESHOLD = 0.25`（25%）
5. 更新策略：全量删除重建（按 `site_id` 删除后重新插入所有时间窗口）

---

### DWS_ASSISTANT_PROJECT_TAG — 助教项目标签

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ASSISTANT_PROJECT_TAG` |
| Python 类 | `AssistantProjectTagTask`（`tasks/dws/assistant_project_tag_task.py`） |
| 目标表 | `dws.dws_assistant_project_tag` |
| 主键 | `site_id`, `assistant_id`, `time_window`, `category_code` |
| 粒度 | 助教 + 时间窗口 + 项目类型 |
| 更新策略 | 全量删除重建（按 site_id） |
| 更新频率 | 每日更新 |
| 依赖 | `DWD_LOAD_FROM_ODS` |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_assistant_service_log` | `dwd` | 助教服务流水（`income_seconds` 工作时长） |
| `dim_table` | `dwd` | 台桌维度（SCD2 当前版本，`area_name` + `table_name`） |
| `cfg_area_category` | `dws` | 区域分类映射（通过 ConfigCache 加载） |

#### 时间窗口

| 枚举值 | 说明 |
|--------|------|
| `THIS_MONTH` | 本月（月初 ~ 今天） |
| `THIS_QUARTER` | 本季度（季度首月1日 ~ 今天） |
| `LAST_MONTH` | 上月（上月初 ~ 上月末） |
| `LAST_3_MONTHS_EXCL_CURRENT` | 前3个月不含本月 |
| `LAST_QUARTER` | 上季度 |
| `LAST_6_MONTHS` | 最近半年（不含本月） |

#### 核心业务逻辑

1. 从 `dwd_assistant_service_log` 按 `(site_assistant_id, site_table_id)` 聚合 `income_seconds`
2. 通过 `dim_table` JOIN 获取台桌的 `area_name` 和 `table_name`
3. 调用 `get_area_category(area_name, table_name)` 映射到 `category_code`
4. 按 `(assistant_id, category_code)` 汇总各项目时长
5. 计算占比：`percentage = duration_seconds / total_seconds`（四位小数）
6. 占比 ≥ 0.25 标记 `is_tagged = TRUE`
7. 过滤条件：`is_delete = 0`，营业日切点通过 `biz_date_sql_expr` 处理

---

### DWS_MEMBER_PROJECT_TAG — 客户项目标签

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_MEMBER_PROJECT_TAG` |
| Python 类 | `MemberProjectTagTask`（`tasks/dws/member_project_tag_task.py`） |
| 目标表 | `dws.dws_member_project_tag` |
| 主键 | `site_id`, `member_id`, `time_window`, `category_code` |
| 粒度 | 会员 + 时间窗口 + 项目类型 |
| 更新策略 | 全量删除重建（按 site_id） |
| 更新频率 | 每日更新 |
| 依赖 | `DWD_LOAD_FROM_ODS` |

#### 数据来源

| 来源表 | Schema | 用途 |
|--------|--------|------|
| `dwd_table_fee_log` | `dwd` | 台费流水（`ledger_count` 计费时长） |
| `dim_table` | `dwd` | 台桌维度（SCD2 当前版本，`area_name` + `table_name`） |
| `cfg_area_category` | `dws` | 区域分类映射（通过 ConfigCache 加载） |

#### 时间窗口

| 枚举值 | 说明 |
|--------|------|
| `LAST_30_DAYS` | 近30天（含今天，base_date-29天 ~ base_date） |
| `LAST_60_DAYS` | 近60天（含今天，base_date-59天 ~ base_date） |

#### 核心业务逻辑

1. 从 `dwd_table_fee_log` 按 `(member_id, site_table_id)` 聚合 `ledger_count`
2. 散客排除：`member_id IS NOT NULL AND member_id != 0`
3. 通过 `dim_table` JOIN 获取台桌的 `area_name` 和 `table_name`
4. 调用 `get_area_category(area_name, table_name)` 映射到 `category_code`
5. 按 `(member_id, category_code)` 汇总各项目时长
6. 计算占比：`percentage = duration_seconds / total_seconds`（四位小数）
7. 占比 ≥ 0.25 标记 `is_tagged = TRUE`
8. 过滤条件：`COALESCE(is_delete, 0) = 0`，营业日切点通过 `biz_date_sql_expr` 处理

<!--
AI_CHANGELOG:
- 日期: 2026-03-19
- Prompt: card_type_id 年卡/月卡映射同步
- 直接原因: 用户确认 2791987095408517=年卡、2793306611533637=月卡，同步到所有涉及 card_type_id 的文档
- 变更摘要: 两处卡类型映射表（DWS_MEMBER_CONSUMPTION 和 DWS_FINANCE_RECHARGE）补充年卡/月卡行，标注"⚠️ 未统计（遗漏）"
- 风险与验证: 纯文档变更，无运行时影响
-->
