Files
gym-manage/gym-manage-api/docs/coach-performance-design.md
T

295 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 教练业绩统计设计文档
**版本**: v2.0
**日期**: 2026-07-26
**状态**: 已确定
---
## 一、功能概述
教练业绩统计为体育馆管理系统提供按教练维度的绩效评估,帮助管理者横向对比教练表现、激励教练提升教学质量。
### 核心能力
- **教练排行榜**:按综合评分降序排列所有教练
- **教练详情**:查看单个教练的六项指标详情
- **自查看板**:教练查看自己的业绩表现
---
## 二、指标体系
### 2.1 六项指标总览
| 序号 | 指标 | 类型 | 含义 | 数据源 |
|------|------|------|------|--------|
| 1 | 授课量 | 基础指标 | 统计周期内已完成的团课数量 | `group_course.status IN ('2','6')` |
| 2 | 出席人次 | 基础指标 | 学员实际参与的人次 | `group_course_booking.status IN ('2','4','5')` |
| 3 | 总预约数 | 基础指标 | 学员预约该教练课程的次数(仅已预约状态) | `group_course_booking.status = '0'` |
| 4 | 出勤率 | 派生指标 | 出席人次 / 总预约数 * 100 | 指标2 + 指标3 |
| 5 | 满员率 | 派生指标 | 各课程出席人数/满员上限的平均值 | `group_course.max_members` + 指标2明细 |
| 6 | 违规次数 | 基础指标 | 统计周期内违规记录数 | `coach_violation` |
| 7 | 综合评分 | 派生指标 | 加权综合得分(详见第三章) | 指标1-6 |
### 2.2 指标口径详解
#### 授课量
```
SELECT coach_id, COUNT(*) FROM group_course
WHERE end_time >= :startTime AND end_time < :endTime
AND status IN ('2', '6') AND deleted_at IS NULL
GROUP BY coach_id
```
- **status='2'**: 教练手动结课
- **status='6'**: 系统自动结课
- **排除**: 已取消(status='1')、教练缺席(status='5')的课程
#### 出席人次
```
SELECT gc.coach_id, COUNT(*) FROM group_course_booking b
INNER JOIN group_course gc ON b.course_id = gc.id
WHERE b.status IN ('2', '4', '5') AND b.deleted_at IS NULL
AND gc.deleted_at IS NULL
AND gc.end_time >= :startTime AND gc.end_time < :endTime
GROUP BY gc.coach_id
```
- **status='2'**: 已出席 — 学员正常到课
- **status='4'**: 教练缺席 — 教练未到,学员仍需记录
- **status='5'**: 迟到 — 学员迟到但仍到场参与
> **设计意图**: 教练缺席和迟到时,学员仍到达了现场(或尝试参与),责任在教练而非学员,故计入出席人次。学员无故缺席(status='3')不计入,因其既非教练功劳也非教练责任。
#### 总预约数
```
SELECT gc.coach_id, COUNT(*) FROM group_course_booking b
INNER JOIN group_course gc ON b.course_id = gc.id
WHERE b.status = '0' AND b.deleted_at IS NULL
AND gc.deleted_at IS NULL
AND gc.end_time >= :startTime AND gc.end_time < :endTime
GROUP BY gc.coach_id
```
- **仅 status='0'(已预约)**: 作为出勤率分母,表示"承诺来上课的学员"。
#### 满员率
```
SELECT gc.coach_id, gc.max_members, COUNT(b.id) AS attended
FROM group_course gc
LEFT JOIN group_course_booking b ON gc.id = b.course_id
AND b.status IN ('2','4','5') AND b.deleted_at IS NULL
WHERE gc.end_time >= :startTime AND gc.end_time < :endTime
AND gc.status IN ('2', '6') AND gc.deleted_at IS NULL
GROUP BY gc.coach_id, gc.id, gc.max_members
```
- 对每个已完成课程,计算 `出席人数 / max_members`
- 所有课程的比值取平均值
- `max_members = 0` 的课程被跳过(除零防御)
#### 违规次数
```
SELECT coach_id, COUNT(*) FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime
AND deleted_at IS NULL
GROUP BY coach_id
```
- 违规类型: `COACH_LATE`(迟到)、`COACH_ABSENT`(缺席)、`NOT_MANUAL_END`(未手动结课)
#### 时间基准
所有指标均基于 `group_course.end_time` 过滤时间范围。跨月课程归属于结束时间所在的月份。
---
## 三、综合评分算法
### 3.1 最终公式
```
综合评分 = 授课量归一化分 * 0.35
+ 出勤率 * 0.25
+ 满员率 * 0.25
+ 违规分 * 0.15
```
### 3.2 授课量归一化:百分位排名法(方案 B)
对于教练数为 N 的集合:
1. 将所有教练按授课量升序排列
2. 统计授课量严格小于当前教练的教练数 `C_fewer`
3. `normalizedCourses = C_fewer / (N - 1) * 100`N=1 时取 100
**示例**4 位教练):
| 教练 | 授课量 | 小于其的教练数 | 归一化分 |
|------|--------|---------------|---------|
| A | 20 | 3 | 100.0 |
| B | 15 | 2 | 66.7 |
| C | 10 | 1 | 33.3 |
| D | 5 | 0 | 0.0 |
**设计意图**: 百分位排名在"相对比较"和"公平性"之间取得平衡。授课量最大的教练得满分,最少的得 0 分,中间按排名线性分布。不受极端值影响——即使第一名开 100 节课、第二名只开 20 节,第二名的排名分数依然是 `2/3 * 100 ≈ 66.7`
### 3.3 违规分:线性扣分法(方案 A)
```
violationScore = max(0, 100 - violations * 20)
```
| 违规次数 | 违规分 |
|----------|--------|
| 0 | 100 |
| 1 | 80 |
| 2 | 60 |
| 3 | 40 |
| 4 | 20 |
| 5+ | 0 |
**设计意图**: 线性扣分简单直观,每次违规固定扣 20 分,累计 5 次后清零。在 15% 的权重下,每次违规对综合评分的影响约为 `20 * 0.15 = 3 分`
### 3.4 出勤率 & 满员率
这两项直接使用原始百分比(0-100),无需归一化——它们天然在 0-100 范围内且具有绝对含义。
```
出勤率 = 出席人次 / 总预约数 * 100(分母为 0 时取 0)
满员率 = avg(单个课程出席人数 / max_members) * 100(跳过 max_members=0 的课程)
```
---
## 四、方案选择记录
### 4.1 授课量归一化方案
| 方案 | 公式 | 优点 | 缺点 | 决定 |
|------|------|------|------|------|
| **B: 百分位排名** | `C_fewer / (N-1) * 100` | 直观、不受极端值影响 | 对教练总数敏感(N<3 时分布粗糙) | **采纳** |
| A: 最大值归一化 | `courses / max(courses) * 100` | 数学简洁 | 极端值压缩中游得分 | 初版方案,已废弃 |
| C: 对数归一化 | `ln(x+1)/ln(m+1)*100` | 压制极端值 | 解释性弱 | 已拒绝 |
| D: 固定目标 | `min(x/target*100, 100)` | 达标制、不互卷 | target 难校准 | 已拒绝 |
### 4.2 违规扣分方案
| 方案 | 公式 | 优点 | 缺点 | 决定 |
|------|------|------|------|------|
| **A: 线性扣分** | `max(0, 100 - v*20)` | 简单直白,每次等量扣分 | 多次违规后惩罚不再加剧 | **采纳** |
| B: 阶梯扣分 | 0→100, 1→70, 2→40, 3→10 | 首次违规惩罚重,有威慑力 | 第 1 到第 2 次差距 30 分,太激进 | 已拒绝 |
| C: 归一化扣分 | `(1 - v/max)*100` | 相对最差教练 | 依赖数据集,全员无违规则无意义 | 已拒绝 |
### 4.3 权重分配方案
| 方案 | 授课量 | 出勤率 | 满员率 | 违规 | 决定 |
|------|--------|--------|--------|------|------|
| **A: 轻违规** | 35% | 25% | 25% | 15% | **采纳** |
| B: 中违规 | 30% | 25% | 25% | 20% | 已拒绝 |
| C: 重违规 | 30% | 23% | 22% | 25% | 已拒绝 |
### 4.4 出勤率口径方案
| 方案 | 分子 | 分母 | 决定 |
|------|------|------|------|
| **当前** | status IN ('2','4','5') | status='0' | **采纳** |
| 初版 | status='2' | status!='1' | 已废弃 |
| 替代方案1 | status='2' | status IN ('0','2','3') | 已拒绝(无故缺席应排除) |
| 替代方案2 | status='2' | status IN ('0','2') | 已拒绝(不能区分取消预约) |
---
## 五、数据模型
### 5.1 API 响应模型
```java
public class CoachPerformance {
Long coachId; // 教练ID
String coachName; // 教练昵称
String avatar; // 头像URL
Long completedCourses; // 授课量
Long attendedStudents; // 出席人次
Long totalBookings; // 总预约数
Double attendanceRate; // 出勤率 (%)
Double fillRate; // 满员率 (%)
Long violationCount; // 违规次数
Double compositeScore; // 综合评分
}
```
### 5.2 API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/datacount/coach-performance/ranking` | 全部教练业绩排行榜 |
| GET | `/api/datacount/coach-performance/{coachId}` | 单个教练业绩详情 |
| GET | `/api/datacount/coach-performance/mine?coachId=` | 教练自查看板 |
**查询参数**: `statType`, `periodType`DAY/WEEK/MONTH/LAST_30_DAYS/LAST_90_DAYS/YEAR, `startTime`, `endTime`
---
## 六、代码架构
```
CoachPerformanceHandler ── Reactive Router Function,解析请求
IDataStatisticsService ── 接口定义
DataStatisticsServiceImpl ── 6 并行查询 + 聚合计算
DataStatisticsDao ── DatabaseClient SQL 聚合
┌───┼───┬───┬───┬───┐
▼ ▼ ▼ ▼ ▼ ▼
sys_user group_course group_course_booking coach_violation
```
### 查询执行流程
```
1. Flux: getAllCoachesWithInfo() ─→ Map<coachId, 基本信息>
2. Flux: countCompletedCoursesByCoach() ─→ Map<coachId, 授课量> ┐
3. Flux: countAttendedStudentsByCoach() ─→ Map<coachId, 出席人次> │
4. Flux: countTotalBookingsByCoach() ─→ Map<coachId, 总预约数> ├─ Mono.zip
5. Flux: getFillRateDetailByCoach() ─→ Map<coachId, List<明细>> │
6. Flux: countViolationsByCoach() ─→ Map<coachId, 违规次数> ┘
flatMapMany: 逐教练计算指标
sorted: 按综合评分降序
Flux<CoachPerformance>
```
---
## 七、边界情况处理
| 场景 | 行为 |
|------|------|
| 教练无任何已完成课程 | 所有指标为 0,综合评分 = 0 + 0 + 0 + 15 = **15**(违规分满分 100 * 0.15 |
| 教练有课程但无人预约 | 授课量 > 0,出勤率/满员率 = 0 |
| 课程 max_members = 0 | 该课程跳过,不参与满员率计算 |
| 仅有 1 位教练 | 百分位排名直接返回 100 |
| 所有教练授课量相同 | 所有教练 `C_fewer = 0`,授课量归一化分均为 0 |
| 跨月课程 | 以 end_time 所在月份归类 |
| 查询单个教练不存在 | 返回零值 `CoachPerformance`coachName="未知教练" |
---
## 八、变更历史
| 日期 | 版本 | 变更内容 |
|------|------|---------|
| 2026-07-22 | v1.0 | 初版:最大值归一化 + 三维度评分(4:3:3),违规仅展示 |
| 2026-07-26 | v2.0 | 时间基准改为 end_time;出席人次扩展为(2,4,5);出勤率分母改为仅 status='0';授课量归一化改为百分位排名;违规纳入综合评分(权重 15%);满员率防御除零 |