chore(docs): Wave 0 调研产出 + P0/P1/P2 反馈调研

建立项目级标杆文档 docs/_overview/ 作为产品全景索引,
解决"PRD 零碎、文档膨胀、跨子系统调研无入口"的问题。

主要内容:
- 00-index 总索引 + 维护协议 + 与 CLAUDE.md 关系
- 01-product-overview 产品全景脑图(6 角色 / 6 子系统 / 数据流 /
  7 业务概念 / 8+1 AI 矩阵 / 22 术语)
- 02a-miniprogram-page-matrix 小程序 21 页业务指纹
- 02b-adminweb-page-matrix admin-web 19 路由业务指纹
- 03-test-spec 测试规范 (L1-L5 分层 + 走查模板 + 75-95 case 估算)
- 04-doc-conflicts 39 条冲突索引(P0×8 / P1×13 / P2×13 + 5 子项)
- 04a/b/c-conflicts-*-detail 业务故事卡(7 字段:关联/逻辑/影响/选项/判定)
- 05-orphan-pages-cleanup admin-web 6 孤儿页面处置(1 归档 + 4 保留)
- WAVES-MASTER-PLAN.md 全 Wave 主计划(0-5,共 22-32 工作日)
- WAVE-1-KICKOFF.md Wave 1 实施 kickoff
- GLOBAL-DECISION-DASHBOARD.md 全局决策仪表板

反馈调研产物:
- 04a-feedback/ P0 两轮反馈(8+8 项决策 + D-1/2/3 + F-1/2 子代理产出)
- 04b-feedback/ P1 两轮反馈(13+1+5 项 + E-1/2/3/4 + G-1/2 子代理产出)
- 04c-feedback/ P2 反馈(13 项 + 5 子项 + H-1/2/3 子代理产出)
- NEO-DECISIONS-LOG 累积决策记录

关键追加发现 8 处 D Bug(原蓝本 0):
- P0-3 看板沙箱接入(Wave 1 W1-T1)
- P0-5 致命 1 (4 处 fdw_etl 残留, 已修 commit 17f045a)
- P0-5 致命 2 (JWT aud 缺失, 已修 commit 17f045a)
- P0-6 clearAllTasks 守卫 (Wave 3)
- P0-8 DBViewer 黑名单漏 (已修 commit 17f045a)
- P1-3 task-detail 跳转传 task_id 而非 customer_id
- P2-7 board-finance 隐式 null
- 2 个独立 Bug (page_context.created_at + ClueCategory 字典)

