建立项目级标杆文档 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 残留, 已修 commit17f045a) - P0-5 致命 2 (JWT aud 缺失, 已修 commit17f045a) - P0-6 clearAllTasks 守卫 (Wave 3) - P0-8 DBViewer 黑名单漏 (已修 commit17f045a) - 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
19 KiB
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: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 定义:
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_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--roomCSS,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(对齐当前生产现状)
- 数据层不动:
cfg_skill_type保持 BASE/BONUS 两类(尊重现状) - 代码层清理:
- 移除
CourseType.ROOM枚举(或注释为 deprecated) - 删除
room_course_price配置,所有包厢服务统一走 base 价格(cfg_assistant_level_price.base_course_price) - 但注意:目前包厢统一 138 元 = 星级 base 价,初级/中级助教做包厢按各自 base 价 → 会改变工资,需求需 Neo 决策
- 移除
- DWS 表瘦身:
dws_assistant_daily_detail/monthly_summary删除room_*三列(或保留为兼容字段一直填 0)
- 前端三页统一:删除
course-tag--roomCSS 与 COURSE_TAG_MAP 中的"包厢/包厢课" key,统一映射到 basic - service-record-card 命名清理:把
vip/tip重命名为basic/incentive,与三页一致 - BD 手册修正:
BD_manual_cfg_skill_type.md删除 ROOM 枚举说明
备选:方案 A(启用 ROOM 三分类,贴合飞球原始数据)
- 数据层补 cfg_skill_type 行:把 skill_id=2807440316432198(包厢服务)和 3039912271463941(包厢课)改为
ROOM - 配置层补 cfg_assistant_level_price:增加
room_course_price字段(可选,目前用代码配置) - 代码层不动(已有 ROOM 分支)
- 前端不动(已有 room CSS)
- 影响:
- 历史 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)
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 课程体系
- 方案选择:A(启用 ROOM 三分类,改 cfg_skill_type) / B(代码清理,合并到 BASE) / 维持现状不动
- service-record-card 命名:是否同步把 vip/tip 重命名为 room/incentive
- room_course_price 138 元配置去留:工资计算时,星级以下助教做包厢应按"星级 base 价(138)" 还是"自身等级 base 价"?(决策依据是真实业务)
P2-7 看板切换限制(全看板共发现 2 个组合限制问题)
- board-coach 双向禁用:确认按"改动点 1"方案实施(filter-dropdown 加 disabledValues prop + 联动 ts)
- board-finance area≠all 三板块消失问题:
- 是否同样修复?(方案 a 占位提示 / 方案 b 选项副标题)
- 还是接受现状(area≠all 是高级用法,暂不优化)
- board-customer:本次未发现组合限制问题,无需修复
- 后端 422 校验补强:目前 board-coach 是 HTTP 400(detail 中文),是否升级为 422 + 标准化错误码?
- 可访问性兜底:如果用户用直链/分享 URL 进入非法组合页面,前端是否需在 onLoad 兜底重置一个合法默认值?
调研副产物(本轮发现的其他问题)
- cfg_skill_type 数据库实际无 ROOM 行,但 BD 手册写了 ROOM 枚举 → 文档过期
- dws_assistant_daily_detail / monthly_summary 的 room_* 三列实际可能恒为 0 → 需 SQL 验证
- dws_assistant_order_contribution 表完全没有 course_type 字段,与文档预期不符 → 与 P2-4 关联