Files
gym-manage/gym-manage-api/docs/adr/0001-coach-performance-statistics.md

8.5 KiB
Raw Permalink Blame History

ADR-0001: 教练业绩统计功能设计

日期: 2026-07-22(初版)/ 2026-07-26(修订) 状态: 已决定 决策者: 通过 grill-with-docs 追问明确


背景

需要在后台管理系统中为体育馆新增"教练业绩统计"功能。现有系统已有 gym-dataCount 模块提供全局统计(含教练违规统计 CoachStatistics),但缺少按教练维度的业绩数据(授课量、出勤率、满员率等正向指标)。


决策

1. 架构:扩展现有 gym-dataCount 模块

选择: 在 gym-dataCount 模块中新增 CoachPerformance 相关的 Handler + Service + DAO,而非新建独立模块。

理由:

  • gym-dataCount 模块已有成熟的统计架构(DatabaseClient + Reactive + 时间范围推导)
  • 现有 DataStatisticsDao 已有教练相关的 SQL 聚合查询,可直接复用
  • 避免模块膨胀,将"统计"职责收敛在一个模块中
  • manage-app 已依赖 gym-dataCount,路由注册零成本

替代方案被拒绝: 新建 gym-coach-performance 独立模块。理由:功能规模不足以支撑独立模块,且会引入额外的模块间依赖管理成本。


2. 数据源:完全基于团课预约数据

选择: 业绩统计的"出席人次"和"出勤率"完全基于 group_course_booking 表,而非 sign_in_record 签到表。

理由:

  • sign_in_record 表中没有 coach_id 字段,签到只关联会员(member_id),不关联教练
  • 学员→教练的唯一数据路径是:member → group_course_booking → group_course → coach_id
  • 改造签到表会增加数据库变更成本,且签到不等于上课(签到可能发生在任何时间)

风险: 如果未来签到记录需要关联教练(例如一对一的私教签到),需要重新评估此决策。


3. 授课量定义:仅计入已完成课程

选择: 只统计 status IN (2, 6) 的课程(已结束 + 自动结束)。

拒绝的定义:

  • 所有非取消课程:会包含教练缺席(status=5)的课程,不应算作业绩
  • 所有排课:会包含已取消的课程,不能反映真实工作量

4. 时间基准:以课程结束时间为准

选择: 所有时间范围过滤均使用 group_course.end_time,而非 start_time

理由: 课程可能跨统计周期边界(如月末 23:00 开课、次月 01:00 结束)。以开始时间为准会导致跨月课程被错误归因到上月。以结束时间为准更符合"这个月完成了哪些课程"的直观理解。

变更历史2026-07-26: 从 start_time 改为 end_time


5. 出席人次口径:参与型状态

选择: 出席人次统计 booking.status IN ('2', '4', '5'),即已出席(2) + 教练缺席(4) + 迟到(5)。

拒绝的定义: 仅统计 status='2'(已出席)。理由:教练缺席和迟到同样意味着学员到达了现场(或至少尝试了参与),应计入出席人次;实际缺席责任在教练而非学员。

变更历史2026-07-26: 从仅 status='2' 扩展为 IN ('2','4','5')


6. 出勤率分母:仅已预约

选择: 出勤率分母仅统计 booking.status = '0'(已预约),而非 status != '1'(所有非取消)。

理由:

  • status='3'(学员缺席)不应出现在分母中——学员预约后无故缺席,既不应计入分子也不应计入分母,因为这既非教练的功劳也非教练的责任
  • 出勤率语义变为"在已预约的学员中,实际参与的比例"
  • 排除了预约后取消(status='1')和学员缺席(status='3')的噪声

变更历史2026-07-26: 从 status != '1' 改为 status = '0'


7. 满员率:按出席人数计算 + 防御除零

选择: 满员率 = 各已完成课程(出席人数 / max_members)的平均值,其中出席人数按 status IN ('2','4','5') 统计。max_members = 0 的课程被跳过不参与计算。

拒绝的定义: 按预约人数(current_members)计算。理由:预约了但没来的学员不能算"满员",出席人数更真实地反映了课程实际到场情况。

变更历史2026-07-26): 满员率明细的出席人数口径从 status='2' 扩展为 IN ('2','4','5'),与出席人次保持一致。


8. 综合评分

最终选择2026-07-26 修订):