参考: docs/_overview/00-index.md
This commit is contained in:
Neo
2026-05-04 07:38:28 +08:00
parent c6453829a6
commit 509cf43284
44 changed files with 10789 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
# P2 反馈响应总报告
> 日期:2026-05-04 / 触发:Neo 在 04c-conflicts-P2-detail.md 13 条 P2 + 5 子项 上写下斜体反馈
> 主线 + 2 个子代理(H-1 课程体系+看板限制 / H-2 prompt 答疑+无权限页设计) / 状态:**调研完成,等 Neo 拍板进入实施**
## 一、13 项 P2 + 5 子项 处理状态总览
| # | Neo 反馈 | 处理 | 关键结论 |
|---|---|---|---|
| **P2-1** | 选项 A | 直接确认 | tabBar 旧文档同步为"动态过滤" |
| **P2-2** | 选项 A | 直接确认 | 维客线索 tag 改枚举 + label 双字段 |
| **P2-3** | 选项 A | 直接确认 | 维客线索 source 改枚举 + recorded_by_name |
| **P2-4** | 要再调研 | [P2-4-and-P2-7-research.md](P2-4-and-P2-7-research.md) | **A 两套独立体系**;无两级嵌套;ROOM 是死代码 |
| **P2-5** | 选项 A | 直接确认 | ChatMessage timestamp → createdAt |
| **P2-6** | 选 A + 问 prompt | [P2-6-and-P2-9-design.md](P2-6-and-P2-9-design.md) | **不改 APP1 prompt**;推荐 R2 SQL `LEFT(content,16)` 写 title;`title` 字段已存在未写入 |
| **P2-7** | 选 B + 调研其他限制 | [P2-4-and-P2-7-research.md](P2-4-and-P2-7-research.md) | **新发现 board-finance 隐式 null Bug**(area≠all 三板块凭空消失);board-customer 无问题 |
| **P2-8** | 选项 B(改原 A→B) | 直接确认 | site_admin/tenant_admin **不混入** dev-tools |
| **P2-9** | 选 A + 设计 + 实施 + 测试 | [P2-6-and-P2-9-design.md](P2-6-and-P2-9-design.md) | 表名 `biz.site_contact_info` / 3 端点 / Wave 5 全部 1.7 人天 |
| **P2-10** | 选项 B | 直接确认 | Pydantic Field alias 显式写 |
| **P2-11** | 选项 A | 直接确认 | AI 需求 2 表头 6→8 |
| **P2-12** | 同意建议 | 直接确认 | NS4 加职责边界节 |
| **P2-13.1** | A | 直接确认 | NS3 加 72 组合二分逻辑节 |
| **P2-13.2** | **B**(改 A→B) | 直接确认 | `biz/cfg_event_types.py` 维护源 + 文档自动生成 |
| **P2-13.3** | A | 直接确认 | 接受双入口现状 + PRD 解释 |
| **P2-13.4** | 同意建议 | 直接确认 | 校对 auth.py 实际路径 + 同步 NS1 |
| **P2-13.5** | A | 直接确认 | getSelectedKeys 改造支持多 group 展开 |
## 二、4 件 Neo 必须知道的事
### 1. P2-4 — 课程体系的"两套" + 死代码隐患
H-1 实测发现:
- **`course_type`(`cfg_skill_type`)只有 BASE / BONUS 两类**,**BD 手册说有 ROOM 但数据库实际无**(BD 手册过期)
- **`category_code`(`cfg_area_category`)6 类**(BILLIARD/SNOOKER/MAHJONG/KTV/SPECIAL/OTHER)
- 两套**完全独立**,不是两级嵌套
- **死代码隐患**:`CourseType.ROOM` 枚举 + DWS `room_*` 列均存在,但配置数据未提供 → **所有 ROOM 相关代码分支永不命中**
修正后 P2-4 选项:
- 推荐 **B 清理代码冗余**:决定 ROOM 是去掉(不需要)还是补配置(需要)
- service-record-card 组件用 `vip/tip` 与 COURSE_TAG_MAP `basic/room/incentive` 不一致,需统一
*category_code不是课程是项目分类。课程的调研不对不够深入课程全部应该有 包厢课 麻将课 基础课 激励课 但应该分布在不同的表中。继续深入调研。*
### 2. P2-7 — 新发现 board-finance 隐式 null Bug
H-1 全看板扫描:
- board-coach(已知):`time=last_6m + sort=sv_desc` 后端 400,前端无禁用提示 → 需双向变灰
- **board-finance(新发现)**:`area≠all` 时后端 **recharge / cashflow / expense 三板块返回 null**,前端无任何提示,**板块凭空消失** → 需补占位文案
- board-customer:无组合限制
**意外多挖出 1 个 D Bug(P2-7 board-finance 隐式 null)**,严重度比 P2-7 主体高(用户看不到原因)。
*`area≠all` 时后端 **recharge / cashflow / expense 三板块返回 null**,前端无任何提示是可以的。找到的BUG进行修复。*
### 3. P2-6 — 不改 APP1 prompt,因为 prompt 在百炼云端不入 git
H-2 调研:
- APP1 prompt 在百炼控制台云端,改动**不可追溯**(不在 git)
- SSE 流式纯文本末尾插结构化 title 会**破坏流式体验**
- **`biz.ai_conversations.title` 字段已存在但当前未写入**(意外发现)
- **`chat_service.generate_title()` 已有"客户姓名 / 首句 20 字 / 新对话"回退链**(意外发现 2)
**推荐 R2 起步**:Wave 5 用 SQL `LEFT(content, 16)` 在首轮结束时写 title。R1(qwen-turbo 异步轻量摘要)后续观察再决定。
*APP1 prompt 在百炼控制台云端,改动不可追溯但如果有改动的必要我可以手动操作。并且现在应该有路径保存了当前所有Prompt。现在用你的推荐方案是可行的暂时不改Prompt我同意。但要记录后续观察的需求需要跟踪。*
### 4. P2-9 — no-permission 编辑入口设计完成,Wave 5 实施 1.7 人天
H-2 设计:
- **表名**:`biz.site_contact_info` PK=site_id
- **字段**:display_name / phone / wechat_id / notes / updated_by / updated_at
- **默认值**:`display_name = '门店管理员'`,**不预填真名**(让 Neo 上线后手工配置)
- **3 端点**:
- `GET /api/tenant/site-contact`(tenant 端读)
- `PATCH /api/tenant/site-contact`(tenant 端改)
- `GET /api/xcx/site-contact`(小程序 no-permission 拉)
- **小程序从 token 推 site_id**,前端不传(防伪造)
- **Wave 5 全部 1.7 人天**(后端 + tenant-admin + 小程序 + 测试)
H-2 提的 7 个审稿题(表名 / 拦真名黑名单 / 刷新时机 / 菜单文字 / 自动建行 / 审计 / R2-R1) 待 Neo 答。
*同意。*
## 三、按 Wave 重新分配的 P2 执行清单
| Wave | 任务 |
|---|---|
| **Wave 1-3 / 即时** | board-finance 隐式 null Bug(P2-7 副发现,Wave 1 协同) |
| **Wave 5** | **全部 P2 主体** — 13 项主条目 + 5 子项,统一文档收尾 + 小代码改动批 |
## 四、给 Neo 的 P2 决策提问
| # | 问题 | 我的建议 |
|---|---|---|
| 1 | P2-4 ROOM 死代码处理:去掉 / 补配置 / 维持现状 | **去掉**(BD 手册更新为 BASE+BONUS 两类) |
| 2 | P2-4 service-record-card 组件命名是否同步改 `vip/tip → vip-tag/tip-tag`? | Y(统一命名) |
| 3 | P2-7 board-finance 隐式 null Bug 是否纳入 D Bug 清单 | **Y(D Bug)** |
| 4 | P2-9 H-2 提的 7 个审稿题(表名/拦真名/菜单文字/审计...)是否一次答完 | Y |
| 5 | P2-6 接受 R2 起步(SQL LEFT 16 字)+ R1 后续观察 | Y |
## 五、产出文件索引
```
docs/_overview/04c-feedback/
├── 00-P2-feedback-response-summary.md (本文)
├── P2-4-and-P2-7-research.md (H-1)
└── P2-6-and-P2-9-design.md (H-2)
```
---
> 全部 P0(2 轮)+ P1(2 轮)+ P2 反馈调研完成。下一步:Neo 答 P2 5 问 + P1 二轮 5 问 + H-2 的 7 个审稿题。

View File

@@ -0,0 +1,425 @@
# 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 关联

View File

