# 设计文档：ETL DWS 层扩展 — 小程序数据支撑

## 概述

本设计覆盖三个独立但相关的 DWS 层扩展模块：

1. **助教订单流水四项统计**：新建 `AssistantOrderContributionTask`，计算每名助教每日的订单总流水、订单净流水、时效贡献流水、时效净贡献。算法核心在于"时效贡献流水"的台费分摊和酒水食品均分逻辑。
2. **会员消费汇总扩展**：修改现有 `MemberConsumptionTask`，新增 30/60/90 天充值窗口统计和次均消费字段。
3. **定档折算惩罚**：修改现有 `AssistantDailyTask`，新增时间重叠检测和惩罚计算逻辑。

三个模块共享同一套 RLS 视图 + FDW 映射基础设施。

### 设计决策

1. **助教订单流水独立建表**：四项统计粒度为 `(site_id, assistant_id, stat_date)`，与现有 `dws_assistant_daily_detail` 粒度相同但语义不同（daily_detail 聚焦服务时长/金额，contribution 聚焦订单级流水分摊），独立建表避免字段膨胀。`stat_date` 为营业日（以 `BUSINESS_DAY_START_HOUR` 08:00 为日切点）。
2. **时效贡献流水计算为纯函数**：核心分摊算法（`compute_time_weighted_revenue`）设计为静态方法，输入为结构化的订单数据，输出为每名助教的贡献值。不依赖数据库，便于属性测试。
3. **惩罚检测在 transform 阶段完成**：定档折算惩罚的时间重叠检测和计算在 `AssistantDailyTask.transform` 中完成，不新建独立任务，因为惩罚字段与日度明细同粒度。
4. **充值统计复用现有 extract 模式**：在 `MemberConsumptionTask` 中新增一个 `_extract_recharge_stats` 方法，与现有的 `_extract_consumption_stats` 并行提取，在 transform 阶段合并。

## 架构

```mermaid
graph TD
    subgraph 数据来源（DWD）
        SH[dwd_settlement_head<br/>结算主表]
        TF[dwd_table_fee_log<br/>台费明细]
        ASL[dwd_assistant_service_log<br/>助教服务记录]
        RO[dwd_recharge_order<br/>充值订单]
    end

    subgraph 新建任务
        AOC[AssistantOrderContributionTask<br/>助教订单流水统计]
    end

    subgraph 修改任务
        MCT[MemberConsumptionTask<br/>+充值窗口 +次均消费]
        ADT[AssistantDailyTask<br/>+惩罚检测 +惩罚计算]
    end

    subgraph 输出（DWS）
        T1[dws_assistant_order_contribution<br/>新建]
        T2[dws_member_consumption_summary<br/>扩展字段]
        T3[dws_assistant_daily_detail<br/>扩展字段]
    end

    subgraph 基础设施
        RLS[app schema RLS 视图]
        FDW[fdw_etl 外部表映射]
    end

    SH --> AOC
    TF --> AOC
    ASL --> AOC
    AOC --> T1

    RO --> MCT
    SH --> MCT
    MCT --> T2

    ASL --> ADT
    TF --> ADT
    ADT --> T3

    T1 --> RLS
    T2 --> RLS
    T3 --> RLS
    RLS --> FDW
```

### 任务依赖关系

```
DWD_LOAD_FROM_ODS
  ├── DWS_ASSISTANT_DAILY          （扩展：+惩罚检测计算）
  ├── DWS_MEMBER_CONSUMPTION       （扩展：+充值窗口+次均消费）
  └── DWS_ASSISTANT_ORDER_CONTRIBUTION （新建：四项统计）
```

`DWS_ASSISTANT_ORDER_CONTRIBUTION` 依赖 `DWD_LOAD_FROM_ODS`（需要最新的结算、台费、服务记录数据）。

## 组件与接口

### AssistantOrderContributionTask（新建）

继承 `BaseDwsTask`，实现四项统计计算：

