新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ADR-0001: 教练业绩统计功能设计
|
||||
|
||||
**日期**: 2026-07-22
|
||||
**日期**: 2026-07-22(初版)/ 2026-07-26(修订)
|
||||
**状态**: 已决定
|
||||
**决策者**: 通过 grill-with-docs 追问明确
|
||||
|
||||
@@ -19,16 +19,18 @@
|
||||
**选择**: 在 `gym-dataCount` 模块中新增 CoachPerformance 相关的 Handler + Service + DAO,而非新建独立模块。
|
||||
|
||||
**理由**:
|
||||
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + Redis 缓存 + 时间范围推导)
|
||||
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + 时间范围推导)
|
||||
- 现有 `DataStatisticsDao` 已有教练相关的 SQL 聚合查询,可直接复用
|
||||
- 避免模块膨胀,将"统计"职责收敛在一个模块中
|
||||
- `manage-app` 已依赖 `gym-dataCount`,路由注册零成本
|
||||
|
||||
**替代方案被拒绝**: 新建 `gym-coach-performance` 独立模块。理由:功能规模不足以支撑独立模块,且会引入额外的模块间依赖管理成本。
|
||||
|
||||
---
|
||||
|
||||
### 2. 数据源:完全基于团课预约数据
|
||||
|
||||
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking` 表(status='2'=已出席),而非 `sign_in_record` 签到表。
|
||||
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking` 表,而非 `sign_in_record` 签到表。
|
||||
|
||||
**理由**:
|
||||
- `sign_in_record` 表中没有 `coach_id` 字段,签到只关联会员(member_id),不关联教练
|
||||
@@ -37,6 +39,8 @@
|
||||
|
||||
**风险**: 如果未来签到记录需要关联教练(例如一对一的私教签到),需要重新评估此决策。
|
||||
|
||||
---
|
||||
|
||||
### 3. 授课量定义:仅计入已完成课程
|
||||
|
||||
**选择**: 只统计 `status IN (2, 6)` 的课程(已结束 + 自动结束)。
|
||||
@@ -45,23 +49,75 @@
|
||||
- 所有非取消课程:会包含教练缺席(status=5)的课程,不应算作业绩
|
||||
- 所有排课:会包含已取消的课程,不能反映真实工作量
|
||||
|
||||
### 4. 满员率:按出席人数计算
|
||||
---
|
||||
|
||||
**选择**: 满员率 = 各课程(出席人数 / max_members)的平均值。
|
||||
### 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)计算。理由:预约了但没来的学员不能算"满员",出席人数更真实地反映了课程实际到场情况。
|
||||
|
||||
### 5. 综合评分权重:授课量 40% + 出勤率 30% + 满员率 30%
|
||||
**变更历史**(2026-07-26): 满员率明细的出席人数口径从 `status='2'` 扩展为 `IN ('2','4','5')`,与出席人次保持一致。
|
||||
|
||||
**选择**: 授课量占比最高,体现工作量;出勤率和满员率体现教学质量。
|
||||
---
|
||||
|
||||
**归一化规则**: 授课量按所有教练中最大值归一化到 0-100。这样即使只有少数教练开课多,评分也能合理分布。
|
||||
### 8. 综合评分
|
||||
|
||||
**拒绝的替代方案**:
|
||||
- 三指标等权重(33/33/34):弱化了工作量差异
|
||||
- 授课量 50%:过度强调数量而忽视质量
|
||||
**最终选择**(2026-07-26 修订):
|
||||
|
||||
### 6. 不包含学员留存率
|
||||
| 指标 | 权重 | 归一化方式 |
|
||||
|------|------|-----------|
|
||||
| 授课量 | 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. 不包含学员留存率
|
||||
|
||||
**选择**: 首版不计算学员留存率。
|
||||
|
||||
@@ -69,11 +125,30 @@
|
||||
|
||||
---
|
||||
|
||||
### 10. 不引入 Redis 缓存
|
||||
|
||||
**选择**: 教练业绩统计数据不进行 Redis 缓存,每次请求实时计算。
|
||||
|
||||
**理由**: 业绩数据需要准实时性,缓存可能导致教练查看时数据滞后;且当前教练数量级下,6 条聚合查询的响应时间可接受。
|
||||
|
||||
---
|
||||
|
||||
### 11. getCoachPerformanceById 复用全量查询
|
||||
|
||||
**选择**: 查询单个教练业绩时,内部调用 `getCoachPerformanceList` 获取全量后过滤。暂不新增按教练 ID 的单独 DAO 方法。
|
||||
|
||||
**理由**: 当前教练数量有限,全量查询后再过滤的性能损耗可接受,优先保持代码简洁。
|
||||
|
||||
**风险**: 教练数量增长后需要重新评估,届时可新增按 coach_id 直查的 DAO 方法。
|
||||
|
||||
---
|
||||
|
||||
## 影响
|
||||
|
||||
### 后端变更
|
||||
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler`、`CoachPerformanceDao`
|
||||
- `manage-app` 的 `SystemRouter` 中新增 2 条路由
|
||||
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler`、`DataStatisticsDao`(教练业绩相关方法)
|
||||
- `manage-app` 的 `SystemRouter` 中新增 3 条路由
|
||||
- `DataStatisticsServiceImpl` 新增 `getCoachPerformanceList`、`getCoachPerformanceById`、`calculateFillRate` 方法
|
||||
|
||||
### 前端变更
|
||||
- `StatisticsDashboard.vue` 新增"教练业绩"Tab
|
||||
@@ -96,3 +171,28 @@
|
||||
新建 `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`
|
||||
- 优点:相对于最差教练扣分
|
||||
- 缺点:依赖数据集中的最大值,若所有教练都无违规则无意义
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# ADR-0002: 教练迟到/缺席时间判定可配置化
|
||||
|
||||
**日期**: 2026-07-26
|
||||
**状态**: 已决定
|
||||
**决策者**: 通过 grill-with-docs 追问明确
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
当前教练开课/结课/迟到/缺席的时间阈值全部硬编码在代码中:
|
||||
|
||||
| 硬编码值 | 位置 | 含义 |
|
||||
|----------|------|------|
|
||||
| 60 分钟 | CoachCourseService + CoachCourseScheduler | 长/短课时分界线 |
|
||||
| 10 分钟 | CoachCourseService L213 | 长课正常开课窗口 |
|
||||
| 30 分钟 | CoachCourseService L217, Scheduler L118 | 长课迟到/缺席截止线 |
|
||||
| 10% | CoachCourseService L229 | 短课正常开课比例 |
|
||||
| 25% | CoachCourseService L230, Scheduler L120 | 短课迟到/缺席比例 |
|
||||
| 10 分钟 | CoachCourseService L283, Scheduler L34 | 结课宽限期 |
|
||||
|
||||
业务方要求:
|
||||
1. **前端统一传入绝对值**(分钟),短课时比例也由前端换算后传入
|
||||
2. 支持**按课程时长区间**匹配不同规则
|
||||
3. 配置存储在**数据库**中
|
||||
4. **热更新**——修改配置后无需重启即生效
|
||||
5. 配置缺失/非法时使用**硬编码值兜底**
|
||||
|
||||
---
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 架构:新建 `gym-coach-config` 独立模块
|
||||
|
||||
**选择**: 创建新模块 `gym-coach-config`,封装时间规则配置的完整功能链。
|
||||
|
||||
**理由**:
|
||||
- 将可配置化逻辑从 `gym-coach` 中解耦,符合单一职责原则
|
||||
- `gym-coach-config` 提供规则 CRUD + 规则匹配服务,是纯"配置域"
|
||||
- `gym-coach` 和 `gym-coach-config` 之间通过依赖注入协作,`gym-coach` 依赖 `gym-coach-config`
|
||||
- 后续若其他模块(如签到、预约)也需要时间阈值配置化,可直接复用
|
||||
|
||||
**替代方案被拒绝**:
|
||||
- 放在 `gym-coach` 模块内:配置逻辑和业务逻辑耦合,违反职责分离
|
||||
- 放在 `manage-sys` 的字典模块:字典是通用 key-value 对,无法支撑规则匹配(需范围查询 + 优先级排序)
|
||||
|
||||
### 2. 数据模型:`coach_time_rule` 表
|
||||
|
||||
采用规则表设计,每条规则定义了一个课程时长区间及其对应的时间阈值:
|
||||
|
||||
```sql
|
||||
CREATE TABLE coach_time_rule (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
min_duration INTEGER, -- 课程时长下限(分钟),NULL 表示无下限
|
||||
max_duration INTEGER, -- 课程时长上限(分钟),NULL 表示无上限
|
||||
normal_window INTEGER NOT NULL, -- 正常开课窗口(分钟)
|
||||
late_window INTEGER NOT NULL, -- 迟到/缺席截止窗口(分钟)
|
||||
end_grace INTEGER NOT NULL, -- 结课宽限期(分钟)
|
||||
is_default BOOLEAN DEFAULT FALSE, -- 是否默认规则
|
||||
sort_order INTEGER DEFAULT 0, -- 优先级
|
||||
status CHAR(1) DEFAULT '1',
|
||||
remark VARCHAR(500),
|
||||
create_by VARCHAR(64),
|
||||
update_by VARCHAR(64),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**示例数据**:
|
||||
|
||||
| id | min_duration | max_duration | normal_window | late_window | end_grace | is_default | 说明 |
|
||||
|----|-------------|-------------|---------------|-------------|-----------|------------|------|
|
||||
| 1 | NULL | NULL | 10 | 30 | 10 | true | 默认规则:原长课逻辑 |
|
||||
| 2 | NULL | 59 | 1 | 15 | 5 | false | 短课(<60分钟):最小1分钟正常,15分钟迟到 |
|
||||
|
||||
### 3. 规则匹配策略
|
||||
|
||||
```
|
||||
对于一门课程(时长 = endTime - startTime 的分钟数):
|
||||
|
||||
1. 从 Redis 缓存中获取所有启用规则(status='1', deleted_at IS NULL)
|
||||
2. 过滤出 minDuration <= courseDuration <= maxDuration 的规则
|
||||
3. 选择范围最精确的规则 —— 即 (maxDuration - minDuration) 最小的那条
|
||||
4. 若无匹配规则,使用 is_default=true 的默认规则
|
||||
5. 若默认规则也不存在,使用硬编码兜底值
|
||||
```
|
||||
|
||||
### 4. 热更新机制
|
||||
|
||||
```
|
||||
┌──────────┐ POST/PUT/DELETE ┌──────────────────┐
|
||||
│ 前端 │ ──────────────────> │ CoachTimeRuleHandler │
|
||||
└──────────┘ └────────┬─────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ DB 更新 │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ 删除 Redis │
|
||||
│ key: │
|
||||
│ coach:time: │
|
||||
│ rules │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────────────┐ 下次开课/调度器触发时 ┌──▼───────────┐
|
||||
│ 业务代码 │ <────────────────── │ Redis Miss │
|
||||
│ (Service/ │ │ → 从 DB 加载 │
|
||||
│ Scheduler) │ │ → 写入 Redis │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- 写操作(创建/更新/删除规则)→ 更新 DB → 立即删除 Redis 缓存 key
|
||||
- 读操作 → 先查 Redis → 未命中则查 DB → 写入 Redis(TTL=300s,兜底)
|
||||
- 每次业务调用(开课/调度器)都实时从 CoachTimeRuleService 获取最新规则,不缓存本地变量
|
||||
|
||||
### 5. 兜底策略
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 所有规则被删除 | 使用硬编码默认值(原逻辑:长课 10/30,短课 10%/25%,结课 10) |
|
||||
| 单条规则中值为 null/负数 | 该字段使用硬编码兜底值 |
|
||||
| Redis 不可用 | 降级为每次查 DB |
|
||||
| DB 不可用 | 使用硬编码兜底值 |
|
||||
|
||||
### 6. API 设计
|
||||
|
||||
```
|
||||
GET /api/coach/time-rules -- 获取所有规则列表
|
||||
GET /api/coach/time-rules/{id} -- 获取单条规则
|
||||
POST /api/coach/time-rules -- 创建规则
|
||||
PUT /api/coach/time-rules/{id} -- 更新规则
|
||||
DELETE /api/coach/time-rules/{id} -- 删除规则
|
||||
```
|
||||
|
||||
POST/PUT 请求体:
|
||||
```json
|
||||
{
|
||||
"minDuration": 60, // 可选,null 表示无下限
|
||||
"maxDuration": null, // 可选,null 表示无上限
|
||||
"normalWindow": 10, // 必填,正常开课窗口(分钟)
|
||||
"lateWindow": 30, // 必填,迟到/缺席截止窗口(分钟)
|
||||
"endGrace": 10, // 必填,结课宽限期(分钟)
|
||||
"isDefault": true, // 是否设为默认规则
|
||||
"sortOrder": 0,
|
||||
"remark": "默认规则"
|
||||
}
|
||||
```
|
||||
|
||||
### 7. API 校验规则
|
||||
|
||||
后端在 Handler 层对前端传入的值做合法性校验:
|
||||
- `normalWindow`:必须 >= 1 且 <= 1440(一天内)
|
||||
- `lateWindow`:必须 >= `normalWindow` 且 <= 1440
|
||||
- `endGrace`:必须 >= 0 且 <= 1440
|
||||
- `minDuration` 和 `maxDuration`:若同时非空,`maxDuration` 必须 >= `minDuration`
|
||||
- 若前端传入非法值,返回 HTTP 400 + 具体错误信息;不落库
|
||||
|
||||
### 8. 模块依赖关系
|
||||
|
||||
```
|
||||
manage-app
|
||||
├── gym-coach (依赖 gym-coach-config)
|
||||
│ └── CoachCourseService → 注入 CoachTimeRuleService 获取规则
|
||||
│ └── CoachCourseScheduler → 注入 CoachTimeRuleService 获取规则
|
||||
└── gym-coach-config (新模块)
|
||||
├── handler/CoachTimeRuleHandler -- HTTP 处理器
|
||||
├── service/CoachTimeRuleService -- 规则匹配 + 缓存
|
||||
├── domain/CoachTimeRule -- 领域对象
|
||||
├── repository/ICoachTimeRuleRepository -- 仓储接口
|
||||
└── router -- 路由注册
|
||||
manage-db
|
||||
├── entity/CoachTimeRuleEntity -- DB 实体
|
||||
├── dao/CoachTimeRuleDao -- DAO (R2DBC)
|
||||
└── migration/V29__Create_coach_time_rule.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `pom.xml` | 新增 | 添加 `gym-coach-config` 模块 |
|
||||
| `gym-coach/pom.xml` | 修改 | 添加 `gym-coach-config` 依赖 |
|
||||
| `CoachCourseService.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
|
||||
| `CoachCourseScheduler.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
|
||||
| `SystemRouter.java` | 修改 | 注册新路由 |
|
||||
| 新建模块文件 | 新增 | 约 8-10 个 Java 文件 + 1 个 SQL 迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 已知问题与修复记录
|
||||
|
||||
### 修复 1:Flyway 版本冲突
|
||||
|
||||
原始版本使用了 V25/V26,与已有迁移冲突。最终使用 V29(建表)/ V30(菜单)。
|
||||
|
||||
### 修复 2:LocalDateTime Redis 反序列化
|
||||
|
||||
`CoachTimeRule.domain` 的 `createdAt`/`updatedAt` 存入 Redis 后反序列化失败(DB 格式 `yyyy-MM-dd HH:mm:ss` 无 `T` 分隔符,Jackson 默认期望 ISO 格式)。已添加 `@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")`。
|
||||
|
||||
### 修复 3:默认规则回退未做区间匹配校验
|
||||
|
||||
**问题**:当有区间限制的规则(如 `minDuration=30`)被标记为 `isDefault=true`,或原始默认规则被修改了区间时,不匹配该区间的课程时长(如 20 分钟)会被错误应用该规则的阈值。
|
||||
|
||||
**修复**:`doMatch()` 中回退到默认规则时,增加 `r.matches(courseDurationMinutes)` 校验。若默认规则也不匹配,继续回退到 `buildFallbackRule` 兜底。
|
||||
|
||||
```diff
|
||||
- .filter(r -> Boolean.TRUE.equals(r.getIsDefault()))
|
||||
+ .filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
|
||||
```
|
||||
@@ -0,0 +1,294 @@
|
||||
# 教练业绩统计设计文档
|
||||
|
||||
**版本**: 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%);满员率防御除零 |
|
||||
Reference in New Issue
Block a user