@@ -0,0 +1,278 @@
# P2-4 课程体系深度调研(第二轮)
> 日期:2026-05-04
> 触发:Neo 否决 H-1 调研结论(`P2-4-and-P2-7-research.md`):"课程的调研不对,不够深入,课程全部应该有 包厢课 麻将课 基础课 激励课 但应该分布在不同的表中"
> 范围:**重新调研课程体系**,把上游 SaaS 真实 skill、配置表、DWD 明细、DWS 派生列、ETL 代码、前端文案统一对齐
> 数据库:`test_etl_feiqiu`(只读 SELECT)
> 替代关系:**本文件取代 `P2-4-and-P2-7-research.md` 中的 P2-4 部分。P2-7 部分仍以原文件为准。**
---
## 一、Neo 提示的关键(逐字引用)
> "category_code 不是课程,是项目分类。课程的调研不对,不够深入,课程全部应该有 **包厢课 麻将课 基础课 激励课** 但应该分布在不同的表中。"
解读:
1. **课程 ≠ 场地分类(`category_code`)**:H-1 把 `cfg_area_category` 当成课程候选,这条路是错的(它在 `category_code` 列里映射的是 `BILLIARD/SNOOKER/MAHJONG/KTV` 等"项目分类",描述台桌而非课程)。
2. **课程至少 4 类**:基础课 / 包厢课 / 麻将课 / 激励课。
3. **分布在不同表中**:不是一张表的 4 行,是多张表的不同列、不同模块各自表达一部分。
---
## 二、表搜索全集(只读,2026-05-04)
### 2.1 schema 全表名搜索结果(命中 course/lesson/class/room/mahjong/incentive/bonus/skill)
| schema | table | 命中关键字 |
|--------|-------|-----------|
| dws | `cfg_skill_type` | skill |
| dws | `cfg_bonus_rules` | bonus |
| app | `v_cfg_bonus_rules` | bonus(RLS 视图) |
注意:**没有任何表名以 `cfg_course_*``dim_course_*``mahjong_*``incentive_*``lesson_*` 命名**。也就是说"课程"在 NeoZQYY 中**不是一个独立的实体**,而是依附在助教服务记录上的一个**类型属性**。
### 2.2 含课程相关字段的表(course/lesson/class_type/room_type/mahjong/bonus_type/incentive)
| 模块 | 表 | 字段 | 用途 |
|------|---|------|------|
| 配置 | `dws.cfg_skill_type` | `skill_id`, `skill_name`, `course_type_code`(BASE/BONUS/ROOM), `course_type_name` | skill_id → 课程类型映射 |
| 配置 | `dws.cfg_assistant_level_price` | `base_course_price`, `bonus_course_price` | 等级 × 课程类型定价 |
| DWS 明细 | `dws.dws_assistant_daily_detail` | `base_*``bonus_*``room_*`(共 12 列:count/seconds/hours/ledger_amount × 3 类型) | 助教日级三维拆分 |
| DWS 工资 | `dws.dws_assistant_salary_calc` | `base_hours``bonus_hours``room_hours``base_income``bonus_income``room_income` | 工资按 3 类计算 |
| DWS 财务 | `dws.dws_assistant_finance_analysis` | `revenue_base``revenue_bonus``revenue_room``room_service_count``room_service_hours` | 助教营收按 3 类拆分 |
| DWS 关系 | `dws.dws_member_assistant_intimacy` | `basic_session_count``incentive_session_count` | 会员-助教亲密度按 2 类拆分 |
| DWS 关系 | `dws.dws_member_assistant_relation_index` | `basic_session_count``incentive_session_count` | 关系指数按 2 类拆分 |
| DWS 区域 | `dws.dws_coach_area_hours` | `base_hours``bonus_hours``room_hours``*_service_count` | 助教区域工时按 3 类 |
| DWD 明细 | `dwd.dwd_assistant_service_log` | `skill_id`, `skill_name`(原始字符串) | 单笔服务原始 skill |
| ODS 上游 | `ods.assistant_service_records` | `skill_id`, `skillname`(飞球 SaaS 原始字段) | 来自飞球 API |
---
## 三、4 类课程的实际分布
> **核心结论**:NeoZQYY 中"课程"以三个不同抽象层、四个不同模块各自表达,不存在统一的课程表。
### 3.1 基础课(BASE) — 全链路一等公民
| 层 | 实例 |
|---|------|
| ODS | `assistant_service_records.skillname='基础课'`,9 095 条有效订单(2025-09-16 ~ 2026-04-23) |
| 配置 | `cfg_skill_type` 映射 `skill_id ∈ {2790683529513797(基础课), 2791903611396869(台球基础陪打)}``course_type_code='BASE'`(`course_type_name='基础课'`) |
| 代码 | `base_dws_task.py::CourseType.BASE` |
| DWS 列 | `dws_assistant_daily_detail.base_service_count/base_hours/base_seconds/base_ledger_amount`(已写入,2026 年 1-4 月有数据) |
| 工资 | `dws_assistant_salary_calc.base_hours / base_income`(按 `cfg_assistant_level_price.base_course_price` × 等级 计价,初/中/高/星 = 98/108/118/138 元) |
| 关系指数 | `dws_member_assistant_intimacy.basic_session_count`(注意:这里是 `basic` 不是 `base`,命名不一致) |
| 前端文案 | "基础课"(performance/coach-detail/task-list/customer-detail) |
| 别名 | "陪打"、"PD"、`assistant_pd_money` |
### 3.2 包厢课(ROOM) — 设计存在但**实际未生效**
| 层 | 实例 |
|---|------|
| ODS | `skillname='包厢课'`,**174 条有效订单**(2026-01-06 ~ 2026-04-26) |
| 配置 | `cfg_skill_type` 中"包厢课"(`skill_id=3039912271463941`)和"包厢服务"(`skill_id=2807440316432198`)**被映射为 `course_type_code='BASE'`**(注释说"包厢服务:归入基础课统计,统一按 138 元/小时计价";"包厢课:与'包厢服务'同类") |
| 代码 | `base_dws_task.py::CourseType.ROOM` 枚举存在;`get_course_type()``if code == 'ROOM': return CourseType.ROOM` 分支存在,**但永不命中**(因配置表无 ROOM 行) |
| DWS 列 | `dws_assistant_daily_detail.room_service_count/room_hours/room_seconds/room_ledger_amount` 列存在,**全 0**(2942 行均为 0) |
| 工资 | `dws_assistant_salary_calc.room_hours / room_income` 列存在,**全 0**(10 个月全 0) |
| 财务 | `dws_assistant_finance_analysis.revenue_room` SQL 中 `WHEN ... = 'ROOM'` 分支存在,**永不命中** |
| 现状 | 174 条飞球原始包厢课订单**实际被合并进 BASE**(因为 cfg_skill_type 把它们都标成 BASE) |
| 文档 | `CLAUDE.md` 写"包厢课统一 138 元/小时(`dws.salary.room_course_price`)";`assistant_finance_task.py:146` 注释写 ROOM 分类 |
**这是 H-1 漏掉的最关键发现**:**ROOM 是设计预留 + 死代码组合**,不是 H-1 说的"纯死代码可移除"。
174 条真实包厢订单存在,Neo 心目中"包厢课要独立"是合理需求,只是配置表没把它们标出来。
### 3.3 麻将课(MAHJONG) — **数据库与代码层完全空白**
| 层 | 实例 |
|---|------|
| ODS | `assistant_service_records.skillname` 中**没有"麻将课"**(只有"基础课"/"附加课"/"包厢课"/NULL 共 4 种) |
| 飞球上游 | 飞球 SaaS **没有"麻将"作为助教 skill** |
| 配置 | `cfg_skill_type` 没有 MAHJONG 行 |
| 代码 | `CourseType` 枚举**没有 MAHJONG**(只有 BASE/BONUS/ROOM) |
| DWS 列 | 没有 `mahjong_*` 列 |
| 文档 | 仅在 `cfg_area_category.category_code='MAHJONG'` 中出现(场地分类,**不是课程**) |
| 前端 | 没有"麻将课"文案 |
**结论**:"麻将课"在 NeoZQYY **完全不存在**。它是 Neo 在业务规划层提出的概念,**当前所有表、代码、配置、上游都没有对应的载体**。
可能的语义:店铺用麻将台桌时,助教提供的服务可能也叫"麻将课"。但飞球助教 skill 当前不区分这种,落到 ODS 时 `skillname` 仍是"基础课"。
### 3.4 激励课(INCENTIVE) — **实际就是 BONUS,有别名混乱**
| 层 | 实例 |
|---|------|
| ODS | `skillname='附加课'`,985 条有效订单 |
| 配置 | `cfg_skill_type` `skill_id ∈ {2790683529513798(附加课), 2807440316432197(台球超休服务)}``course_type_code='BONUS'`,`course_type_name='附加课'`,`description='附加课:超休/激励课,固定 190 元/小时'` |
| 代码 | `CourseType.BONUS`;`relation_index_task.py:268` `is_incentive = course_type == CourseType.BONUS` |
| DWS 列 | `bonus_service_count/bonus_hours/bonus_seconds/bonus_ledger_amount`(已写入,2 942 行中 574 行有 bonus) |
| 关系 | `incentive_session_count`(BD 手册写"附加课服务次数",但列名用 `incentive`) |
| 工资 | `bonus_income`(单价 190,`bonus_deduction_ratio` 35%~50%) |
| 前端文案 | **"激励课" / "超休" / "打赏课" 三种说法在前端都用** — `performance.ts``performance-records.ts` 中明确写 `'超休': 'incentive', '激励课': 'incentive', '打赏课': 'incentive'`(三个上游别名都映射到 `incentive` CSS 标签) |
| BD-DWD-DOC | 称为"助教激励课正价 / `assistant_cx_money`",规则 2 强制要求 `assistant_cx_money` 表示 BONUS |
| 别名总集 | "附加课"(配置/ODS) / "激励课"(前端文案/Neo 用语) / "超休"(用户口语) / "打赏课"(用户口语) / `BONUS`(代码枚举) / `cx`(SQL/费用字段) |
**结论**:"激励课"和"BONUS"和"附加课"和"超休"是**同一个东西**,只是叫法不一致。Neo 提的"激励课"不是新维度,只是更口语化的命名。
---
## 四、4 类是否真的"分布在不同表中"
回答 Neo 的核心判断:**部分正确,但分布的不是"4 类"而是"多种抽象组合"**。
| 类别 | 是否独立存在 | 在哪些表/列体现 |
|------|------------|----------------|
| 基础课(BASE) | 是,完整 | cfg_skill_type、dwd_assistant_service_log.skill_id、dws.*.base_*(daily/salary/finance/coach_area) + intimacy.basic_session_count |
| 激励课(BONUS) | 是,完整 | 同上替换为 bonus_*、intimacy.incentive_session_count |
| 包厢课(ROOM) | **半残**:DWD 有 174 条原始订单,DWS 列预留但全 0,代码 ROOM 分支永不命中 | dws.*.room_*(daily/salary/finance/coach_area)全 0;cfg_skill_type 把它们标成 BASE 而不是 ROOM |
| 麻将课(MAHJONG) | **不存在**:仅在台桌分类中有 MAHJONG 区(`cfg_area_category`),从未作为课程类型落库 | 没有任何相关列、配置、代码 |
所以"分布在不同表"只对前两类成立(基础课和激励课确实在 daily/salary/finance/intimacy/relation_index 多表多列各自展开)。
**包厢课是半成品,麻将课是空白**
---
## 五、与 H-1 调研对照(必须修正的错误)
H-1 报告(`P2-4-and-P2-7-research.md` 第 87-115 行)说:
| H-1 结论 | 实际情况 | 修正 |
|---------|---------|------|
| "cfg_skill_type 只有 2 行" | **6 行**(基础课/附加课各 3 个 skill_id 别名都已建好) | H-1 漏掉了 SKill_id 4/5/6(2026-03-24 通过 `add_missing_cfg_skill_type.sql` 补齐的"基础课/附加课/包厢课"原始 skill) |
| "ROOM 是死代码,所有 ROOM 分支永不命中" | 命中是真的不命中(配置无 ROOM 行),但**DWD 有 174 条真实包厢订单**正在被错误合并进 BASE | 不是"死代码可移除",而是"配置缺失导致包厢数据被吞" |
| "包厢课在 NeoZQYY 不需要" | Neo 明确要求"包厢课"独立 | 需要补齐(改 cfg_skill_type 把包厢类 skill 标成 ROOM) |
| 完全没提"麻将课" | Neo 提的 4 类之一 | 需在产品层决策"是否新增麻将课作为第 4 类" |
| 没区分"激励课 / 附加课 / 超休 / 打赏课"的别名问题 | 前端 4 套文案,后端 BONUS,配置"附加课",上游 SaaS"附加课/超休服务" | 应在"统一术语"层面收口 |
H-1 漏掉的 5 个关键点:
1. **174 条飞球包厢课订单已落 ODS / DWD,被错误归入 BASE**(因 cfg_skill_type 把它们标成 BASE)
2. **room_* 列在 4 张 DWS 表(daily/salary/finance/coach_area)同时预留,全 0**
3. **`assistant_finance_task.py:146` 写了 `WHEN ... = 'ROOM'` 的 SQL 分支**(永不命中,但代码意图清晰)
4. **关系指数模块用 `basic_session_count` / `incentive_session_count` 命名,不与 `base_*` / `bonus_*` 对齐**(同概念两套命名)
5. **前端文案 4 个别名("激励课"/"超休"/"打赏课"/"附加课")混用,没有统一术语表**
---
## 六、修正后的 P2-4 选项(给 Neo 决策)
### 选项 A:统一术语 + 启用 ROOM(最小改动,贴近现状)
1.`cfg_skill_type` 中"包厢课"和"包厢服务"两行的 `course_type_code``BASE` 改为 `ROOM`
2. 重跑 dws_assistant_daily_detail / salary_calc / finance_analysis / coach_area_hours,让 174 条订单进入 `room_*`
3. 决定 `room_course_price` 真实值(目前 CLAUDE.md 写 138 元/小时,但 cfg_assistant_level_price 没有 room_price 列 — 需要补字段或借用现有规则)
4. 统一前端"激励课"术语:performance.ts 已经做了别名映射,但其他页面要全检
5. **不引入麻将课**
适用场景:Neo 只是想看到包厢课独立,不想全面铺开。
代价:1 次 cfg_skill_type 数据修改 + 1 次回填 + 前端术语清理 + 价格字段补齐。
### 选项 B:课程类型完整化(4 类全开,新增 MAHJONG)
1. `cfg_skill_type.course_type_code` CHECK 约束扩展支持 `BASE/BONUS/ROOM/MAHJONG`
2. `dws_assistant_daily_detail` / `salary_calc` / `coach_area_hours` 增加 `mahjong_*` 列(count/seconds/hours/ledger_amount)
3. `cfg_assistant_level_price` 增加 `room_course_price``mahjong_course_price` 两列
4. 关系指数表增加 `room_session_count``mahjong_session_count`(以及把 `basic_session_count` 重命名为 `base_session_count` 对齐)
5. ETL 4 任务全部改写
6. 飞球上游目前没有"麻将课" skill 数据 — 需要店铺侧手动在飞球后台新建 skill,或在 NeoZQYY 侧用台桌区域(area_category=MAHJONG)反推
7. 前端 task-list / performance / coach-detail / customer-detail / board-coach 全部增加第 3、4 类展示
适用场景:Neo 真的要按 4 类做长期产品规划。
代价:大改动,4 张 DWS 表 schema + 5 个 ETL 任务 + 前端多页 + 飞球上游配合。
### 选项 C:三层澄清(暂不增数据列,先消除认知冲突)
1. **不动 schema**,只产出权威术语对照表归档 `docs/_overview/04c-glossary-courses.md`
2. 明确:"激励课 = 附加课 = 超休 = 打赏课 = BONUS"是同一概念
3. 明确:"包厢课"目前合并在 BASE,需要后续单独建模(选项 A)
4. 明确:"麻将课"目前不存在,需要后续业务决策(选项 B 或不做)
5. 把现有 `room_*` 列 + `CourseType.ROOM` 枚举 + `mahjong_*` 概念 全部标记为"预留,未启用"
6. 修正 BD 手册中"附加课"/"激励课"不一致的注释
适用场景:Neo 只是想确认"我提的 4 类系统当前实际分布",不一定要立刻改实现。
代价:零代码改动,只写文档。
---
## 七、给 Neo 的决策清单
| 编号 | 决策点 | 选项 | 影响范围 |
|-----|--------|------|---------|
| D1 | "包厢课"是否独立成第 3 类 | (a)合并 BASE 不变 (b)启用 ROOM(改 cfg_skill_type+回填) (c)不动 schema 只标记 | DWS 4 表回填 + 前端展示 |
| D2 | "麻将课"是否新增为第 4 类 | (a)不引入 (b)新增 MAHJONG(大改) | DWS 列新增 + 前端 + 飞球上游配合 |
| D3 | "激励课/附加课/超休/打赏课" 是否统一术语 | (a)前端统一为"激励课" (b)统一为"附加课" (c)保持现状 | 前端 5+ 页面 + BD 手册 + 后端 schema 注释 |
| D4 | `room_*` / `CourseType.ROOM` 当前状态 | (a)启用并回填 (b)删除并清理 (c)保留预留并文档化 | 4 张 DWS 表 + ETL 4 任务 + 后端服务 |
| D5 | 关系指数模块 `basic_session_count` vs DWS 通用 `base_*` 命名不一致 | (a)统一命名(改列名) (b)在文档说明同义 (c)保持现状 | 关系表迁移 + 后端 schema |
| D6 | `cfg_assistant_level_price.room_course_price` 缺失 | (a)增加列 (b)用单一常量 138 写死 (c)等 D1 决策后再处理 | cfg_assistant_level_price 表结构 |
---
## 八、命名一致性问题(派生发现)
| 概念 | DWS 主流命名 | 关系指数命名 | 前端 CSS | 后端字段 | 上游 |
|-----|------------|-------------|---------|---------|------|
| 基础课 | `base_*` | `basic_session_count` | `course-tag--base` | `assistant_pd_money`(PD) | "基础课"/"台球基础陪打" |
| 激励课 | `bonus_*` | `incentive_session_count` | `course-tag--incentive` | `assistant_cx_money`(CX) | "附加课"/"台球超休服务" |
| 包厢课 | `room_*`(全 0) | 无 | 无 | 无 | "包厢课"/"包厢服务" |
| 麻将课 | 无 | 无 | 无 | 无 | 无 |
5 套命名混用是高熵问题,长期容易踩坑。建议在 D3/D5 决策时一并收口。
---
## 九、实际 ODS / DWD 课程数据样本(2026-05-04 测试库,脱敏)
ODS 上游 `assistant_service_records` skillname 实际分布(有效订单,`is_trash=0 AND is_delete=0`):
| skillname | 订单数 | 首次日期 | 最近日期 |
|-----------|--------|----------|----------|
| 基础课 | 9 095 | 2025-09-16 | 2026-04-23 |
| (NULL) | 7 382 | 2025-07-21 | 2025-11-30 |
| 附加课 | 985 | 2025-09-18 | 2026-04-18 |
| 包厢课 | 174 | 2026-01-06 | 2026-04-26 |
注意:NULL 7 382 条主要在 2025-07~11 期间,2025-12 起 skillname 全部填充。说明飞球 SaaS 在 2025-12 之后才补全 skillname 字段 — 这是另一个数据质量问题。
cfg_skill_type 全量(6 行):
| id | skill_id | skill_name | course_type_code | description |
|---|----------|-----------|------------------|-------------|
| 1 | 2791903611396869 | 台球基础陪打 | BASE | 基础课:陪打服务,按助教等级计价 |
| 2 | 2807440316432197 | 台球超休服务 | BONUS | 附加课:超休/激励课,固定 190 元/小时 |
| 3 | 2807440316432198 | 包厢服务 | BASE | 包厢服务:归入基础课统计,统一按 138 元/小时计价 |
| 4 | 2790683529513797 | 基础课 | BASE | 基础课:飞球系统原始课程类型,与"台球基础陪打"同类 |
| 5 | 2790683529513798 | 附加课 | BONUS | 附加课:飞球系统原始课程类型,与"台球超休服务"同类 |
| 6 | 3039912271463941 | 包厢课 | BASE | 包厢课:飞球系统原始课程类型,与"包厢服务"同类 |
**注意**:第 3、6 行注释里写"包厢服务/包厢课"但 `course_type_code``BASE` — 这是**配置数据有意为之的合并**,不是 bug。
要让 ROOM 列生效,需要把它们改成 `ROOM` + 给 `cfg_assistant_level_price``room_course_price` 列(或使用现有的 BASE 价 + 一个 ROOM 专属规则)。
DWS `dws_assistant_daily_detail` 整体统计(2 942 行):
| 指标 | base | bonus | room |
|-----|------|-------|------|
| 非零行数 | 2 833 | 574 | **0** |
| 累计服务次数 | 5 237 | 645 | **0** |
| 累计金额 | 1 546 983.80 | 301 910.00 | **0.00** |
DWS `dws_assistant_salary_calc` 月度统计(10 个月):**room_hours / room_income 全部为 0.00**。
DWS `dws_member_assistant_intimacy` 整体统计(219 行):
| 指标 | 数值 |
|------|------|
| 总会话 | 698 |
| basic_session_count | 698 |
| incentive_session_count | **0** |
注意:亲密度模块的 incentive_session_count 也全 0。这与 daily_detail 的 bonus(574 行非零)不一致,值得在选项 A 决策后再深查 `relation_index_task.py` 的归类口径。
---
## 十、总结(给主流程)
1. **课程实际分布**:不是 H-1 说的"BASE+BONUS 两类",也不是 Neo 提的"4 类独立分布",而是 **"BASE + BONUS 完整,ROOM 半残空表,MAHJONG 空白"**。
2. **5 处分布载体**:`cfg_skill_type`(配置)、`cfg_assistant_level_price`(定价)、`dws_assistant_daily_detail/salary_calc/finance_analysis/coach_area_hours`(派生汇总)、`dws_member_assistant_intimacy/relation_index`(关系)、`dwd_assistant_service_log` + `ods.assistant_service_records`(原始)。
3. **关键 H-1 错误**:漏报 cfg_skill_type 实际 6 行(不是 2 行)、漏报 174 条真实包厢课订单被错误合并进 BASE、未提"激励课"4 套别名混用、未识别关系指数模块的命名错位。
4. **不存在统一课程表**:NeoZQYY 用"skill_id → course_type_code"映射 + DWS 派生列展开的方式表达课程,没有 `dim_course` / `cfg_course` 这种独立维度。
5. **决策清单 6 项**:D1(包厢课独立) / D2(麻将课新增) / D3(术语统一) / D4(ROOM 处置) / D5(关系指数命名) / D6(包厢定价字段)。