```python
class AssistantOrderContributionTask(BaseDwsTask):
    DATE_COL = "stat_date"

    def get_task_code(self) -> str:
        return "DWS_ASSISTANT_ORDER_CONTRIBUTION"

    def get_target_table(self) -> str:
        return "dws_assistant_order_contribution"

    def get_primary_keys(self) -> List[str]:
        return ["site_id", "assistant_id", "stat_date"]

    # --- ETL 主流程 ---
    def extract(self, context: TaskContext) -> Dict[str, Any]: ...
    def transform(self, extracted, context) -> List[Dict[str, Any]]: ...
    # load() 使用 BaseDwsTask 默认实现

    # --- 数据提取 ---
    def _extract_order_data(self, site_id, start_date, end_date) -> List[Dict]: ...

    # --- 核心计算（纯函数，可独立测试） ---
    @staticmethod
    def compute_order_gross_revenue(order: OrderData) -> Decimal:
        """订单总流水 = 台费 + 酒水食品 + 所有助教服务费"""
        ...

    @staticmethod
    def compute_order_net_revenue(order: OrderData) -> Decimal:
        """订单净流水 = 订单总流水 - 所有助教服务分成"""
        ...

    @staticmethod
    def compute_time_weighted_revenue(
        order: OrderData, assistant_id: int
    ) -> Decimal:
        """时效贡献流水 = 台费按时长分摊 + 个人服务费 + 酒水食品按时长比例"""
        ...

    @staticmethod
    def compute_time_weighted_net_revenue(
        time_weighted_revenue: Decimal, assistant_commission: Decimal
    ) -> Decimal:
        """时效净贡献 = 时效贡献流水 - 个人服务分成"""
        ...
```

### 核心数据结构

```python
@dataclass
class TableUsage:
    """台桌使用信息"""
    table_id: int
    table_area: str           # 区域名称（A/B/C/S/TV/M1-M7 等）
    usage_seconds: int        # 台桌使用时长（秒）
    table_fee: Decimal        # 台费/房费

@dataclass
class AssistantService:
    """助教服务记录"""
    assistant_id: int
    table_id: int
    service_seconds: int      # 服务时长（秒）
    ledger_amount: Decimal    # 服务流水（助教收费）
    commission: Decimal       # 助教分成
    skill_id: int
    course_type: str          # BASE / BONUS / ROOM

@dataclass
class OrderData:
    """订单聚合数据（一个结算单的完整信息）"""
    order_settle_id: int
    site_id: int
    total_table_fee: Decimal       # 台费总额
    total_goods_amount: Decimal    # 酒水食品总额
    tables: List[TableUsage]       # 台桌列表
    assistants: List[AssistantService]  # 助教服务列表
```

### 四项统计算法详解

#### 1. 订单总流水（order_gross_revenue）

```
order_gross_revenue = total_table_fee + total_goods_amount + SUM(所有助教的 ledger_amount)
```

每个参与助教获得相同的 order_gross_revenue 值。

#### 2. 订单净流水（order_net_revenue）

```
order_net_revenue = order_gross_revenue - SUM(所有助教的 commission)
```

每个参与助教获得相同的 order_net_revenue 值。

#### 3. 时效贡献流水（time_weighted_revenue）— 核心算法

这是最复杂的计算，按以下步骤进行：

**步骤 1：确定每张台桌的有效计费时长**
```
对于每张台桌 t：
  助教总服务时长 = SUM(该台桌所有助教的 service_seconds)
  有效计费时长 = MAX(助教总服务时长, 台桌使用时长)
  每小时单价 = table_fee / (有效计费时长 / 3600)
```

**步骤 2：按助教在台桌的服务时长分摊台费**
```
对于每个助教 a 在台桌 t：
  台费分摊 = 每小时单价 × (助教在该台桌的服务时长 / 3600)

  特殊情况：当助教总服务时长 < 台桌使用时长时
    按比例缩放：台费分摊 = (table_fee × 台桌使用时长对应比例) / 该台桌助教人数中按时长占比分配
    即：台费分摊 = (table_fee / 台桌使用时长 × MIN(助教总服务时长, 台桌使用时长))
                   × (助教个人时长 / 助教总服务时长)
```

