# 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<string, string> = {
  '陪打': '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 关联
