Files
Neo-ZQYY/docs/_overview/04c-feedback/P2-4-and-P2-7-research.md
Neo 509cf43284 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
2026-05-04 07:38:28 +08:00

19 KiB
Raw Blame History

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):

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 — 完全独立的"项目分类"体系

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. 全库扫描:没有任何"二级嵌套课程表"

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

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

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

工资计算公式:

# 基础课收入 = 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

"包厢课": "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:221courseType: 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 定义:

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:

/** 课程标签文字,如 "基础课" "包厢课" "打赏课" */
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 还有:

{ 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_idcfg_skill_typecourse_type_code → 服务次数三分类
  • dim_table.area_namecfg_area_categorycategory_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-262HTTPException(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)

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 联动重算):

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 关联