更精确的公式（统一处理两种情况）：
```
对于台桌 t：
  billable_seconds = MAX(SUM(助教服务时长), 台桌使用时长)
  对于助教 a：
    台费分摊_a = table_fee_t × (service_seconds_a / billable_seconds)
```

> 注意：当 `SUM(助教服务时长) > 台桌使用时长` 时，`billable_seconds = SUM(助教服务时长)`，
> 此时各助教按自己的时长占比分摊台费，总和 = table_fee。
> 当 `SUM(助教服务时长) < 台桌使用时长` 时，`billable_seconds = 台桌使用时长`，
> 此时各助教分摊的台费总和 < table_fee（未被助教覆盖的时段不分配给任何人）。

**步骤 3：助教个人服务费直接计入**
```
个人服务费 = 助教的 ledger_amount
```

**步骤 4：酒水食品按助教总时长比例均分**
```
助教总时长 = SUM(所有助教在所有台桌的 service_seconds)
对于助教 a：
  酒水食品分摊 = total_goods_amount × (助教 a 的总服务时长 / 助教总时长)
```

**合成：**
```
time_weighted_revenue_a = SUM(各台桌台费分摊_a) + 个人服务费_a + 酒水食品分摊_a
```

#### 4. 时效净贡献（time_weighted_net_revenue）

```
time_weighted_net_revenue_a = time_weighted_revenue_a - commission_a
```

#### 5. 超休/打赏课特殊处理

当助教为超休/打赏课类型（`course_type = BONUS`）时，该助教不参与订单级分摊：
```
order_gross_revenue = ledger_amount（个人服务流水）
order_net_revenue = ledger_amount - commission
time_weighted_revenue = ledger_amount
time_weighted_net_revenue = ledger_amount - commission
```

### MemberConsumptionTask 扩展

在现有任务中新增：

```python
# extract 阶段新增
def _extract_recharge_stats(self, site_id: int, stat_date: date) -> Dict[int, Dict]:
    """从 dwd_recharge_order 提取 30/60/90 天充值统计"""
    ...

# transform 阶段新增字段
record['recharge_count_30d'] = recharge.get('count_30d', 0)
record['recharge_count_60d'] = recharge.get('count_60d', 0)
record['recharge_count_90d'] = recharge.get('count_90d', 0)
record['recharge_amount_30d'] = recharge.get('amount_30d', Decimal('0'))
record['recharge_amount_60d'] = recharge.get('amount_60d', Decimal('0'))
record['recharge_amount_90d'] = recharge.get('amount_90d', Decimal('0'))
record['avg_ticket_amount'] = (
    record['total_consume_amount'] / max(record['total_visit_count'], 1)
)
```

### AssistantDailyTask 扩展 — 惩罚检测

在现有任务的 transform 阶段新增惩罚检测逻辑：

```python
# 惩罚检测核心逻辑
@staticmethod
def detect_overlap_violations(
    service_records: List[Dict],
    penalty_areas: Set[str]
) -> Dict[Tuple[int, date], List[Dict]]:
    """
    检测同一台桌同一时间段超过 2 名助教挂台的违规。

    penalty_areas: 指定区域集合，如 {'A','B','C','S','TV','M1','M2',...,'M7'}

    返回: {(assistant_id, stat_date): [violation_info, ...]}
    """
    ...

@staticmethod
def compute_penalty_minutes(
    actual_minutes: Decimal,
    per_hour_contribution: Decimal,
    threshold: Decimal = Decimal('24')
) -> Decimal:
    """
    计算惩罚分钟数。

    per_hour_contribution >= threshold: 返回 0
    per_hour_contribution < threshold:
        返回 actual_minutes × (1 - per_hour_contribution / threshold)
    """
    ...
```

**惩罚区域定义：**
- 大厅：A、B、C、S、TV
- 麻将房：M1、M2、M3、M4、M5、M6、M7