指标 权重 归一化方式
授课量 35% 百分位排名(授课量排序,小于当前教练的教练数 / (总教练数-1) * 100
出勤率 25% 原始百分比(0-100
满员率 25% 原始百分比(0-100
违规扣分 15% 线性扣分:max(0, 100 - 违规次数 * 20)
综合评分 = 授课量归一化分 * 0.35 + 出勤率 * 0.25 + 满员率 * 0.25 + 违规分 * 0.15

公式变更历史:

  • 初版(2026-07-22: 授课量归一化(最大值归一化) * 0.4 + 出勤率 * 0.3 + 满员率 * 0.3,违规仅展示不参与评分
  • 修订(2026-07-26): 授课量归一化改为百分位排名,违规纳入评分,权重重新分配

拒绝的替代方案: 详见设计文档 docs/coach-performance-design.md


9. 不包含学员留存率

选择: 首版不计算学员留存率。

理由: 现有系统缺少"学员持续上课"的显式数据模型。要实现留存率需要定义"留存"的判定规则(如:连续两个月以上预约同一教练的课程),这会引入新的领域概念,增加首版复杂度。


10. 不引入 Redis 缓存

选择: 教练业绩统计数据不进行 Redis 缓存,每次请求实时计算。

理由: 业绩数据需要准实时性,缓存可能导致教练查看时数据滞后;且当前教练数量级下,6 条聚合查询的响应时间可接受。


11. getCoachPerformanceById 复用全量查询

选择: 查询单个教练业绩时,内部调用 getCoachPerformanceList 获取全量后过滤。暂不新增按教练 ID 的单独 DAO 方法。

理由: 当前教练数量有限,全量查询后再过滤的性能损耗可接受,优先保持代码简洁。

风险: 教练数量增长后需要重新评估,届时可新增按 coach_id 直查的 DAO 方法。


影响

后端变更

  • gym-dataCount 模块新增:CoachPerformance domain、CoachPerformanceHandlerDataStatisticsDao(教练业绩相关方法)
  • manage-appSystemRouter 中新增 3 条路由
  • DataStatisticsServiceImpl 新增 getCoachPerformanceListgetCoachPerformanceByIdcalculateFillRate 方法

前端变更

  • StatisticsDashboard.vue 新增"教练业绩"Tab
  • statistics.api.ts 新增 API 接口类型
  • 可选:教练端新增个人业绩页面(通过路由守卫区分角色)

数据库

  • 无新增表。完全基于现有表(group_coursegroup_course_bookingcoach_violationsys_user

备选方案记录

方案 A:基于签到表改造(已拒绝)

改造 sign_in_record 添加 coach_id 字段,使签到直接关联教练。

  • 优点:数据更准确(签到是真实到店行为)
  • 缺点:需要改表、改签到流程、影响面大;签到不等于上团课

方案 B:新建独立模块(已拒绝)

新建 gym-coach-performance 独立 Maven 模块。

  • 优点:职责隔离清晰
  • 缺点:模块碎片化,增加编译和依赖管理成本

方案 C:授课量最大值归一化(已拒绝,初版方案)

normalizedCourses = courses / maxCourses * 100

  • 优点:数学简洁
  • 缺点:若有一位教练授课量远超其他,中游教练得分被严重压缩;鼓励"互卷"而非"达标"

方案 D:授课量对数归一化(已拒绝)

normalizedCourses = ln(courses + 1) / ln(maxCourses + 1) * 100

  • 优点:自然压制极端值
  • 缺点:解释性弱,非技术人员难以理解评分含义

方案 E:授课量固定目标归一化(已拒绝)

normalizedCourses = min(courses / target * 100, 100)target 可配置

  • 优点:变成"达标制",不受其他教练影响
  • 缺点:target 值需要根据实际数据校准,设置不当会全员满分或全员不及格

方案 F:违规阶梯扣分(已拒绝)

0次=100, 1次=70, 2次=40, 3次=10, >=4次=0

  • 优点:首次违规惩罚重,有威慑力
  • 缺点:阶梯粒度太粗,第 1 次和第 2 次违规之间差距 30 分,过于激进

方案 G:违规归一化扣分(已拒绝)

violationScore = (1 - violations / maxViolations) * 100

  • 优点:相对于最差教练扣分
  • 缺点:依赖数据集中的最大值,若所有教练都无违规则无意义