# 设计文档: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 聚焦订单级流水分摊),独立建表避免字段膨胀。 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
结算主表] TF[dwd_table_fee_log
台费明细] ASL[dwd_assistant_service_log
助教服务记录] RO[dwd_recharge_order
充值订单] end subgraph 新建任务 AOC[AssistantOrderContributionTask
助教订单流水统计] end subgraph 修改任务 MCT[MemberConsumptionTask
+充值窗口 +次均消费] ADT[AssistantDailyTask
+惩罚检测 +惩罚计算] end subgraph 输出(DWS) T1[dws_assistant_order_contribution
新建] T2[dws_member_consumption_summary
扩展字段] T3[dws_assistant_daily_detail
扩展字段] 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:\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 编号