**时间重叠检测算法：**
1. 按 `(台桌ID, 服务日期)` 分组所有服务记录
2. 对每组内的服务记录，检查时间段是否有重叠（任意两个助教的 `[start_time, end_time]` 有交集）
3. 若同一时间段内助教数 > 2，标记为违规
4. 对违规记录计算 `per_hour_contribution = 台费每小时单价 / 该时段助教人数`
5. 根据 `per_hour_contribution` 与 24 元阈值比较，计算 `penalty_minutes`

## 数据模型

### dws.dws_assistant_order_contribution（新建）

```sql
CREATE TABLE dws.dws_assistant_order_contribution (
    contribution_id     BIGSERIAL PRIMARY KEY,
    site_id             INTEGER NOT NULL,
    tenant_id           INTEGER NOT NULL,
    assistant_id        BIGINT NOT NULL,
    assistant_nickname  VARCHAR(100),
    stat_date           DATE NOT NULL,

    -- 四项统计
    order_gross_revenue         NUMERIC(14,2) DEFAULT 0,
    order_net_revenue           NUMERIC(14,2) DEFAULT 0,
    time_weighted_revenue       NUMERIC(14,2) DEFAULT 0,
    time_weighted_net_revenue   NUMERIC(14,2) DEFAULT 0,

    -- 辅助字段
    order_count                 INTEGER DEFAULT 0,
    total_service_seconds       INTEGER DEFAULT 0,

    -- 元数据
    created_at  TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at  TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_aoc_site_assistant_date
    ON dws.dws_assistant_order_contribution (site_id, assistant_id, stat_date);

CREATE INDEX idx_aoc_stat_date
    ON dws.dws_assistant_order_contribution (site_id, stat_date);
```

### dws_member_consumption_summary 扩展字段

```sql
ALTER TABLE dws.dws_member_consumption_summary
    ADD COLUMN IF NOT EXISTS recharge_count_30d  INTEGER DEFAULT 0,
    ADD COLUMN IF NOT EXISTS recharge_count_60d  INTEGER DEFAULT 0,
    ADD COLUMN IF NOT EXISTS recharge_count_90d  INTEGER DEFAULT 0,
    ADD COLUMN IF NOT EXISTS recharge_amount_30d NUMERIC(14,2) DEFAULT 0,
    ADD COLUMN IF NOT EXISTS recharge_amount_60d NUMERIC(14,2) DEFAULT 0,
    ADD COLUMN IF NOT EXISTS recharge_amount_90d NUMERIC(14,2) DEFAULT 0,
    ADD COLUMN IF NOT EXISTS avg_ticket_amount   NUMERIC(14,2) DEFAULT 0;
```

### dws_assistant_daily_detail 扩展字段

```sql
ALTER TABLE dws.dws_assistant_daily_detail
    ADD COLUMN IF NOT EXISTS penalty_minutes       NUMERIC(10,2) DEFAULT 0,
    ADD COLUMN IF NOT EXISTS penalty_reason         TEXT,
    ADD COLUMN IF NOT EXISTS is_exempt              BOOLEAN DEFAULT FALSE,
    ADD COLUMN IF NOT EXISTS per_hour_contribution  NUMERIC(14,2);
```

### RLS 视图（app schema）

```sql
-- 新建：助教订单流水统计
CREATE OR REPLACE VIEW app.v_dws_assistant_order_contribution AS
SELECT * FROM dws.dws_assistant_order_contribution
WHERE site_id = current_setting('app.current_site_id')::bigint;

-- 已有视图无需修改：dws_member_consumption_summary 和 dws_assistant_daily_detail
-- 的 RLS 视图使用 SELECT *，新增字段自动包含
```

### FDW 映射（fdw_etl schema）

在 `test_zqyy_app.fdw_etl` 中新建外部表：

