# P2-4 课程体系 + P2-7 看板切换限制 调研 > 日期:2026-05-04 > 触发:Neo 在 04c 反馈中提出 > 调研环境:测试库 `test_etl_feiqiu`(只 SELECT)+ ETL/后端/小程序源码 > 子代理:无(主流程) --- ## 一、P2-4 课程体系实证 ### 1.1 数据库层(SQL 结果) #### A. cfg_skill_type — 课程类型主映射(skill_id → BASE/BONUS/ROOM) 实测共 6 行(`SELECT * FROM dws.cfg_skill_type ORDER BY skill_type_id`): | skill_type_id | skill_id | skill_name | course_type_code | course_type_name | description | |---|---|---|---|---|---| | 1 | 2791903611396869 | 台球基础陪打 | **BASE** | 基础课 | 按助教等级计价 | | 2 | 2807440316432197 | 台球超休服务 | **BONUS** | 附加课 | 固定 190 元/小时 | | 3 | 2807440316432198 | 包厢服务 | **BASE** | 基础课 | 包厢服务:**归入基础课统计**,统一 138 元/小时 | | 4 | 2790683529513797 | 基础课 | **BASE** | 基础课 | 飞球系统原始课程类型 | | 5 | 2790683529513798 | 附加课 | **BONUS** | 附加课 | 飞球系统原始课程类型 | | 6 | 3039912271463941 | 包厢课 | **BASE** | 基础课 | 飞球系统原始课程类型(2026-03-24 补录) | > **关键事实**:数据库里**只有 2 个课程类型代码**:`BASE` / `BONUS`,**没有 `ROOM`**。 > "包厢服务/包厢课"两个 skill 都被标记为 `BASE`。 > 这与 BD 手册 `BD_manual_cfg_skill_type.md`(写有 ROOM 枚举)不一致 — **数据库种子数据是真相**。 #### B. cfg_assistant_level_price — 助教等级单价(只区分 BASE/BONUS,无 ROOM) 实测 5 行: | level_code | level_name | base_course_price | bonus_course_price | |---|---|---|---| | 10 | 初级 | 98.00 | 190.00 | | 20 | 中级 | 108.00 | 190.00 | | 30 | 高级 | 118.00 | 190.00 | | 40 | 星级 | 138.00 | 190.00 | | 8 | 助教管理 | 98.00 | 190.00 | > 表本身**只有 base/bonus 两列**,包厢课没有独立列,全靠代码层硬编码 138 元(`dws.salary.room_course_price`)。 #### C. dws_assistant_daily_detail / monthly_summary — 服务计数把"包厢"独立成第 3 类 实测字段(`information_schema.columns`): ```text 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 total_ledger_amount / base_ledger_amount / bonus_ledger_amount / room_ledger_amount ``` > **结论**:配置层只有 2 类(BASE/BONUS),但 DWS 汇总层把 `room_*` 单独抽出 → 是**派生第 3 个统计维度**,不是配置数据维度。 #### D. dws_assistant_order_contribution — **没有 course_type 字段** 字段总览(14 列):`contribution_id, site_id, tenant_id, assistant_id, assistant_nickname, stat_date, order_gross_revenue, order_net_revenue, time_weighted_revenue, time_weighted_net_revenue, order_count, total_service_seconds, created_at, updated_at` > **没有按课程类型拆分**。该表是助教订单营收聚合,与课程分类无关。 #### E. cfg_area_category / dws_assistant_project_tag — **完全独立的"项目分类"体系** ```text cfg_area_category 6 行: BILLIARD/SNOOKER/MAHJONG/KTV/SPECIAL/OTHER dws_assistant_project_tag 实测 4 大项目: BILLIARD(158)/KTV(120)/MAHJONG(112)/SNOOKER(106) ``` > 这是**与课程类型平行的另一个分类体系**:从台桌名映射到台球/斯诺克/麻将/K歌/补时长/其他。 > 字段名 `category_code`,**不复用 course_type 字段**。 #### F. 全库扫描:**没有任何"二级嵌套课程表"** ```sql SELECT table_schema, table_name FROM information_schema.tables WHERE table_name ILIKE '%course%'; -- 结果: 0 行 ``` course_type 概念只通过 `cfg_skill_type.course_type_code` 这一个字段存在,**不存在父子层级、不存在 dim_course、不存在 cfg_course_*`**。 --- ### 1.2 后端层映射 #### A. ETL `apps/etl/connectors/feiqiu/tasks/dws/base_dws_task.py` ```python class CourseType(Enum): BASE = "BASE" BONUS = "BONUS" ROOM = "ROOM" # 包厢课 — 代码层定义,但数据库没有 ROOM 行,实际走默认 BASE 分支 def get_course_type(self, skill_id: int) -> CourseType: skill_config = config.skill_types.get(skill_id) if skill_config: code = skill_config.get('course_type_code', 'BASE') if code == 'BONUS': return CourseType.BONUS if code == 'ROOM': return CourseType.ROOM # 永不命中 return CourseType.BASE return CourseType.BASE ``` > **关键**:`CourseType.ROOM` 是代码层枚举,但 cfg_skill_type 里没有 `course_type_code='ROOM'` 的行。 > 所有"包厢"相关的 skill 实际返回 `CourseType.BASE`。 #### B. ETL `apps/etl/connectors/feiqiu/tasks/dws/assistant_daily_task.py:313-321` ```python course_type = self.get_course_type(skill_id) if skill_id else CourseType.BASE is_base = course_type == CourseType.BASE is_bonus = course_type == CourseType.BONUS is_room = course_type == CourseType.ROOM # 永远 False ``` > 但是 daily/monthly 表却有 `base/bonus/room` 三列。**这说明 room_* 三列实测一直是 0**(或被其他逻辑填充)。 > 需进一步用 SQL 验证(本次未跑该样本数据 SELECT,留作下一步)。 > **暂判定**:`room_*` 三列在配置正确的情况下应为 0,实际"包厢服务"全部累加进 `base_*` 三列。 #### C. ETL `apps/etl/connectors/feiqiu/tasks/dws/assistant_salary_task.py:31-36` 工资计算公式: ```python # 基础课收入 = base_hours × (base_price - base_deduction) # 附加课收入 = bonus_hours × bonus_price × (1 - bonus_deduction_ratio) # 包厢课收入 = room_hours × (room_course_price - base_deduction) # room_course_price=138 配置 total_course_income = base_income + bonus_income + room_income ``` > 工资公式**显式按 3 类计算**。但因 `room_hours` 在数据流中始终为 0,实际只有 base+bonus 两路。 > 这是**代码层为未来留接口**,但当前数据流上**等价于两类**。 #### D. ETL `coach_area_hours_task.py:36` ```python "包厢课": "room", ``` 唯一一处把"包厢课" skill_name 显式映射到 `room` 标签的地方(用于 area_hours 统计)。 #### E. 后端 `apps/backend/app/routers/xcx_*` 没有发现 `course_type` 直接字段返回 - `xcx_board.py` 中 BOARD-1 助教看板返回字段不包含课程类型(返回 perf/salary/sv/task 四维度数据) - `apps/miniprogram/miniprogram/services/api.ts:221` 有 `courseType: string`,这是后端 PERF-2 接口返回(performance-records / coach-service-records 用的同一接口形式) > **真实 API 响应里 courseType 直接是中文字符串**(基础课/包厢课/激励课),**不是枚举代码**。 --- ### 1.3 前端层使用 #### 三个页面的课程标签映射(完全一致) `performance.ts:17-23` / `performance-records.ts:18-24` / `coach-service-records.ts:20-26`,COURSE_TAG_MAP 定义: ```typescript const COURSE_TAG_MAP: Record = { '陪打': 'basic', '基础课': 'basic', '包厢': 'room', '包厢课': 'room', '超休': 'incentive', '激励课': 'incentive', '打赏课': 'incentive', } function courseTagClass(courseType: string): string { return COURSE_TAG_MAP[courseType] || 'basic' } ``` WXSS:三个页面都定义 `.course-tag--basic` / `.course-tag--room` / `.course-tag--incentive` 三个 CSS 类(performance.wxss:699-712 等)。 > **前端 3 类:basic / room / incentive**,与后端 ETL 的 BASE / BONUS / ROOM 一一对应: | 后端 CourseType | 中文(API courseType) | 前端 CSS class | |---|---|---| | BASE | 陪打 / 基础课 | basic | | ROOM(代码层未命中) | 包厢 / 包厢课 | room | | BONUS | 超休 / 激励课 / 打赏课 | incentive | #### 不一致点:service-record-card 组件的 typeClass `service-record-card.ts:13-16`: ```typescript /** 课程标签文字,如 "基础课" "包厢课" "打赏课" */ courseLabel: { type: String, value: '' }, /** 课程样式 class 后缀:basic / vip / tip / recharge */ typeClass: { type: String, value: 'basic' }, ``` > **此组件用 `vip/tip/recharge`,而非 `room/incentive`**。被 task-detail / customer-service-records 引用。 > 这是**第三套 CSS 命名(并非配置/统计维度差异),只是组件局部 class)**,会和上面三页的 `room/incentive` 形成视觉割裂。 #### board-finance.wxml:101 还有: ```xml { value: 'vip', text: '台球包厢' } ``` > 这里 vip 是**区域筛选枚举**(area filter),不是课程标签 — 与 service-record-card 的 vip 含义不同,容易混淆。 --- ### 1.4 结论:两套 / 两级 / 同源 **答案:A. 两套独立体系 + B. 两级隐含口径,但 NOT 严格父子嵌套。** 具体拆分: | 体系 | 来源 | 字段 | 取值 | 用途 | |---|---|---|---|---| | **课程类型(course_type)** | `cfg_skill_type` | `course_type_code` | BASE / BONUS(数据库实际只有这 2 个) | 助教薪资 / 服务次数 / 工时统计 | | **项目分类(category_code)** | `cfg_area_category` | `category_code` | BILLIARD / SNOOKER / MAHJONG / KTV / SPECIAL / OTHER | 区域偏好 / 项目标签 / 客户分群 / 财务分区 | **两个体系完全独立**,通过 `dwd_assistant_service_log.skill_id` 的不同关联路径分别落到不同 DWS 表: - `skill_id` → `cfg_skill_type` → `course_type_code` → 服务次数三分类 - `dim_table.area_name` → `cfg_area_category` → `category_code` → 项目标签 **额外的"派生第三类":** - 在 `dws_assistant_daily_detail` / `dws_assistant_monthly_summary` 中,**包厢被从 BASE 中拆出来**,形成 `base_* / bonus_* / room_*` 三列。 - 但**配置数据(cfg_skill_type)里没有 ROOM 这个 course_type_code**,所以代码层 `is_room = course_type == CourseType.ROOM` **永远为 False**,实际 room_* 三列**一直是 0 或失效**。 - 这是**代码层到配置层的脱节**,需要 Neo 决定: - **方案甲**:补一行 `cfg_skill_type` 让"包厢服务/包厢课"的 course_type_code 改为 `ROOM` → 启用三分类 - **方案乙**:接受现状,**前端把 room/包厢标签合并到 basic**,删除 `course-tag--room` CSS,workspaceWidth 仍按 138 元/小时计费 - **方案丙**:改前端文案,让"包厢"在 UI 上显示为基础课分支(让用户感知两类即可) **"看似两级"的错觉来源**: - 工资计算公式确实有 3 个变量:`base_income / bonus_income / room_income`,但 room_income 实际为 0(因 `room_hours` 为 0) - 前端 3 个 CSS class:basic/room/incentive 配合 3 种颜色,看起来像"两级" - BD 手册 `BD_manual_cfg_skill_type.md` 写了 ROOM 枚举,但**与生产数据冲突** --- ### 1.5 修正后的 P2-4 推荐选项 > 原 P2-4 推荐方案(假设)调整方向: #### 推荐:方案 B(对齐当前生产现状) 1. **数据层不动**:`cfg_skill_type` 保持 BASE/BONUS 两类(尊重现状) 2. **代码层清理**: - 移除 `CourseType.ROOM` 枚举(或注释为 deprecated) - 删除 `room_course_price` 配置,所有包厢服务统一走 base 价格(`cfg_assistant_level_price.base_course_price`) - 但**注意**:目前包厢统一 138 元 = 星级 base 价,初级/中级助教做包厢按各自 base 价 → **会改变工资**,**需求需 Neo 决策** 3. **DWS 表瘦身**: - `dws_assistant_daily_detail` / `monthly_summary` 删除 `room_*` 三列(或保留为兼容字段一直填 0) 4. **前端三页统一**:删除 `course-tag--room` CSS 与 COURSE_TAG_MAP 中的"包厢/包厢课" key,统一映射到 basic 5. **service-record-card 命名清理**:把 `vip/tip` 重命名为 `basic/incentive`,与三页一致 6. **BD 手册修正**:`BD_manual_cfg_skill_type.md` 删除 ROOM 枚举说明 #### 备选:方案 A(启用 ROOM 三分类,贴合飞球原始数据) 1. **数据层补 cfg_skill_type 行**:把 skill_id=2807440316432198(包厢服务)和 3039912271463941(包厢课)改为 `ROOM` 2. **配置层补 cfg_assistant_level_price**:增加 `room_course_price` 字段(可选,目前用代码配置) 3. **代码层不动**(已有 ROOM 分支) 4. **前端不动**(已有 room CSS) 5. **影响**: - 历史 DWS 数据需要回填(2026-03-24 之前所有"包厢"被错误标 BASE) - 工资重算口径:包厢按 138 元统一 vs 各等级 base 价的差异 #### 我建议:**方案 B**(不动数据,清理代码与前端冗余),原因: - 当前生产数据稳定且符合用户感知("包厢就是基础课的一种") - 改 cfg_skill_type 会触发 ETL 全量回算,风险大于收益 - 命名一致性问题(service-record-card vip/tip)是更迫切的真问题 --- ## 二、P2-7 看板切换限制全调研 ### 2.1 board-coach 全部组合矩阵 #### TIME_OPTIONS(6 项) × SORT_OPTIONS(6 项)= 36 个组合 | time \ sort | perf_desc | perf_asc | salary_desc | salary_asc | sv_desc | task_desc | |---|---|---|---|---|---|---| | month | OK | OK | OK | OK | OK | OK | | quarter | OK | OK | OK | OK | OK | OK | | last_month | OK | OK | OK | OK | OK | OK | | last_3m | OK | OK | OK | OK | OK | OK | | last_quarter | OK | OK | OK | OK | OK | OK | | **last_6m** | OK | OK | OK | OK | **400 报错** | OK | **唯一非法组合**:`time=last_6m + sort=sv_desc` → 后端 `board_service.py:258-262` 抛 `HTTPException(400, "最近6个月不支持客源储值排序")`。 #### SKILL_OPTIONS(5 项)× 上述 6×6:无任何额外组合限制 #### 现状(双向都没拦截) - **前端**:TIME_OPTIONS 第 6 项的 `text` 为 `"最近6个月(不含本月,不支持客源储值最高)"`,**仅文字提示**,filter-dropdown 没有任何 disabled 状态 - **后端**:仅服务端 if 校验 → 返回 HTTP 400 - **用户体验路径**:用户先选 sv_desc(成功),再切到 last_6m(切换瞬间触发 loadData → 后端报错 → 前端 toast/console.error) ### 2.2 board-finance 类似问题(扫描结果) #### TIME_OPTIONS:8 项 `month / lastMonth / week / lastWeek / quarter3 / quarter / lastQuarter / half6` #### AreaFilterEnum:9 项 `all / hall / hallA / hallB / hallC / vip / snooker / mahjong / ktv` #### **隐式组合限制(后端 `board_service.py:707-760`)** `area ≠ all` 时,**后端直接把 3 个板块返回 null**: | 板块 | area=all | area≠all | |---|---|---| | overview(经营一览) | 完整 | 完整 | | recharge(预收资产) | 完整 | **null** | | revenue(应计收入) | 完整 | 完整(部分子项不同) | | cashflow(现金流入) | 完整 | **null** | | expense(现金流出) | 完整 | **null** | | coach_analysis(助教分析) | 完整 | 完整 | > **前端没有任何提示**,也没有把 area 选项灰掉。用户切到"台球包厢"等区域 → 页面 3 个板块凭空消失,用户无法理解为什么。 #### **额外限制:`isCurrentMonthFilter` 函数(line 16-18)** ```typescript function isCurrentMonthFilter(selectedTime: string): boolean { return selectedTime === 'month' && new Date().getDate() <= 5 } ``` > 月初 5 号前选"本月"会有特殊提示,但不阻塞操作。属于隐性约束。 ### 2.3 board-customer 类似问题(扫描结果) - DIMENSION_OPTIONS:8 项(recall/potential/balance/recharge/recent/spend60/freq60/loyal) - PROJECT_OPTIONS:5 项(ALL/BILLIARD/SNOOKER/MAHJONG/KTV) - **未扫到任何组合限制**(代码 grep 无 disabled / 不支持 / HTTPException 命中) - 后端 `board_service.py:584-640` 也只有 `if not query_fn_name: raise 400`(参数本身非法) > board-customer 暂无组合限制问题。 ### 2.4 修正后的 P2-7 实施清单 #### 修正核心:Neo 要求"双向禁止/变灰" > 原选项 B(只在 sv_desc 选中时禁用 last_6m)需要扩展为: > - 选 last_6m → sv_desc 选项变灰禁选 > - 选 sv_desc → last_6m 选项变灰禁选 #### 改动点 1:board-coach 双向禁用(P2-7 主要诉求) **文件**:`apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts` + `.wxml` **ts 改动**(SORT_OPTIONS / TIME_OPTIONS 加 `disabled` 字段,onSortChange / onTimeChange 联动重算): ```typescript const SORT_OPTIONS = [ { value: 'perf_desc', text: '定档业绩最高' }, { value: 'perf_asc', text: '定档业绩最低' }, { value: 'salary_desc', text: '工资最高' }, { value: 'salary_asc', text: '工资最低' }, { value: 'sv_desc', text: '客源储值最高', disabledWhen: ['last_6m'] }, // 新增 { value: 'task_desc', text: '任务完成最多' }, ] const TIME_OPTIONS = [ ... { value: 'last_6m', text: '最近6个月(不含本月)', disabledWhen: ['sv_desc'] }, // 提示文字简化 ] // 联动:onSortChange / onTimeChange 触发后,recompute disabled 标记并 setData // 如果当前组合已经非法(用户先选 sv,再点 last_6m),应阻止切换并 toast ``` **wxml 改动**:filter-dropdown 组件需支持 `disabled` 数组 prop,被禁选项渲染为灰色不可点。 **filter-dropdown 组件改动**:增加 `disabledValues: string[]` prop,内部渲染时给禁用项加 `option--disabled` 样式并阻止 tap。 #### 改动点 2:board-finance 隐式 null 板块的可见性提示(扫描发现) **问题**:area≠all 时,recharge/cashflow/expense 三个板块凭空消失,无任何提示。 **改动方案两选一**: - **方案 a(强提示)**:在 board-finance.wxml 每个板块外层加 `wx:if="{{recharge}}"`,并在板块位置渲染占位 `view`:`"该指标暂不支持按区域拆分,请切换到全部区域查看"` - **方案 b(过滤源头)**:在 area 选项 dropdown 上,把"非 all"选项加副标题"(部分指标仅全部区域可见)" > 我建议方案 a。零 UI 状态变化(三板块直接变成提示卡片),用户不会困惑。 #### 改动点 3:service-record-card 命名一致性(P2-4 调研副产物) **改动文件**: - `apps/miniprogram/miniprogram/components/service-record-card/service-record-card.ts` 注释 - `apps/miniprogram/miniprogram/components/service-record-card/service-record-card.wxss` 类名 - 引用方:`apps/miniprogram/miniprogram/pages/customer-service-records/`、`apps/miniprogram/miniprogram/pages/task-detail/` 把 `vip/tip` 重命名为 `room/incentive`,与三个 -records 页面统一。 #### 改动点 4:文档对齐 - `BD_manual_cfg_skill_type.md`:删除 ROOM 枚举说明(或加注"仅代码层保留") - `04c-conflicts-P2-detail.md`:补充本调研结论 --- ## 三、给 Neo 的决策清单 ### P2-4 课程体系 1. [ ] **方案选择**:A(启用 ROOM 三分类,改 cfg_skill_type) / **B(代码清理,合并到 BASE)** / 维持现状不动 2. [ ] **service-record-card 命名**:是否同步把 vip/tip 重命名为 room/incentive 3. [ ] **room_course_price 138 元配置去留**:工资计算时,星级以下助教做包厢应按"星级 base 价(138)" 还是"自身等级 base 价"?(决策依据是真实业务) ### P2-7 看板切换限制(全看板共发现 2 个组合限制问题) 1. [ ] **board-coach 双向禁用**:确认按"改动点 1"方案实施(filter-dropdown 加 disabledValues prop + 联动 ts) 2. [ ] **board-finance area≠all 三板块消失问题**: - 是否同样修复?(方案 a 占位提示 / 方案 b 选项副标题) - 还是接受现状(area≠all 是高级用法,暂不优化) 3. [ ] **board-customer**:本次未发现组合限制问题,无需修复 4. [ ] **后端 422 校验补强**:目前 board-coach 是 HTTP 400(detail 中文),是否升级为 422 + 标准化错误码? 5. [ ] **可访问性兜底**:如果用户用直链/分享 URL 进入非法组合页面,前端是否需在 onLoad 兜底重置一个合法默认值? ### 调研副产物(本轮发现的其他问题) 1. [ ] cfg_skill_type 数据库实际无 ROOM 行,但 BD 手册写了 ROOM 枚举 → 文档过期 2. [ ] dws_assistant_daily_detail / monthly_summary 的 room_* 三列实际可能恒为 0 → 需 SQL 验证 3. [ ] dws_assistant_order_contribution 表完全没有 course_type 字段,与文档预期不符 → 与 P2-4 关联