View File

@@ -0,0 +1,298 @@
# P2-6 答疑 + P2-9 功能设计
> 日期:2026-05-04 / 触发:Neo 在 04c 反馈
> 状态:**仅设计 + 待 Neo 审稿**,实施延后到 Wave 5
> 作者:子代理(基于代码现状调研)
---
## P2-6 — 答疑:是否改百炼 APP1 的系统 Prompt
### 一、当前应用 1 prompt 状态
代码层面调研结论:
1. `apps/backend/app/ai/prompts/` 下只有 `app2~app8` 的 prompt 模板,**没有 `app1_*.py`**。
2. APP1 的系统 prompt **完全在百炼控制台云端配置**,后端只通过 `app_id` 调用(参见 `apps/backend/app/ai/dashscope_client.py:69-178``DashScopeClient.call_app_stream`)。
3. APP1 的输出形态:**纯文本 SSE 流**(`xcx_chat.py``chat_stream` 端点),前端按 token 渲染,流末尾发 `done` 事件。**没有任何结构化字段输出**。
4. `biz.ai_conversations.title` 字段**已存在**(varchar(200),见 `db/zqyy_app/schemas/biz.sql:60`),但当前**只读不写** — `chat_service.py` 的列表查询 `SELECT title` 会读,创建 `_create_session` 与 SSE 落库流程都没有对它赋值。
5. `chat_service.generate_title()` 已经有"自定义标题 > 客户姓名 > 首条消息前 20 字 > '新对话'"的回退链(`chat_service.py:494-522`),所以**当前 title 列表展示用的是回退链,不是 AI 摘要**。
### 二、修改方案
#### 是否需要改百炼 APP1 prompt:**N(不改)**
#### 理由
1. **流式纯文本 + 末尾插结构化字段会破坏 SSE 体验**:APP1 是逐 token 流式输出。在末尾再让模型多吐一段 `<title>关于王昕的消费</title>` 会让用户看到原始 XML/JSON 标记一闪而过,或需要前端再加解析层 — 复杂度不值。
2. **百炼控制台的 prompt 是"配置在云端"的资产**:每次调整都要进百炼控制台手工改,**不在 git 仓库的版本管理内**,改动不可追溯,违背 NeoZQYY"一切逻辑改动可追溯"原则。
3. **生成 title 是低价值场景**:title 只用于"对话历史列表"的辨识度,不参与业务逻辑。用全功率 APP1 模型 + 工具调用做摘要是杀鸡用牛刀。
4. **回退链已经够用**:`generate_title` 已能用客户姓名 + 首条消息前 20 字给出可辨识的标题。优化空间是"AI 让标题更精炼",但代价不应该是改 APP1 主对话 prompt。
#### 推荐替代方案(三选一)
| 方案 | 描述 | 成本 | 推荐度 |
|------|------|------|--------|
| **R1**(推荐) | 首轮对话结束后,后端**异步**用 `qwen-turbo` 直调一次"摘要 prompt"(类似 APP6 备注分析的轻量调用),16 字内,失败回退到现有 `generate_title` | 低(qwen-turbo 价格远低于 APP1) | ★★★★★ |
| **R2** | 直接用现有 `chat_service.generate_title` 回退链,**不上 AI 摘要**,把 title 列变成"客户姓名 / 首句截断"二选一 | 零额外成本 | ★★★★(若 Neo 觉得"够用",优先选这条) |
| **R3** | 借用 APP6(备注分析)的结构化输出能力做摘要 | 中,APP6 prompt 要扩展支持"摘要"模式 | ★★(改动面比 R1 大,无收益) |
#### 推荐:**R1 + R2 组合(默认 R2,可观察后升级 R1)**
- **Wave 5 先落 R2**:首轮 user message 落库时,同步 `UPDATE ai_conversations SET title = LEFT(content, 16)`(纯 SQL,零外部调用)。这一步可以解决"列表里全是空 title"的问题,且与现有 `generate_title` 回退链完全兼容。
- **后续观察**:如果产品反馈"前 16 字太机械"(例如用户首句是"你好,帮我看看"),再上 R1。R1 的实现可直接复用 `DashScopeClient.call_app(app_id=qwen-turbo, prompt="给下面对话写 ≤16 字标题: ...")` 模式,加进 background task 即可。
- **不动 APP1 的百炼系统 prompt**。
#### 给 Neo 的最终回答
> **不改 APP1 prompt。** P2-6 选项 A 描述里那句"由应用 1 首轮自动摘要生成"是策略层措辞,落到工程上更稳妥的实现是后端首轮自己写 title(纯 SQL 截断或异步轻量 AI 调用),与 APP1 主对话流解耦。
---
## P2-9 — tenant-admin 无权限页编辑入口 功能设计
### 背景与目标
现状:`apps/miniprogram/miniprogram/pages/no-permission/no-permission.wxml:54` 硬编码 `<text class="reason-footer-value">厉超</text>`
Neo 反馈选项 A,但**不直接显示 site_admin 的真实姓名**(隐私 / 角色边界),改为在 tenant-admin 后台单独提供一个"编辑无权限页显示信息"的管理入口,由 site_admin / tenant_admin 自己填"对外昵称、联系方式、说明文字"。
### 一、数据模型
#### 表结构
```sql
-- db/zqyy_app/migrations/2026-05-XX__create_site_contact_info.sql
CREATE TABLE biz.site_contact_info (
site_id bigint PRIMARY KEY REFERENCES biz.sites(id),
display_name varchar(50) NOT NULL DEFAULT '门店管理员', -- 对外显示昵称(非真名)
phone varchar(20), -- 可选联系电话
wechat_id varchar(50), -- 可选微信号
notes varchar(200), -- 自定义说明文字(覆盖默认"如有疑问请联系管理员")
updated_by bigint REFERENCES auth.tenant_admins(id),
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE biz.site_contact_info IS '门店对外联系信息(用于小程序无权限页等公开场景)';
COMMENT ON COLUMN biz.site_contact_info.display_name IS '对外显示昵称,默认"门店管理员",不应填真实姓名';
COMMENT ON COLUMN biz.site_contact_info.notes IS '自定义说明文字,空则用默认文案';
```
#### 设计要点
1. **表名定为 `biz.site_contact_info`**:与 `biz.sites` 直接关联,语义上是门店对外联络配置,不属于 auth schema。
2. **PK 直接用 `site_id`**:每个门店只有一行,不需要独立自增 id;减少 JOIN。
3. **不存 `tenant_admin_id` 关联**:因为门店管理员可能轮换,且这里**显示的是"门店身份",不是"具体某个人"**。
4. **`display_name` 默认 "门店管理员"**:迁移时所有现存 site 都 INSERT 一条默认记录,即便 tenant_admin 还没来配置,小程序也能显示通用文案。
5. **不加 RLS**:这张表本质是公开信息(小程序未登录页要读),走应用层授权即可;tenant-admin 改自己门店通过 API 层 `WHERE site_id = ANY(:managed_site_ids)` 过滤。
#### 默认值迁移 SQL
```sql
INSERT INTO biz.site_contact_info (site_id, display_name)
SELECT id, '门店管理员'
FROM biz.sites
WHERE id NOT IN (SELECT site_id FROM biz.site_contact_info);
```
### 二、后端 API 设计
#### 端点清单
| 方法 | 路径 | aud | 说明 |
|------|------|-----|------|
| GET | `/api/tenant/site-contact?siteId={id}` | tenant-admin | 取自己管辖门店的配置;不传 siteId 取首个 |
| PATCH | `/api/tenant/site-contact` | tenant-admin | 编辑配置 |
| GET | `/api/xcx/site-contact?siteId={id}` | miniapp(允许 limited token) | 小程序无权限页读取 |
#### 权限矩阵
| 角色 | GET 自己门店 | GET 任意门店 | PATCH 自己门店 | PATCH 任意门店 |
|------|------|------|------|------|
| `site_admin` | OK | 拒 | OK | 拒 |
| `tenant_admin`(管多个 site) | OK(白名单内) | 拒(白名单外) | OK(白名单内) | 拒(白名单外) |
| 小程序 limited token | OK(只能读自己 apply 时填的 site) | — | — | — |
| 小程序 approved token | OK(自己绑定的 site) | — | — | — |
权限校验通过 `auth.tenant_admins.managed_site_ids` 白名单实现。
#### Pydantic schema
```python
# apps/backend/app/schemas/site_contact.py
from pydantic import Field
from app.schemas.base import CamelModel
class SiteContactInfo(CamelModel):
site_id: int
display_name: str = Field(..., max_length=50)
phone: str | None = Field(None, max_length=20)
wechat_id: str | None = Field(None, max_length=50)
notes: str | None = Field(None, max_length=200)
updated_at: str # ISO 8601
class SiteContactUpdateRequest(CamelModel):
site_id: int
display_name: str = Field(..., min_length=1, max_length=50)
phone: str | None = Field(None, max_length=20, pattern=r"^[\d\-\+\s]*$")
wechat_id: str | None = Field(None, max_length=50)
notes: str | None = Field(None, max_length=200)
```
#### 校验规则
- `display_name`:必填,1-50 字符,**禁止包含全字符姓名常见字段**(可加简单黑名单:`厉超` 等明显的真名 — 实施时若 Neo 想软提示,可改为"前端给提示,后端不强制")。
- `phone`:选填,允许数字 / `-` / `+` / 空格,长度 ≤ 20。
- `notes`:选填,≤ 200 字符。
- 写入时 `updated_by` 取 JWT 中 `tenant_admin_id`
### 三、tenant-admin 前端页面设计
#### 路径与菜单位置
- 路径:`/site-contact`
- 菜单:在现有侧边栏 `用户审核 / 用户管理 / Excel 上传 / 维客线索管理 / 店铺管理员` 之后增加 `门店联络信息`,图标用 `IdcardOutlined`
- 权限:tenant_admin 看到所有管辖门店的下拉切换;site_admin 只看自己门店(下拉禁用)。
#### 页面结构(ASCII 草图)
```
┌─────────────────────────────────────────────────────────┐
│ 门店联络信息 │
│ 用于小程序"无权限页/账号被禁页"对外展示。 │
│ 请勿填写真实姓名,推荐使用"店长" / "客服" 等通用称呼。 │
├─────────────────────────────────────────────────────────┤
│ │
│ 门店: [朗朗桌球(总店) ▾] ← tenant_admin 可切换 │
│ │
│ 对外昵称* [店长_______________] 1-50 字 │
│ 联系电话 [13800000000_______] 可选,数字+-空格 │
│ 微信号 [_____________________] 可选 │
│ 自定义说明 [如有疑问请扫码加微信___________________] │
│ 0/200 │
│ │
│ 最近修改:tenant_admin_neo / 2026-05-04 14:32:11 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 恢复默认 │ │ 保 存 │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ─── 预览 ────────────────────────────────────────────── │
│ 小程序"无权限页"将显示: │
│ ┌─────────────────────────┐ │
│ │ 请联系管理员 │ │
│ │ 店长 │ │
│ │ ☎ 138-0000-0000 │ │
│ │ 如有疑问请扫码加微信 │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
#### 实现要点
- 表单组件:`Form.useForm()` + Ant Design `Input` / `Input.TextArea`
- 实时预览:右下角同步渲染小程序的卡片样式(用 React 组件还原)。
- 保存按钮:loading 态 + 成功 toast。
- "恢复默认":一键填回 `display_name='门店管理员'`,清空其他字段。
### 四、小程序 no-permission 改造
#### 改造点
1. `no-permission.ts` `_checkStatus` 调用完 `/api/xcx/me` 后,**串行**再请求 `/api/xcx/site-contact?siteId={当前用户 site_id}`
2. 拿到的 `displayName / phone / wechatId / notes` 写入 `data.contactInfo`
3. `no-permission.wxml:51-54` 替换:
```xml
<!-- 改造前 -->
<view class="reason-footer">
<text class="reason-footer-label">请联系管理员</text>
<text class="reason-footer-value">厉超</text>
</view>
<!-- 改造后 -->
<view class="reason-footer">
<text class="reason-footer-label">请联系管理员</text>
<text class="reason-footer-value">{{contactInfo.displayName || '门店管理员'}}</text>
<text class="reason-footer-extra" wx:if="{{contactInfo.phone}}">☎ {{contactInfo.phone}}</text>
<text class="reason-footer-extra" wx:if="{{contactInfo.wechatId}}">微信:{{contactInfo.wechatId}}</text>
<text class="reason-footer-notes" wx:if="{{contactInfo.notes}}">{{contactInfo.notes}}</text>
</view>
```
4. 兜底:接口失败 / 未配置时,`contactInfo = { displayName: '门店管理员' }`
5. 同时检查 `disabled` 状态下的 `contact-hint`(`no-permission.wxml:58-61` "如有疑问,请联系管理员"),如有 `notes` 则用 `notes` 覆盖。
#### 站点 ID 来源
小程序 limited token 也需能解出 `siteId`。当前 `auth.user_applications` 在用户 apply 时已记 `site_id`,后端 `/api/xcx/site-contact` 读 token 中 `site_id`(若 approved)或 `latestApplication.site_id`(若 rejected/disabled)。设计时**不要让前端传 siteId**(防伪造),由后端从 token / 申请记录推断。
### 五、默认值 / 迁移策略
1. **建表 + 默认数据**:迁移脚本同步 INSERT 所有现存 `biz.sites` 行,`display_name='门店管理员'`,其他字段 NULL。
2. **朗朗桌球(总店)**:**不在迁移里写"店长"/"厉超",保持默认 '门店管理员'**,让 Neo 上线后手工进 tenant-admin 配置(避免误把真名写进迁移)。
3. **新建门店**:在 `biz.sites` 新建门店的服务里(若有)或加 trigger,自动 INSERT 一行默认 site_contact_info。简化方案:每次小程序读取 API 时若行不存在,后端自动 INSERT 默认值再返回。
### 六、测试规范
#### 后端 unit(`apps/backend/tests/unit/`)
- `test_site_contact_schema.py`:Pydantic 校验
- `display_name` 空字符串 / 超 50 字 / 含 emoji 的边界
- `phone` 含中文 / 字母 → 拒
- `notes` 超 200 字 → 拒
- `test_site_contact_service.py`:服务层
- 行不存在时自动返回默认值
- 更新时 `updated_by` / `updated_at` 自动写入
#### 后端 integration(`apps/backend/tests/integration/`,连测试库)
- `test_site_contact_api.py`
- tenant_admin A 改自己门店 → 200
- tenant_admin A 改非管辖门店 → 403
- site_admin 改非自己门店 → 403
- 小程序 limited token 读自己 apply 的 site → 200
- 小程序 token 读他人 site → 403
- 跨 tenant_admin 的 site_id 隔离(白名单 ANY 校验)
#### tenant-admin e2e(Playwright,`apps/tenant-admin/tests/e2e/`)
- `site-contact.spec.ts`
- 登录 tenant_admin,跳到 `/site-contact`,看到门店切换下拉
-`display_name` 为 "店长" → 保存 → toast → 重载页面 → 字段保留
- 改 phone 为非法格式 → 表单校验失败,无法提交
- 切换到非管辖门店 → 接口 403,前端显示"无权限"
#### 小程序 e2e(微信开发者工具,`apps/miniprogram/tests/e2e/`)
- `no-permission.spec.ts`(参考 P0-7 已有 e2e 模式)
- mock `/api/xcx/me` 返回 `status='disabled'`
- mock `/api/xcx/site-contact` 返回 `{displayName:'店长', phone:'13800000000'}`
- 断言 wxml 渲染包含 "店长" 和 "13800000000",**不包含 "厉超"**
- mock 接口 500 → 兜底显示 "门店管理员"
### 七、Wave 分配建议
| 任务 | Wave | 说明 |
|------|------|------|
| 数据模型 + 迁移 SQL | Wave 5 | 与 P10 tenant-admin 账号体系收尾联动 |
| 后端 API(3 个端点 + Pydantic + 权限) | Wave 5 | 依赖数据模型 |
| tenant-admin 前端页面 | Wave 5 | 依赖后端 API |
| 小程序 no-permission 改造 | Wave 5 | 依赖 `/api/xcx/site-contact` |
| 全部测试 | 跟随各自实施 | unit + integration + e2e 并行 |
| 文档同步(`docs/database/` + `docs/_overview/02b-adminweb-page-matrix.md` + `02a-miniprogram-page-matrix.md`) | Wave 5 | 表 + API + 页面入口三处同步 |
预估工作量(单人):后端 0.5 天 / tenant-admin 0.5 天 / 小程序 0.2 天 / 测试 0.5 天 ≈ **1.7 天**
### 八、给 Neo 的设计审稿提问
1. **表名**:推荐 `biz.site_contact_info`。是否同意?或倾向 `biz.site_public_contact` / `biz.site_display_info`?
2. **`display_name` 真名黑名单**:是否需要后端硬拦"厉超 / 真实姓名"?推荐前端弹提示但后端不强制(避免遗漏 / 误伤)。
3. **小程序读取时机**:`no-permission` `onLoad` 时拉一次就够,还是 `onShow` 每次都拉(允许 tenant_admin 改了之后用户立即看到)?推荐 `onShow`
4. **menu 菜单文字**:推荐 `门店联络信息`,可选 `小程序展示信息` / `对外展示设置`,Neo 选哪个?
5. **新建门店时是否自动 INSERT 默认行**:推荐"读取时若不存在则自动 INSERT 默认值"(零侵入)。或"`biz.sites` INSERT 时加 trigger"(复杂)。Neo 选哪种?
6. **是否要审计这张表的修改**:每次 PATCH 是否要落 `audit_log` 表?当前 NeoZQYY 没有通用审计表设计,推荐**先不加**,只靠 `updated_by / updated_at` 字段。
7. **R2 vs R1(P2-6)的取舍**:Neo 是否同意 Wave 5 先做 R2(纯 SQL 取首句 16 字),后续观察后再考虑 R1(qwen-turbo 异步摘要)?
---
> 等 Neo 审稿确认 → 进 Wave 5 实施 → 实施时按本文档分阶段提 PR。