```sql
CREATE FOREIGN TABLE fdw_etl.dws_assistant_order_contribution (
    contribution_id     BIGINT,
    site_id             INTEGER,
    tenant_id           INTEGER,
    assistant_id        BIGINT,
    assistant_nickname  VARCHAR(100),
    stat_date           DATE,
    order_gross_revenue         NUMERIC(14,2),
    order_net_revenue           NUMERIC(14,2),
    time_weighted_revenue       NUMERIC(14,2),
    time_weighted_net_revenue   NUMERIC(14,2),
    order_count                 INTEGER,
    total_service_seconds       INTEGER,
    created_at  TIMESTAMP WITH TIME ZONE,
    updated_at  TIMESTAMP WITH TIME ZONE
) SERVER etl_server
OPTIONS (schema_name 'app', table_name 'v_dws_assistant_order_contribution');
```

对于扩展字段的表（`dws_member_consumption_summary`、`dws_assistant_daily_detail`），需要 `DROP` 并重建 FDW 外部表定义以包含新字段。


## 正确性属性

*正确性属性（Correctness Property）是系统在所有合法执行路径上都应成立的行为特征——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*

以下属性基于需求文档中的验收标准推导。四项统计的核心计算函数（`compute_order_gross_revenue`、`compute_time_weighted_revenue` 等）和惩罚计算函数（`compute_penalty_minutes`）设计为纯静态方法，不依赖数据库，可直接用于属性测试。

### Property 1: 订单级统计不变量 — gross/net 各助教相等

*For any* 订单数据（包含任意数量的台桌、助教服务和酒水食品），所有参与该订单的助教应获得相同的 `order_gross_revenue` 值，且获得相同的 `order_net_revenue` 值。

推导：`order_gross_revenue` 和 `order_net_revenue` 是订单级聚合值，不按助教个人拆分，因此所有参与助教共享同一个值。

**Validates: Requirements 2.2, 2.3, 10.1, 10.2**

### Property 2: 时效贡献流水之和约束

*For any* 订单数据，所有参与助教的 `time_weighted_revenue` 之和应满足：
- 当所有台桌的助教总服务时长 ≥ 台桌使用时长时，之和 = `order_gross_revenue`
- 当存在台桌的助教总服务时长 < 台桌使用时长时，之和 ≤ `order_gross_revenue`

且在所有情况下，之和 ≥ 0。

推导：台费按时长比例分摊，当助教完全覆盖台桌时长时分摊总和等于台费；酒水食品按时长比例均分总和等于酒水总额；助教服务费直接计入。因此总和 = 台费分摊总和 + 酒水分摊总和 + 服务费总和 ≤ order_gross_revenue。

**Validates: Requirements 2.4, 10.3**

### Property 3: 时效净贡献减法关系

*For any* 助教和订单数据，该助教的 `time_weighted_net_revenue` 应等于 `time_weighted_revenue - commission`（该助教个人的服务分成）。

推导：这是定义性等式，直接从需求 2.5 得出。

**Validates: Requirements 2.5, 10.4**

### Property 4: 惩罚分钟数分段公式

*For any* 非负的 `actual_minutes` 和非负的 `per_hour_contribution`：
- 当 `per_hour_contribution >= 24` 时，`penalty_minutes = 0`
- 当 `per_hour_contribution < 24` 时，`penalty_minutes = actual_minutes × (1 - per_hour_contribution / 24)`

且在所有情况下，`0 ≤ penalty_minutes ≤ actual_minutes`。

推导：直接从需求 6.3/6.4 的分段公式得出。上界 `actual_minutes` 在 `per_hour_contribution = 0` 时取到。

**Validates: Requirements 6.3, 6.4, 10.5, 10.6**

### Property 5: 次均消费公式

*For any* 非负的 `total_consume_amount` 和非负整数 `total_visit_count`，`avg_ticket_amount` 应等于 `total_consume_amount / MAX(total_visit_count, 1)`。

推导：直接从需求 3.4 得出。`MAX(total_visit_count, 1)` 防止除零。

**Validates: Requirements 3.4, 10.7**

### Property 6: 重叠检测正确性

*For any* 一组助教服务记录，若在指定区域的同一台桌上存在 3 名或以上助教的服务时间段有重叠，则 `detect_overlap_violations` 应返回非空的违规列表。

