# DWS 层任务详解 > 本文档说明飞球 ETL 系统中 DWS(数据服务层)的所有任务。 > DWS 层负责从 DWD 层读取明细数据,按业务维度聚合计算后写入汇总表, > 服务于助教业绩、会员分析、财务统计、指数算法等业务场景。 --- ## 概述 DWS 层共有 15 个已注册任务,按业务域分为四组: ### 助教业绩域(5 个) | 任务代码 | 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 | ### 会员分析域(2 个) | 任务代码 | Python 类 | 目标表 | 粒度 | 更新策略 | |----------|-----------|--------|------|----------| | `DWS_MEMBER_CONSUMPTION` | `MemberConsumptionTask` | `dws_member_consumption_summary` | 日期+会员 | delete-before-insert | | `DWS_MEMBER_VISIT` | `MemberVisitTask` | `dws_member_visit_detail` | 日期+会员+结账单 | delete-before-insert | ### 财务统计域(4 个) | 任务代码 | 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 | ### 运维任务(4 个) | 任务代码 | Python 类 | 继承 | 说明 | 更新策略 | |----------|-----------|------|------|----------| | `DWS_BUILD_ORDER_SUMMARY` | `DwsBuildOrderSummaryTask` | `BaseTask` | 构建订单汇总中间表 | delete-before-insert | | `DWS_RETENTION_CLEANUP` | `DwsRetentionCleanupTask` | `BaseDwsTask` | 按时间分层清理历史数据 | DELETE | | `DWS_MV_REFRESH_FINANCE_DAILY` | `DwsMvRefreshFinanceDailyTask` | `BaseMvRefreshTask` | 刷新财务日报物化视图 | REFRESH | | `DWS_MV_REFRESH_ASSISTANT_DAILY` | `DwsMvRefreshAssistantDailyTask` | `BaseMvRefreshTask` | 刷新助教日报物化视图 | REFRESH | > 注册位置:`orchestration/task_registry.py` > 除 `DWS_BUILD_ORDER_SUMMARY` 继承 `BaseTask` 外,其余 14 个任务均继承 `BaseDwsTask`(或其子类 `BaseMvRefreshTask`)。 --- ## BaseDwsTask 公共机制 `BaseDwsTask`(位于 `tasks/dws/base_dws_task.py`)继承自 `BaseTask`,为所有 DWS 层业务任务提供统一的基础设施。核心能力包括: 1. **时间分层与窗口计算** — 按业务需要选取不同跨度的数据范围 2. **配置缓存** — 从 DWS 配置表加载业绩档位、等级定价、奖金规则等,带 TTL 缓存 3. **幂等更新(delete-before-insert)** — 先删后插,保证重跑结果一致 4. **批量写入(bulk_insert / upsert)** — 两种落库方式,适配不同场景 5. **DWD 数据读取** — 分批迭代或直接查询 DWD 层数据 6. **SCD2 维度 as-of 取值** — 按历史生效期获取维度快照 7. **滚动窗口统计与排名计算** — 多窗口聚合和考虑并列的排名 ### 子类必须实现的抽象方法 ```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` | `billiards_dws.cfg_performance_tier` | 绩效档位(小时阈值 → 抽成/休假) | | `level_prices` | `billiards_dws.cfg_assistant_level_price` | 助教等级单价(基础课/附加课) | | `bonus_rules` | `billiards_dws.cfg_bonus_rules` | 奖金规则(冲刺奖金/Top 排名奖金) | | `area_categories` | `billiards_dws.cfg_area_category` | 区域分类映射(精确/模糊/兜底) | | `skill_types` | `billiards_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)` | 区域名 → 分类 | 精确匹配 → 模糊匹配 → 兜底 `OTHER` | | `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()` 获取目标表名,拼接 `billiards_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()` 确定,自动拼接 `billiards_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` 分批读取 `billiards_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) 查询 `billiards_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) 查询 `billiards_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_trash_event ──┘ │ ▼ 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_DAILY — 助教日度业绩明细 | 属性 | 值 | |------|-----| | 任务代码 | `DWS_ASSISTANT_DAILY` | | Python 类 | `AssistantDailyTask`(`tasks/dws/assistant_daily_task.py`) | | 目标表 | `billiards_dws.dws_assistant_daily_detail` | | 主键 | `site_id`, `assistant_id`, `stat_date` | | 粒度 | 日期 + 助教 | | 更新策略 | delete-before-insert(按日期窗口) | | 更新频率 | 每小时增量更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_assistant_service_log` | `billiards_dwd` | 助教服务流水(主数据源) | | `dwd_assistant_trash_event` | `billiards_dwd` | 废除记录(排除无效业绩) | | `dim_assistant` | `billiards_dwd` | 助教维度(SCD2,获取当日等级) | | `cfg_skill_type` | `billiards_dws` | 技能 → 课程类型映射 | #### 聚合维度与输出字段 按 `(assistant_id, service_date)` 聚合,输出以下字段: | 字段分组 | 字段 | 说明 | |----------|------|------| | 标识 | `site_id`, `tenant_id`, `assistant_id`, `assistant_nickname`, `stat_date` | 门店、助教、日期 | | 等级 | `assistant_level_code`, `assistant_level_name` | SCD2 as-of 取值,取统计日当日生效的等级 | | 服务次数 | `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` | 被废除的秒数和次数 | #### 核心业务逻辑 1. **课程类型分类**:通过 `skill_id` 查询 `cfg_skill_type` 映射,分为 `BASE`(基础课)、`BONUS`(附加课)、`ROOM`(包厢课),未匹配默认 `BASE` 2. **废除记录排除**:以 `assistant_service_id` 为键构建废除索引,被废除的服务记录不计入有效业绩(服务次数、时长、金额),但单独统计 `trashed_seconds` 和 `trashed_count` 3. **助教等级 SCD2 取值**:调用 `get_assistant_level_asof(assistant_id, service_date)` 获取统计日当日生效的等级版本,而非当前最新版本 4. **散客过滤**:`unique_customers` 统计时排除 `member_id` 为 0 或 None 的散客 5. **客户/台桌去重**:无论服务记录是否被废除,客户和台桌均参与去重统计 --- ### DWS_ASSISTANT_MONTHLY — 助教月度业绩汇总 | 属性 | 值 | |------|-----| | 任务代码 | `DWS_ASSISTANT_MONTHLY` | | Python 类 | `AssistantMonthlyTask`(`tasks/dws/assistant_monthly_task.py`) | | 目标表 | `billiards_dws.dws_assistant_monthly_summary` | | 主键 | `site_id`, `assistant_id`, `stat_month` | | 粒度 | 月份 + 助教 | | 更新策略 | delete-before-insert(按月份) | | 更新频率 | 每日更新当月数据 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dws_assistant_daily_detail` | `billiards_dws` | 日度明细(按月聚合) | | `dwd_assistant_service_log` | `billiards_dwd` | 月度去重客户/台桌(直接从 DWD 去重,避免日度求和失真) | | `dim_assistant` | `billiards_dwd` | 助教维度(入职日期、当前等级) | | `cfg_performance_tier` | `billiards_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`) | | 目标表 | `billiards_dws.dws_assistant_customer_stats` | | 主键 | `site_id`, `assistant_id`, `member_id`, `stat_date` | | 粒度 | 统计日期 + 助教 + 会员 | | 更新策略 | delete-before-insert(按统计日期) | | 更新频率 | 每日更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_assistant_service_log` | `billiards_dwd` | 助教服务流水(全量,用于累计和滚动窗口统计) | | `dim_member` | `billiards_dwd` | 会员维度(昵称、手机号) | | `dim_assistant` | `billiards_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`) | | 目标表 | `billiards_dws.dws_assistant_salary_calc` | | 主键 | `site_id`, `assistant_id`, `salary_month` | | 粒度 | 月份 + 助教 | | 更新策略 | delete-before-insert(按月份) | | 更新频率 | 月初计算上月工资 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dws_assistant_monthly_summary` | `billiards_dws` | 月度业绩汇总(有效小时数、档位、排名) | | `dws_assistant_recharge_commission` | `billiards_dws` | 充值提成(Excel 导入) | | `cfg_performance_tier` | `billiards_dws` | 绩效档位(抽成比例、假期天数) | | `cfg_assistant_level_price` | `billiards_dws` | 等级定价(客户支付价格) | | `cfg_bonus_rules` | `billiards_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`) | | 目标表 | `billiards_dws.dws_assistant_finance_analysis` | | 主键 | `site_id`, `stat_date`, `assistant_id` | | 粒度 | 日期 + 助教 | | 更新策略 | delete-before-insert(按日期) | | 更新频率 | 每日更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_assistant_service_log` | `billiards_dwd` | 助教服务流水(日度收入) | | `cfg_skill_type` | `billiards_dws` | 技能 → 课程类型映射(收入分类) | | `dws_assistant_salary_calc` | `billiards_dws` | 工资计算结果(月度成本) | | `dws_assistant_daily_detail` | `billiards_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`) | | 目标表 | `billiards_dws.dws_member_consumption_summary` | | 主键 | `site_id`, `member_id`, `stat_date` | | 粒度 | 统计日期 + 会员 | | 更新策略 | delete-before-insert(按统计日期) | | 更新频率 | 每日更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_settlement_head` | `billiards_dwd` | 结账单头表(消费金额、台费、商品、助教费用) | | `dim_member` | `billiards_dwd` | 会员维度(SCD2 当前版本,昵称、手机号、卡等级、注册日期、累计充值) | | `dim_member_card_account` | `billiards_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` | 各窗口消费金额 | | 卡余额 | `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 阶段均做过滤。 **2. 消费统计来源** 从 `dwd_settlement_head` 按 `member_id` 聚合,消费金额拆分为: - `consume_money`:总消费金额 - `table_charge_money`:台费 - `goods_money`:商品金额 - `assistant_pd_money + assistant_cx_money`:助教费用(专业课 + 陪练课合计) **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` | `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`) | | 目标表 | `billiards_dws.dws_member_visit_detail` | | 主键 | `site_id`, `member_id`, `order_settle_id` | | 粒度 | 会员 + 结账单(每次到店一条记录) | | 更新策略 | delete-before-insert(按 `visit_date` 日期窗口) | | 更新频率 | 每日增量更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_settlement_head` | `billiards_dwd` | 结账单头表(消费金额、支付方式) | | `dwd_assistant_service_log` | `billiards_dwd` | 助教服务流水(服务时长、金额) | | `dwd_table_fee_log` | `billiards_dwd` | 台费流水(真实台桌使用秒数) | | `dim_member` | `billiards_dwd` | 会员维度(SCD2 当前版本,昵称、手机号、生日) | | `dim_table` | `billiards_dwd` | 台桌维度(SCD2 当前版本,台桌名称、区域名称) | | `cfg_area_category` | `billiards_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`, `cash_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` 过滤,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`:`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` | 现金/在线支付 | | `cash_card_pay` | `balance_amount` | 储值卡(现金卡)支付 | | `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)` 将台桌区域名称映射为分类标签。匹配逻辑:精确匹配 → 模糊匹配 → 兜底 `OTHER`。 **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`) | | 目标表 | `billiards_dws.dws_finance_daily_summary` | | 主键 | `site_id`, `stat_date` | | 粒度 | 日期 | | 更新策略 | delete-before-insert(按日期窗口) | | 更新频率 | 每小时更新当日数据 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_settlement_head` | `billiards_dwd` | 结账单头表(发生额、支付、优惠) | | `dwd_groupbuy_redemption` | `billiards_dwd` | 团购核销(团购实付金额) | | `dwd_recharge_order` | `billiards_dwd` | 充值订单(首充/续充、现金/赠送) | | `dwd_member_balance_change` | `billiards_dwd` | 余额变动(赠送卡消费) | | `dws_finance_expense_summary` | `billiards_dws` | 支出汇总(Excel 导入,按月分摊到日) | | `dws_platform_settlement` | `billiards_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` | 优惠合计及按类型拆分 | | 确认收入 | `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`, `cash_card_consume`, `gift_card_consume` | 储值卡消费 + 赠送卡消费 | | 充值统计 | `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 ``` 从 `dwd_settlement_head` 按 `DATE(pay_time)` 聚合,分别统计台费、商品、专业课(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 confirmed_income = gross_amount - discount_total ``` **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. 卡消费统计** ``` cash_card_consume = recharge_card_amount + balance_amount (储值卡支付) gift_card_consume = 赠送卡消费总额 (来自余额变动) card_consume_total = cash_card_consume + gift_card_consume ``` --- ### DWS_FINANCE_RECHARGE — 充值统计 | 属性 | 值 | |------|-----| | 任务代码 | `DWS_FINANCE_RECHARGE` | | Python 类 | `FinanceRechargeTask`(`tasks/dws/finance_recharge_task.py`) | | 目标表 | `billiards_dws.dws_finance_recharge_summary` | | 主键 | `site_id`, `stat_date` | | 粒度 | 日期 | | 更新策略 | delete-before-insert(按日期窗口) | | 更新频率 | 每日更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_recharge_order` | `billiards_dwd` | 充值订单(首充/续充、现金/赠送) | | `dim_member_card_account` | `billiards_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_money(现金部分)+ gift_money(赠送部分) ``` **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` | ``` 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`) | | 目标表 | `billiards_dws.dws_finance_income_structure` | | 主键 | `site_id`, `stat_date`, `structure_type`, `category_code` | | 粒度 | 日期 + 结构类型 + 分类代码 | | 更新策略 | delete-before-insert(按日期窗口) | | 更新频率 | 每日更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_settlement_head` | `billiards_dwd` | 结账单头表(按收入类型汇总) | | `dwd_table_fee_log` | `billiards_dwd` | 台费流水(按区域汇总台费收入) | | `dwd_assistant_service_log` | `billiards_dwd` | 助教服务流水(按区域汇总助教收入) | | `dim_table` | `billiards_dwd` | 台桌维度(SCD2,获取区域名称) | | `cfg_area_category` | `billiards_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_status = 1`),每日展开为 4 条记录: | category_code | category_name | 来源字段 | 说明 | |---------------|---------------|----------|------| | `TABLE_FEE` | 台费收入 | `table_charge_money` | 台桌使用费 | | `GOODS` | 商品收入 | `goods_money` | 商品销售 | | `ASSISTANT_BASE` | 助教基础课 | `assistant_pd_money` | 专业课(PD=陪打) | | `ASSISTANT_BONUS` | 助教附加课 | `assistant_cx_money` | 附加课(CX=超休/促销) | 占比计算:`income_ratio = 该类型金额 / 当日四类收入总和` **维度 2:按区域(`structure_type = 'AREA'`)** 通过 CTE 合并台费流水和助教服务流水,关联 `dim_table` 获取 `site_table_area_name`,再通过 `get_area_category(area_name)` 映射到分类代码。 区域映射逻辑(与 `DWS_MEMBER_VISIT` 相同):精确匹配 → 模糊匹配 → 兜底 `OTHER`。 相同 `category_code` 的不同区域名称会被合并聚合。每条记录额外输出 `duration_minutes`(台费秒数 + 助教服务秒数,转换为分钟)。 占比计算:`income_ratio = 该区域金额 / 当日所有区域收入总和` #### 核心业务逻辑 **1. 自定义 load 方法** 此任务未使用 `BaseDwsTask.delete_existing_data()` + `bulk_insert()`,而是自行实现 DELETE + INSERT SQL,直接操作 `billiards_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`) | | 目标表 | `billiards_dws.dws_finance_discount_detail` | | 主键 | `site_id`, `stat_date`, `discount_type_code` | | 粒度 | 日期 + 折扣类型 | | 更新策略 | delete-before-insert(按日期窗口) | | 更新频率 | 每日更新 | #### 数据来源 | 来源表 | Schema | 用途 | |--------|--------|------| | `dwd_settlement_head` | `billiards_dwd` | 结账单头表(团购、手动调整、会员折扣、抹零) | | `dwd_groupbuy_redemption` | `billiards_dwd` | 团购核销(团购实付金额) | | `dwd_member_balance_change` | `billiards_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_status = 1`)。 **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`。 --- ## 运维任务 运维任务包含 4 个任务,负责订单汇总中间表构建、历史数据清理和物化视图刷新。这些任务不直接产出业务报表,而是为其他 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_RETENTION_CLEANUP(历史数据清理) dws_finance_daily_summary ────► DWS_MV_REFRESH_FINANCE_DAILY(财务日报物化视图刷新) dws_assistant_daily_detail ───► DWS_MV_REFRESH_ASSISTANT_DAILY(助教日报物化视图刷新) ``` --- ### DWS_BUILD_ORDER_SUMMARY — 订单汇总中间表构建 | 属性 | 值 | |------|-----| | 任务代码 | `DWS_BUILD_ORDER_SUMMARY` | | Python 类 | `DwsBuildOrderSummaryTask`(`tasks/utility/dws_build_order_summary_task.py`) | | 继承 | `BaseTask`(非 `BaseDwsTask`) | | 目标表 | `billiards_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` | `billiards_dwd` | 结账单头表(基础订单信息、支付金额、优惠金额) | | `dwd_table_fee_log` | `billiards_dwd` | 台费流水(真实台费金额) | | `dwd_assistant_service_log` | `billiards_dwd` | 助教服务流水(助教服务金额) | | `dwd_store_goods_sale` | `billiards_dwd` | 商品销售明细(商品数量、金额) | | `dwd_groupbuy_redemption` | `billiards_dwd` | 团购核销(团购金额) | | `dwd_refund` / `dwd_refund_ex` | `billiards_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` | 订单原价、实付金额 | | 支付方式 | `stored_card_deduct`, `external_paid_amount`, `total_paid_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` | 有效消费现金、有效充值现金、有效流水 | | 退款 | `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 但有支付金额的订单标记为充值订单。 #### 配置参数 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `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` 窗口分段,大时间范围自动拆分为多段依次执行 #### 前置依赖 目标表 `billiards_dws.dws_order_summary` 必须已存在,否则抛出 `RuntimeError`。需先运行 `INIT_DWS_SCHEMA` 任务创建表结构。 --- ### DWS_RETENTION_CLEANUP — 时间分层清理 | 属性 | 值 | |------|-----| | 任务代码 | `DWS_RETENTION_CLEANUP` | | Python 类 | `DwsRetentionCleanupTask`(`tasks/dws/retention_cleanup_task.py`) | | 继承 | `BaseDwsTask` | | 目标表 | 多张 DWS 表(按配置) | | 更新策略 | DELETE(按日期截断删除历史数据) | #### 用途 按配置的时间分层范围,对 DWS 层的汇总表执行历史数据清理。用于控制 DWS 表的数据量增长,删除超出保留期的历史记录。**该任务默认不启用**,需通过配置显式开启。 #### 默认清理表列表 任务内置了 14 张 DWS 表的清理定义,每张表指定了对应的日期列: | 目标表 | 日期列 | 说明 | |--------|--------|------| | `dws_assistant_daily_detail` | `stat_date` | 助教日度明细 | | `dws_assistant_monthly_summary` | `stat_month` | 助教月度汇总 | | `dws_assistant_customer_stats` | `stat_date` | 助教-客户关系 | | `dws_assistant_salary_calc` | `salary_month` | 助教工资 | | `dws_assistant_recharge_commission` | `commission_month` | 充值提成 | | `dws_assistant_finance_analysis` | `stat_date` | 助教收支分析 | | `dws_member_consumption_summary` | `stat_date` | 会员消费汇总 | | `dws_member_visit_detail` | `visit_date` | 会员到店明细 | | `dws_finance_daily_summary` | `stat_date` | 财务日报 | | `dws_finance_income_structure` | `stat_date` | 收入结构 | | `dws_finance_discount_detail` | `stat_date` | 折扣明细 | | `dws_finance_recharge_summary` | `stat_date` | 充值统计 | | `dws_finance_expense_summary` | `expense_month` | 支出汇总 | | `dws_platform_settlement` | `settlement_date` | 平台结算 | #### 配置参数 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `dws.retention.enabled` | `bool` | `False` | 是否启用清理(**必须显式设为 `true`**) | | `dws.retention.layer` | `str` | `"ALL"` | 默认清理层级(TimeLayer 枚举值) | | `dws.retention.tables` | `str` | 全部 14 张表 | 需要清理的表名列表(逗号分隔),为空时使用全部默认表 | | `dws.retention.table_layers` | `str/dict` | `{}` | 表级别的层级覆盖(JSON 格式),可为特定表指定不同的保留期 | #### 配置示例 ```ini # .env 配置 DWS_RETENTION_ENABLED=true DWS_RETENTION_LAYER=LAST_3_MONTHS DWS_RETENTION_TABLES=dws_finance_daily_summary,dws_assistant_daily_detail DWS_RETENTION_TABLE_LAYERS={"dws_finance_expense_summary":"ALL"} ``` 上述配置含义: - 启用清理功能 - 默认保留近 3 个月数据,删除更早的记录 - 仅清理 `dws_finance_daily_summary` 和 `dws_assistant_daily_detail` 两张表 - `dws_finance_expense_summary` 使用 `ALL` 层级(即不清理,保留全部数据) #### 核心业务逻辑 **1. 启用检查** 任务执行时首先检查 `dws.retention.enabled` 配置,未启用则直接跳过,不执行任何删除操作。 **2. 层级解析与截断日期计算** 根据配置的 `TimeLayer` 层级,调用 `get_time_layer_range(layer, base_date)` 计算时间范围,取范围的起始日期作为截断点(cutoff)。早于截断点的数据将被删除。 | 层级 | 保留范围 | 截断点(以 2026-03-15 为例) | |------|----------|------------------------------| | `LAST_2_DAYS` | 近 2 天 | 2026-03-14 | | `LAST_1_MONTH` | 近 30 天 | 2026-02-13 | | `LAST_3_MONTHS` | 近 90 天 | 2025-12-15 | | `LAST_6_MONTHS` | 近 6 个月 | 2025-09-01(月初) | | `ALL` | 全量保留 | 不清理(跳过) | **3. 月度列截断对齐** 对于日期列为月度粒度的表(`stat_month`、`salary_month`、`commission_month`、`expense_month`),截断日期自动对齐到月初(`day=1`),避免删除当月部分数据。 **4. 表级层级覆盖** 通过 `dws.retention.table_layers` 配置,可为特定表指定不同于默认层级的保留期。例如支出汇总表(月度 Excel 导入)可设为 `ALL` 永久保留,而日度明细表设为 `LAST_3_MONTHS`。 **5. 清理 SQL** 对每张目标表执行: ```sql DELETE FROM billiards_dws.{table} WHERE site_id = {store_id} AND {date_col} < {cutoff} ``` 按门店隔离,仅删除当前门店的历史数据。 **6. 执行结果** 返回每张表的删除行数明细和总删除行数: ```json { "counts": {"cleaned": 1500}, "extra": { "details": [ {"table": "dws_finance_daily_summary", "deleted": 800, "cutoff": "2025-12-15"}, {"table": "dws_assistant_daily_detail", "deleted": 700, "cutoff": "2025-12-15"} ] } } ``` --- ### DWS_MV_REFRESH_FINANCE_DAILY / DWS_MV_REFRESH_ASSISTANT_DAILY — 物化视图分层刷新 这两个任务共享同一个基类 `BaseMvRefreshTask`,仅在基表名称上有所不同。 #### 任务信息 | 属性 | DWS_MV_REFRESH_FINANCE_DAILY | DWS_MV_REFRESH_ASSISTANT_DAILY | |------|------------------------------|--------------------------------| | 任务代码 | `DWS_MV_REFRESH_FINANCE_DAILY` | `DWS_MV_REFRESH_ASSISTANT_DAILY` | | Python 类 | `DwsMvRefreshFinanceDailyTask` | `DwsMvRefreshAssistantDailyTask` | | 继承 | `BaseMvRefreshTask` → `BaseDwsTask` | `BaseMvRefreshTask` → `BaseDwsTask` | | 基表 | `dws_finance_daily_summary` | `dws_assistant_daily_detail` | | 日期列 | `stat_date` | `stat_date` | | 更新策略 | `REFRESH MATERIALIZED VIEW` | `REFRESH MATERIALIZED VIEW` | #### 用途 按时间分层刷新 PostgreSQL 物化视图。物化视图预先按不同时间范围(L1-L4)聚合基表数据,供前端查询直接使用,避免每次查询都执行全表扫描。**该任务默认不启用**,需通过配置显式开启。 #### 分层机制(L1-L4) 每张基表对应最多 4 个物化视图,按时间范围从小到大排列: | 层级 | 后缀 | 对应 TimeLayer | 时间范围 | 视图命名示例 | |------|------|----------------|----------|-------------| | L1 | `_l1` | `LAST_2_DAYS` | 近 2 天 | `mv_dws_finance_daily_summary_l1` | | L2 | `_l2` | `LAST_1_MONTH` | 近 30 天 | `mv_dws_finance_daily_summary_l2` | | L3 | `_l3` | `LAST_3_MONTHS` | 近 90 天 | `mv_dws_finance_daily_summary_l3` | | L4 | `_l4` | `LAST_6_MONTHS` | 近 6 个月 | `mv_dws_finance_daily_summary_l4` | 视图命名规则:`mv_{基表名}_{层级后缀}` #### 配置参数 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `dws.mv.enabled` | `bool` | `False` | 是否启用物化视图刷新(**必须显式设为 `true`**) | | `dws.mv.tables` | `str` | `None` | 需要刷新的基表列表(逗号分隔),为空时回退到 `dws.retention.tables` | | `dws.mv.layers` | `str` | `None` | 显式指定刷新的层级列表(逗号分隔,如 `LAST_2_DAYS,LAST_1_MONTH`) | | `dws.mv.table_layers` | `str/dict` | `None` | 表级别的层级覆盖(JSON 格式),为空时回退到 `dws.retention.table_layers` | | `dws.mv.refresh_concurrently` | `bool` | `False` | 是否使用 `CONCURRENTLY` 关键字刷新(不阻塞读查询,但需要唯一索引) | #### 层级解析优先级 任务按以下优先级确定需要刷新哪些层级: 1. **显式配置**(`dws.mv.layers`):直接指定层级列表,最高优先级 2. **表级覆盖**(`dws.mv.table_layers` → `dws.retention.table_layers`):按基表名查找对应层级,刷新该层级及其以下所有层级 3. **默认层级**(`dws.retention.layer`):使用保留清理的层级配置,刷新该层级及其以下所有层级 4. **全部刷新**:以上均未配置时,刷新 L1-L4 全部 4 个层级 "该层级及其以下"的含义:若配置为 `LAST_3_MONTHS`(L3),则刷新 L1、L2、L3 三个层级。 #### 启用条件 任务启用需同时满足: 1. `dws.mv.enabled = true` 2. 当前基表在允许列表中(`dws.mv.tables` 或 `dws.retention.tables` 包含该基表名),或两个列表均为空(不限制) #### 核心业务逻辑 **1. 视图存在性检查** 刷新前通过 `SELECT to_regclass(...)` 检查物化视图是否存在。不存在的视图会被跳过并记录警告日志,不会导致任务失败。 **2. 刷新 SQL** ```sql -- 普通刷新(阻塞读查询) REFRESH MATERIALIZED VIEW billiards_dws.mv_dws_finance_daily_summary_l1; -- 并发刷新(不阻塞读查询,需要唯一索引) REFRESH MATERIALIZED VIEW CONCURRENTLY billiards_dws.mv_dws_finance_daily_summary_l1; ``` 是否使用 `CONCURRENTLY` 由 `dws.mv.refresh_concurrently` 配置控制。 **3. 执行结果** 返回刷新的视图数量和明细: ```json { "counts": {"refreshed": 3}, "extra": { "details": [ {"view": "mv_dws_finance_daily_summary_l1", "layer": "LAST_2_DAYS"}, {"view": "mv_dws_finance_daily_summary_l2", "layer": "LAST_1_MONTH"}, {"view": "mv_dws_finance_daily_summary_l3", "layer": "LAST_3_MONTHS"} ] } } ``` #### 配置示例 ```ini # .env 配置 DWS_MV_ENABLED=true DWS_MV_REFRESH_CONCURRENTLY=false DWS_MV_LAYERS=LAST_2_DAYS,LAST_1_MONTH,LAST_3_MONTHS # 或通过 retention 配置联动 DWS_RETENTION_ENABLED=true DWS_RETENTION_LAYER=LAST_3_MONTHS # 物化视图刷新会自动使用 retention 的层级配置,刷新 L1/L2/L3 ``` #### 与 DWS_RETENTION_CLEANUP 的配置联动 物化视图刷新任务与保留清理任务共享部分配置: - `dws.mv.tables` 为空时,回退到 `dws.retention.tables` 确定需要刷新的基表 - `dws.mv.table_layers` 为空时,回退到 `dws.retention.table_layers` 确定表级层级 - `dws.retention.layer` 作为默认层级的最终回退 这种设计使得两个任务可以通过统一的 retention 配置体系联动控制,也可以通过 `dws.mv.*` 配置独立覆盖。