Files

563 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档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 = 0penalty_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:\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 编号