推导：需求 6.1 要求检测"同一台桌同一时间段超过 2 名助教挂台"。我们可以生成随机的服务记录（包含时间段重叠和不重叠的情况），验证检测函数的正确性。

**Validates: Requirements 6.1**

## 错误处理

| 场景 | 处理方式 |
|------|----------|
| 订单无助教服务记录 | 跳过该订单，不生成统计记录 |
| 台桌使用时长为 0 | 台费分摊设为 0，避免除零 |
| 助教总服务时长为 0 | 酒水食品分摊设为 0，避免除零 |
| 会员无充值记录 | 充值次数/金额设为 0 |
| 会员无消费记录 | avg_ticket_amount 设为 0 |
| 助教当日无违规 | penalty_minutes = 0，penalty_reason = NULL |
| 服务记录缺少时间段信息 | 跳过该记录的重叠检测，日志 WARNING |
| per_hour_contribution 为负数 | 视为 0 处理（防御性编程） |
| FDW 映射创建失败 | 事务回滚，报错终止 |
| 数据库写入失败 | 事务回滚，抛出异常由调度器处理 |

> **注意：所有数据库操作均在测试库（`test_etl_feiqiu` / `test_zqyy_app`）中进行。**

## 测试策略

### 属性测试（hypothesis）

属性测试位于 `tests/` 目录（Monorepo 级），使用 `hypothesis` 库。

每个属性测试对应设计文档中的一个 Property，最少运行 100 次迭代。

测试文件：`tests/test_dws_contribution_properties.py`

```python
# Feature: 02-etl-dws-miniapp-extensions, Property 1: 订单级统计不变量
@given(order_data=order_data_strategy())
@settings(max_examples=200)
def test_gross_net_equal_across_assistants(order_data):
    """所有参与助教的 order_gross_revenue 和 order_net_revenue 应分别相等"""
    gross = AssistantOrderContributionTask.compute_order_gross_revenue(order_data)
    net = AssistantOrderContributionTask.compute_order_net_revenue(order_data)
    # 每个助教获得相同的 gross 和 net
    for assistant in order_data.assistants:
        assert assistant_gross == gross
        assert assistant_net == net
```

```python
# Feature: 02-etl-dws-miniapp-extensions, Property 4: 惩罚分钟数分段公式
@given(
    actual_minutes=st.decimals(min_value=0, max_value=600, places=2),
    per_hour_contribution=st.decimals(min_value=0, max_value=200, places=2),
)
@settings(max_examples=200)
def test_penalty_minutes_formula(actual_minutes, per_hour_contribution):
    """惩罚分钟数应符合分段公式且在 [0, actual_minutes] 范围内"""
    result = AssistantDailyTask.compute_penalty_minutes(
        actual_minutes, per_hour_contribution
    )
    if per_hour_contribution >= 24:
        assert result == 0
    else:
        expected = actual_minutes * (1 - per_hour_contribution / 24)
        assert abs(result - expected) < Decimal('0.01')
    assert 0 <= result <= actual_minutes
```

属性测试库：`hypothesis`（已在项目依赖中）

### 单元测试

单元测试位于 `apps/etl/connectors/feiqiu/tests/unit/`，使用 FakeDB/FakeAPI 工具。

重点覆盖：
- PRD 示例数据验算：使用 PRD 中的具体订单示例（3 名助教、2 张台桌、酒水 600 元）验证四项统计的精确数值
- 超休/打赏课边界：验证超休助教的四项统计等于个人流水
- 零值边界：无台费、无酒水、无助教服务的极端情况
- 惩罚计算边界：per_hour_contribution 恰好等于 24 元的临界值
- 充值窗口：验证 30/60/90 天窗口的正确切分
- 豁免逻辑：is_exempt = TRUE 时跳过惩罚

### 测试配置

- 属性测试：`cd C:\Project\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 单元测试：`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_assistant_order_contribution.py -v`
- 每个属性测试标注 `@settings(max_examples=200)`
- 每个属性测试注释引用设计文档 Property 编号
