# 需求文档 ## 简介 v8 联调修复了 11 个 BUG,其中 4 个的当前修复方式是"临时止血",需要更完整的方案。本 Spec 覆盖以下四个需求: - **需求 A**:助教月度聚合按档位分段统计,替代 `MAX()` 聚合 - **需求 B**:多门店会员查询 `register_site_id` 已知限制标记 + 预留扩展方案 - **需求 C**:会员生日字段全链路补齐(C1: ETL 链路 / C2: 手动补录) - **需求 D**:`DwdLoadTask.load()` 返回值格式规范化 ## 术语表 - **DwdLoadTask**:DWD 层装载任务,负责将 ODS 原始数据清洗装载至 DWD 明细层 - **DWS 任务**:数据汇总层任务,从 DWD 层聚合生成业务报表数据 - **SCD2**:缓慢变化维度类型 2,通过版本化记录维度属性的历史变化 - **BaseTask**:ETL 任务基类,提供 Extract/Transform/Load 模板方法和计数累加逻辑 - **FlowRunner**:ETL 流程编排器,按层级顺序执行任务并汇总计数 - **dim_member**:DWD 层会员维度表,使用 SCD2 管理历史版本 - **dws_assistant_monthly_summary**:DWS 层助教月度汇总表 - **dws_assistant_salary_calc**:DWS 层助教工资计算表 - **register_site_id**:会员注册门店 ID,当前 `dim_member` 中唯一的门店标识 - **assistant_level_code**:助教档位代码,助教月内可能因升级/降级而变化 - **dim_member_birthday_manual**:手动补录生日表(位于 `zqyy_app` 业务库),存储助教提交的会员生日信息,通过 FDW 只读映射供 ETL DWS 任务读取 - **_safe_int()**:`flow_runner.py` 中的类型安全辅助函数,将 `int`/`list`/`None` 统一转为 `int` - **_accumulate_counts()**:`BaseTask` 中的计数累加方法,合并多段执行的统计结果 ## 需求 ### 需求 1:DwdLoadTask 返回值格式规范化(需求 D) **用户故事:** 作为 ETL 开发者,我希望 DwdLoadTask.load() 的返回值格式与其他任务保持一致,以便 FlowRunner 能安全地汇总所有任务的计数。 #### 验收标准 1. WHEN DwdLoadTask.load() 执行完成, THE DwdLoadTask SHALL 返回包含 `errors` 键(值为 `int` 类型,等于错误列表长度)和 `error_details` 键(值为 `list[dict]` 类型,包含错误详情)的字典 2. WHEN BaseTask._accumulate_counts() 遇到值为 `list` 类型的计数项, THE BaseTask SHALL 将该值转换为 `len(list)` 后再累加(防御层) 3. WHEN FlowRunner 汇总所有任务计数, THE FlowRunner SHALL 保留 `_safe_int()` 作为最终防御层,确保 `sum()` 不会因类型不一致而崩溃 ### 需求 2:助教月度聚合按档位分段统计(需求 A) **用户故事:** 作为运营管理者,我希望助教月度汇总按档位分段统计业绩,以便准确反映助教在不同档位期间的表现和工资计算。 #### 验收标准 1. WHEN 助教在同一月内存在多个 assistant_level_code, THE AssistantMonthlyTask SHALL 按 `(assistant_id, stat_month, assistant_level_code)` 分组生成多行记录,分别统计各档位的业绩指标 2. THE dws_assistant_monthly_summary 表 SHALL 使用 `(site_id, assistant_id, stat_month, assistant_level_code)` 作为唯一约束 3. WHEN AssistantMonthlyTask 需要取 nickname 值, THE AssistantMonthlyTask SHALL 按时间倒序取最后一条记录的 nickname,而非使用 `MAX()` 聚合 4. WHEN AssistantSalaryTask 计算工资, THE AssistantSalaryTask SHALL 按档位分段计算抽成,适配新的多行月度汇总结构 5. WHEN AssistantFinanceTask 提取日度收入需要 nickname, THE AssistantFinanceTask SHALL 按时间倒序取最后一条记录的 nickname,而非使用 `MAX()` 聚合 6. WHEN AssistantCustomerTask 提取服务对需要 nickname, THE AssistantCustomerTask SHALL 按时间倒序取最后一条记录的 nickname,而非使用 `MAX()` 聚合 ### 需求 3:多门店会员查询支持(需求 B) **用户故事:** 作为运营管理者,我希望 DWS 任务能正确查询跨店消费的会员信息,以便 B 店能看到在 A 店注册但在 B 店消费的会员维度数据。 #### 验收标准 1. WHEN DWS 任务需要查询会员信息, THE DWS 任务 SHALL 通过事实表中的 `member_id` 反查 `dim_member`,而非使用 `WHERE register_site_id = %s` 预筛选 2. WHEN 会员在 A 店注册并在 B 店消费, THE B 店的 DWS 任务 SHALL 能查询到该会员的昵称、手机号等维度信息 3. WHEN DWS 任务执行会员信息提取, THE DWS 任务 SHALL 使用 `WHERE member_id IN (SELECT DISTINCT member_id FROM dwd.事实表 WHERE site_id = %s)` 模式获取会员维度数据 ### 需求 4:会员生日字段 ETL 链路补齐(需求 C1) **用户故事:** 作为运营管理者,我希望会员生日信息能从上游 API 完整传递到 DWS 层,以便用于会员分析和销售线索。 #### 验收标准 1. THE dim_member 表 SHALL 包含 `birthday DATE` 列 2. WHEN DwdLoadTask 执行 ODS → DWD 装载, THE DwdLoadTask SHALL 在列映射中包含 `birthday` 字段,将 ODS 中的生日数据装载到 `dim_member.birthday` 3. WHEN SCD2 更新 dim_member 记录, THE DwdLoadTask SHALL 将 `birthday` 作为变化检测字段之一,正常处理生日值的变化 4. WHEN MemberVisitTask 等 DWS 任务提取会员信息, THE DWS 任务 SHALL 从 `dim_member.birthday` 读取生日字段并写入 DWS 目标表 ### 需求 5:助教手动补录会员生日(需求 C2) **用户故事:** 作为助教,我希望能手动提交客户的生日信息,以便在上游 API 未提供生日数据时补充这一重要销售线索。 #### 验收标准 1. THE `zqyy_app` 业务库(开发/测试环境使用 `test_zqyy_app`)SHALL 包含 `member_birthday_manual` 表,结构包含 `member_id`、`birthday_value`、`recorded_by_assistant_id`、`recorded_by_name`、`recorded_at`、`source` 字段,唯一约束为 `(member_id, recorded_by_assistant_id)` 2. WHEN 同一助教对同一会员重复提交生日, THE 系统 SHALL 更新该助教的已有记录(UPSERT),保留所有其他助教的提交记录 3. WHEN DWS 任务需要读取手动补录生日, THE DWS 任务 SHALL 通过 FDW 只读映射从 `zqyy_app.member_birthday_manual` 读取数据 4. WHEN dim_member.birthday(API 来源)和 member_birthday_manual(手动来源)同时存在, THE DWS 任务 SHALL 优先使用手动补录值 5. WHEN 助教通过后端 API 提交生日, THE 后端 SHALL 提供 POST 接口接收 `member_id`、`birthday_value`、`assistant_id`、`assistant_name` 参数,执行 UPSERT 写入 `zqyy_app.member_birthday_manual` 6. WHEN SCD2 更新 dim_member.birthday, THE DwdLoadTask SHALL 正常更新 API 来源的生日值,不影响业务库中手动补录表的数据