From fb87c95506f36d90f0bc1563b2f64f49953a5be8 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Mon, 14 Sep 2026 15:17:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9F=B9=E8=AE=AD=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AF=BE=E6=AC=A1=E6=8E=92=E8=AF=BEP0?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../train/TrainLessonController.java | 94 ++ .../train/TrainLessonControllerTest.java | 109 ++ .../ruoyi/train/constant/LessonErrorCode.java | 55 + .../com/ruoyi/train/domain/TrainLesson.java | 152 +++ .../domain/dto/LessonConflictCheckDTO.java | 145 ++ .../train/domain/dto/LessonCreateDTO.java | 133 ++ .../train/domain/dto/LessonUpdateDTO.java | 133 ++ .../ruoyi/train/domain/vo/ConflictItemVO.java | 121 ++ .../train/domain/vo/LessonConflictVO.java | 67 + .../train/domain/vo/PlanTimetableVO.java | 85 ++ .../ruoyi/train/domain/vo/TimetableDayVO.java | 54 + .../domain/vo/TimetableLessonItemVO.java | 212 +++ .../ruoyi/train/mapper/TrainLessonMapper.java | 120 ++ .../train/service/SchedulingAppService.java | 58 + .../impl/SchedulingAppServiceImpl.java | 516 ++++++++ .../mapper/train/TrainLessonMapper.xml | 278 ++++ .../impl/SchedulingAppServiceTest.java | 1168 +++++++++++++++++ 17 files changed, 3500 insertions(+) create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainLessonController.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainLessonControllerTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/LessonErrorCode.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainLesson.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonConflictCheckDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonCreateDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonUpdateDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ConflictItemVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/LessonConflictVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanTimetableVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableDayVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableLessonItemVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/SchedulingAppService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainLessonController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainLessonController.java new file mode 100644 index 000000000..62213e4e0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainLessonController.java @@ -0,0 +1,94 @@ +package com.ruoyi.web.controller.train; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.dto.LessonCreateDTO; +import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; +import com.ruoyi.train.domain.dto.LessonUpdateDTO; +import com.ruoyi.train.service.SchedulingAppService; + +/** + * 课次编排与冲突检测 API (ISSUE-04-01 / EPIC-04)。 + * + * @author ruoyi + */ +@RestController +public class TrainLessonController extends BaseController +{ + private final SchedulingAppService schedulingAppService; + + @Autowired + public TrainLessonController(SchedulingAppService schedulingAppService) + { + this.schedulingAppService = schedulingAppService; + } + + /** + * 新增课次 (UC-07)。 + */ + @PreAuthorize("@ss.hasPermi('train:lesson:add')") + @Log(title = "新增课次", businessType = BusinessType.INSERT) + @PostMapping("/train/plans/{planId}/lessons") + public AjaxResult addLesson(@PathVariable String planId, @Validated @RequestBody LessonCreateDTO dto) + { + Long lessonId = schedulingAppService.createLesson(planId, dto, SecurityUtils.getUsername()); + return success(lessonId); + } + + /** + * 修改课次 (UC-08)。 + */ + @PreAuthorize("@ss.hasPermi('train:lesson:edit')") + @Log(title = "修改课次", businessType = BusinessType.UPDATE) + @PutMapping("/train/lessons/{lessonId}") + public AjaxResult editLesson(@PathVariable Long lessonId, @Validated @RequestBody LessonUpdateDTO dto) + { + schedulingAppService.updateLesson(lessonId, dto, SecurityUtils.getUsername()); + return success(); + } + + /** + * 删除课次 (UC-09)。 + */ + @PreAuthorize("@ss.hasPermi('train:lesson:remove')") + @Log(title = "删除课次", businessType = BusinessType.DELETE) + @DeleteMapping("/train/lessons/{lessonId}") + public AjaxResult removeLesson(@PathVariable Long lessonId) + { + schedulingAppService.deleteLesson(lessonId, SecurityUtils.getUsername()); + return success(); + } + + /** + * 排课冲突检查预检 (UC-10)。 + */ + @PreAuthorize("@ss.hasPermi('train:lesson:query')") + @PostMapping("/train/lessons/check-conflict") + public AjaxResult checkConflict(@Validated @RequestBody LessonConflictCheckDTO dto) + { + return success(schedulingAppService.checkConflict(dto)); + } + + /** + * 获取计划完整课程表聚合 (UC-11 / 编辑回显必需)。 + */ + @PreAuthorize("@ss.hasPermi('train:plan:query')") + @GetMapping("/train/plans/{planId}/timetable") + public AjaxResult getTimetable(@PathVariable String planId) + { + return success(schedulingAppService.getPlanTimetable(planId)); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainLessonControllerTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainLessonControllerTest.java new file mode 100644 index 000000000..45e9cd92e --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainLessonControllerTest.java @@ -0,0 +1,109 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Method; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.dto.LessonCreateDTO; +import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; +import com.ruoyi.train.domain.dto.LessonUpdateDTO; + +/** + * ISSUE-04-01 Controller 契约与冻结边界测试。 + * + * @author ruoyi + */ +public class TrainLessonControllerTest +{ + @Test + public void shouldExposeFrozenP0MethodsAndPermissions() throws Exception + { + // 1. 新增课次 + Method add = TrainLessonController.class.getMethod("addLesson", String.class, LessonCreateDTO.class); + assertNotNull(add.getAnnotation(PostMapping.class)); + assertEquals("/train/plans/{planId}/lessons", add.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:lesson:add')", add.getAnnotation(PreAuthorize.class).value()); + + // 2. 修改课次 + Method edit = TrainLessonController.class.getMethod("editLesson", Long.class, LessonUpdateDTO.class); + assertNotNull(edit.getAnnotation(PutMapping.class)); + assertEquals("/train/lessons/{lessonId}", edit.getAnnotation(PutMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:lesson:edit')", edit.getAnnotation(PreAuthorize.class).value()); + + // 3. 删除课次 + Method remove = TrainLessonController.class.getMethod("removeLesson", Long.class); + assertNotNull(remove.getAnnotation(DeleteMapping.class)); + assertEquals("/train/lessons/{lessonId}", remove.getAnnotation(DeleteMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:lesson:remove')", remove.getAnnotation(PreAuthorize.class).value()); + + // 4. 排课冲突检查 + Method check = TrainLessonController.class.getMethod("checkConflict", LessonConflictCheckDTO.class); + assertNotNull(check.getAnnotation(PostMapping.class)); + assertEquals("/train/lessons/check-conflict", check.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:lesson:query')", check.getAnnotation(PreAuthorize.class).value()); + + // 5. 计划课表日历聚合 (编辑回显) + Method timetable = TrainLessonController.class.getMethod("getTimetable", String.class); + assertNotNull(timetable.getAnnotation(GetMapping.class)); + assertEquals("/train/plans/{planId}/timetable", timetable.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:plan:query')", timetable.getAnnotation(PreAuthorize.class).value()); + } + + @Test + public void controllerMustNotInventSingleGetLessonEndpoint() + { + // 严禁发明 GET /train/lessons/{lessonId} + for (Method method : TrainLessonController.class.getDeclaredMethods()) + { + if (method.isAnnotationPresent(GetMapping.class)) + { + String[] paths = method.getAnnotation(GetMapping.class).value(); + for (String path : paths) + { + assertFalse("不允许发明 GET /train/lessons/{lessonId}", path.equals("/train/lessons/{lessonId}")); + } + } + } + } + + @Test + public void controllerMustNotExposePersistenceDomain() + { + // 严禁入参或出参直接暴露 TrainLesson 数据库持久化实体 + for (Method method : TrainLessonController.class.getDeclaredMethods()) + { + assertFalse(method.getReturnType().equals(TrainLesson.class)); + for (Class parameterType : method.getParameterTypes()) + { + assertFalse(parameterType.equals(TrainLesson.class)); + } + } + } + + @Test + public void controllerMustOnlyExposeFrozenFiveEndpoints() + { + int endpointCount = 0; + for (Method method : TrainLessonController.class.getDeclaredMethods()) + { + boolean isEndpoint = method.isAnnotationPresent(GetMapping.class) + || method.isAnnotationPresent(PostMapping.class) + || method.isAnnotationPresent(PutMapping.class) + || method.isAnnotationPresent(DeleteMapping.class); + if (isEndpoint) + { + endpointCount++; + } + } + assertEquals("Controller 必须不多不少正好暴露 5 个冻结端点", 5, endpointCount); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/LessonErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/LessonErrorCode.java new file mode 100644 index 000000000..de2e4be62 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/LessonErrorCode.java @@ -0,0 +1,55 @@ +package com.ruoyi.train.constant; + +/** + * 排课与课次管理业务错误码 (API Contract V1.0 - EPIC-04 / ISSUE-04-01)。 + * + * @author ruoyi + */ +public final class LessonErrorCode +{ + /** 教师排课时间冲突:该教师在指定时段已有其他教学任务 */ + public static final int LESSON_TEACHER_CONFLICT = 40040; + + /** 教室排课时间冲突:该教室在指定时段已被占用 */ + public static final int LESSON_CLASSROOM_CONFLICT = 40041; + + /** 实训场地排课时间冲突:该实训场地在指定时段已被占用 */ + public static final int LESSON_SITE_CONFLICT = 40042; + + /** 本班课次时间重叠:同一培训班不可在同一时段上多门课 */ + public static final int LESSON_PLAN_OVERLAP = 40043; + + /** 该课次已有学员签到打卡,禁止修改上课时间 / 严禁删除 */ + public static final int LESSON_ALREADY_HAS_ATTENDANCE = 40044; + + /** 排课场地配置不合法:普通教室与实训场地必须二选一 */ + public static final int VENUE_CONFIG_INVALID = 40045; + + /** 排课课次不存在 */ + public static final int LESSON_NOT_FOUND = 40440; + + /** 授课教师不存在或已无效 */ + public static final int TEACHER_NOT_FOUND = 40401; + + /** 教学教室不存在或已无效 */ + public static final int CLASSROOM_NOT_FOUND = 40408; + + /** 实训场地不存在或已无效 */ + public static final int SITE_NOT_FOUND = 40409; + + public static final String LESSON_TEACHER_CONFLICT_MESSAGE = "教师排课时间冲突:该教师在指定时段已有其他教学任务"; + public static final String LESSON_CLASSROOM_CONFLICT_MESSAGE = "教室排课时间冲突:该教室在指定时段已被占用"; + public static final String LESSON_SITE_CONFLICT_MESSAGE = "实训场地排课时间冲突:该实训场地在指定时段已被占用"; + public static final String LESSON_PLAN_OVERLAP_MESSAGE = "本班课次时间重叠:同一培训班不可在同一时段上多门课"; + public static final String LESSON_ALREADY_HAS_ATTENDANCE_MESSAGE = "该课次已有学员签到打卡,禁止修改上课时间"; + public static final String LESSON_ALREADY_HAS_ATTENDANCE_DELETE_MESSAGE = "该课次已有学员签到打卡,严禁删除"; + public static final String VENUE_CONFIG_INVALID_MESSAGE = "排课场地配置不合法:普通教室与实训场地必须二选一"; + public static final String LESSON_NOT_FOUND_MESSAGE = "排课课次不存在"; + public static final String TEACHER_NOT_FOUND_MESSAGE = "授课教师不存在或已无效"; + public static final String CLASSROOM_NOT_FOUND_MESSAGE = "教学教室不存在或已无效"; + public static final String SITE_NOT_FOUND_MESSAGE = "实训场地不存在或已无效"; + + private LessonErrorCode() + { + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainLesson.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainLesson.java new file mode 100644 index 000000000..1ccefcd0c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainLesson.java @@ -0,0 +1,152 @@ +package com.ruoyi.train.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 课次教学活动对象 train_lesson + * + * @author ruoyi + * @date 2026-09-14 + */ +public class TrainLesson extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 课次ID (核心ID之一) */ + private Long lessonId; + + /** 所属培训课程实例ID */ + @Excel(name = "所属培训课程实例ID") + private Long planCourseId; + + /** 授课教师ID (课次必要组成) */ + @Excel(name = "授课教师ID") + private String tId; + + /** 课次开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "课次开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "课次结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 场地类型 (1:普通教室, 2:实训场地) */ + @Excel(name = "场地类型", readConverterExp = "1=普通教室,2=实训场地") + private Integer venueType; + + /** 普通教室ID */ + @Excel(name = "普通教室ID") + private String classRoomId; + + /** 实训场地ID */ + @Excel(name = "实训场地ID") + private String siteId; + + public Long getLessonId() + { + return lessonId; + } + + public void setLessonId(Long lessonId) + { + this.lessonId = lessonId; + } + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getTId() + { + return tId; + } + + public void setTId(String tId) + { + this.tId = tId; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Integer getVenueType() + { + return venueType; + } + + public void setVenueType(Integer venueType) + { + this.venueType = venueType; + } + + public String getClassRoomId() + { + return classRoomId; + } + + public void setClassRoomId(String classRoomId) + { + this.classRoomId = classRoomId; + } + + public String getSiteId() + { + return siteId; + } + + public void setSiteId(String siteId) + { + this.siteId = siteId; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("lessonId", getLessonId()) + .append("planCourseId", getPlanCourseId()) + .append("tId", getTId()) + .append("startTime", getStartTime()) + .append("endTime", getEndTime()) + .append("venueType", getVenueType()) + .append("classRoomId", getClassRoomId()) + .append("siteId", getSiteId()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonConflictCheckDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonConflictCheckDTO.java new file mode 100644 index 000000000..138c8855c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonConflictCheckDTO.java @@ -0,0 +1,145 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 排课冲突预检请求 DTO (UC-10)。 + * + * @author ruoyi + */ +public class LessonConflictCheckDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 课次主键ID (编辑排课预检时传入以排除自身,新增排课时传空) */ + private Long lessonId; + + /** 培训计划ID */ + @NotBlank(message = "培训计划ID不能为空") + private String planId; + + /** 本期培训课程实例ID (可选) */ + private Long planCourseId; + + /** 授课教师工号/标识 */ + @NotBlank(message = "授课教师工号/标识不能为空") + @Size(max = 32, message = "教师工号长度不能超过32个字符") + private String tId; + + /** 课次开始时间 */ + @NotNull(message = "课次开始时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 */ + @NotNull(message = "课次结束时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 场地类型 (1:普通教室, 2:实训场地) */ + @NotNull(message = "场地类型不能为空 (1:普通教室, 2:实训场地)") + private Integer venueType; + + /** 普通教室ID */ + @Size(max = 32, message = "教室ID长度不能超过32个字符") + private String classRoomId; + + /** 实训场地ID */ + @Size(max = 32, message = "实训场地ID长度不能超过32个字符") + private String siteId; + + public Long getLessonId() + { + return lessonId; + } + + public void setLessonId(Long lessonId) + { + this.lessonId = lessonId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getTId() + { + return tId; + } + + public void setTId(String tId) + { + this.tId = tId; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Integer getVenueType() + { + return venueType; + } + + public void setVenueType(Integer venueType) + { + this.venueType = venueType; + } + + public String getClassRoomId() + { + return classRoomId; + } + + public void setClassRoomId(String classRoomId) + { + this.classRoomId = classRoomId; + } + + public String getSiteId() + { + return siteId; + } + + public void setSiteId(String siteId) + { + this.siteId = siteId; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonCreateDTO.java new file mode 100644 index 000000000..25ddd2fcf --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonCreateDTO.java @@ -0,0 +1,133 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 新增课次请求 DTO (UC-07)。 + * + * @author ruoyi + */ +public class LessonCreateDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 本期培训课程实例ID */ + @NotNull(message = "本期课程实例ID不能为空") + private Long planCourseId; + + /** 授课教师工号/标识 */ + @NotBlank(message = "授课教师工号/标识不能为空") + @Size(max = 32, message = "教师工号长度不能超过32个字符") + private String tId; + + /** 课次开始时间 */ + @NotNull(message = "课次开始时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 */ + @NotNull(message = "课次结束时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 场地类型 (1:普通教室, 2:实训场地) */ + @NotNull(message = "场地类型不能为空 (1:普通教室, 2:实训场地)") + private Integer venueType; + + /** 普通教室ID (venueType=1 时有效且必填) */ + @Size(max = 32, message = "教室ID长度不能超过32个字符") + private String classRoomId; + + /** 实训场地ID (venueType=2 时有效且必填) */ + @Size(max = 32, message = "实训场地ID长度不能超过32个字符") + private String siteId; + + /** 课次备注 */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getTId() + { + return tId; + } + + public void setTId(String tId) + { + this.tId = tId; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Integer getVenueType() + { + return venueType; + } + + public void setVenueType(Integer venueType) + { + this.venueType = venueType; + } + + public String getClassRoomId() + { + return classRoomId; + } + + public void setClassRoomId(String classRoomId) + { + this.classRoomId = classRoomId; + } + + public String getSiteId() + { + return siteId; + } + + public void setSiteId(String siteId) + { + this.siteId = siteId; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonUpdateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonUpdateDTO.java new file mode 100644 index 000000000..e4158a948 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/LessonUpdateDTO.java @@ -0,0 +1,133 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 修改课次请求 DTO (UC-08)。 + * + * @author ruoyi + */ +public class LessonUpdateDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 本期培训课程实例ID */ + @NotNull(message = "本期课程实例ID不能为空") + private Long planCourseId; + + /** 授课教师工号/标识 */ + @NotBlank(message = "授课教师工号/标识不能为空") + @Size(max = 32, message = "教师工号长度不能超过32个字符") + private String tId; + + /** 课次开始时间 */ + @NotNull(message = "课次开始时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 */ + @NotNull(message = "课次结束时间不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 场地类型 (1:普通教室, 2:实训场地) */ + @NotNull(message = "场地类型不能为空 (1:普通教室, 2:实训场地)") + private Integer venueType; + + /** 普通教室ID (venueType=1 时有效且必填) */ + @Size(max = 32, message = "教室ID长度不能超过32个字符") + private String classRoomId; + + /** 实训场地ID (venueType=2 时有效且必填) */ + @Size(max = 32, message = "实训场地ID长度不能超过32个字符") + private String siteId; + + /** 课次备注 */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getTId() + { + return tId; + } + + public void setTId(String tId) + { + this.tId = tId; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Integer getVenueType() + { + return venueType; + } + + public void setVenueType(Integer venueType) + { + this.venueType = venueType; + } + + public String getClassRoomId() + { + return classRoomId; + } + + public void setClassRoomId(String classRoomId) + { + this.classRoomId = classRoomId; + } + + public String getSiteId() + { + return siteId; + } + + public void setSiteId(String siteId) + { + this.siteId = siteId; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ConflictItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ConflictItemVO.java new file mode 100644 index 000000000..4ab74abdf --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ConflictItemVO.java @@ -0,0 +1,121 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 冲突课次明细 VO。 + * + * @author ruoyi + */ +public class ConflictItemVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 课次主键标识 */ + private Long lessonId; + + /** 培训计划标识 */ + private String planId; + + /** 培训班名称 */ + private String planName; + + /** 课程名称 */ + private String courseName; + + /** 授课教师姓名 */ + private String teacherName; + + /** 场地展示名称 */ + private String venueName; + + /** 课次开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + public Long getLessonId() + { + return lessonId; + } + + public void setLessonId(Long lessonId) + { + this.lessonId = lessonId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public String getCourseName() + { + return courseName; + } + + public void setCourseName(String courseName) + { + this.courseName = courseName; + } + + public String getTeacherName() + { + return teacherName; + } + + public void setTeacherName(String teacherName) + { + this.teacherName = teacherName; + } + + public String getVenueName() + { + return venueName; + } + + public void setVenueName(String venueName) + { + this.venueName = venueName; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/LessonConflictVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/LessonConflictVO.java new file mode 100644 index 000000000..f96585050 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/LessonConflictVO.java @@ -0,0 +1,67 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 排课冲突检测结果 VO (API Contract 1.4)。 + * + * @author ruoyi + */ +public class LessonConflictVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 是否存在冲突 */ + private Boolean hasConflict; + + /** 冲突类型 (NONE, TEACHER, CLASSROOM, SITE, PLAN_OVERLAP) */ + private String conflictType; + + /** 冲突描述文本 */ + private String conflictMessage; + + /** 冲突的现有课次明细列表 */ + private List conflictingLessons = new ArrayList<>(); + + public Boolean getHasConflict() + { + return hasConflict; + } + + public void setHasConflict(Boolean hasConflict) + { + this.hasConflict = hasConflict; + } + + public String getConflictType() + { + return conflictType; + } + + public void setConflictType(String conflictType) + { + this.conflictType = conflictType; + } + + public String getConflictMessage() + { + return conflictMessage; + } + + public void setConflictMessage(String conflictMessage) + { + this.conflictMessage = conflictMessage; + } + + public List getConflictingLessons() + { + return conflictingLessons; + } + + public void setConflictingLessons(List conflictingLessons) + { + this.conflictingLessons = conflictingLessons; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanTimetableVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanTimetableVO.java new file mode 100644 index 000000000..9ae1f8564 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanTimetableVO.java @@ -0,0 +1,85 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 计划完整课程表 VO (API Contract 1.2 / CG-02 聚合课表与编辑回显)。 + * 下属 days[].lessons 集合项为扩展后的 TimetableLessonItemVO。 + * + * @author ruoyi + */ +public class PlanTimetableVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 培训计划标识 */ + private String planId; + + /** 培训班名称 */ + private String planName; + + /** 课表开始日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date startDate; + + /** 课表结束日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date endDate; + + /** 按日期分组的课次排期集合 */ + private List days = new ArrayList<>(); + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public Date getStartDate() + { + return startDate; + } + + public void setStartDate(Date startDate) + { + this.startDate = startDate; + } + + public Date getEndDate() + { + return endDate; + } + + public void setEndDate(Date endDate) + { + this.endDate = endDate; + } + + public List getDays() + { + return days; + } + + public void setDays(List days) + { + this.days = days; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableDayVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableDayVO.java new file mode 100644 index 000000000..0c0c65efa --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableDayVO.java @@ -0,0 +1,54 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 按日期分组的排期集合 VO。 + * + * @author ruoyi + */ +public class TimetableDayVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 日期 (yyyy-MM-dd) */ + private String date; + + /** 星期几 (1~7,1为周一,7为周日) */ + private Integer dayOfWeek; + + /** 当天课次集合 */ + private List lessons = new ArrayList<>(); + + public String getDate() + { + return date; + } + + public void setDate(String date) + { + this.date = date; + } + + public Integer getDayOfWeek() + { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) + { + this.dayOfWeek = dayOfWeek; + } + + public List getLessons() + { + return lessons; + } + + public void setLessons(List lessons) + { + this.lessons = lessons; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableLessonItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableLessonItemVO.java new file mode 100644 index 000000000..33d1f9ec6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/TimetableLessonItemVO.java @@ -0,0 +1,212 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 计划课表课次明细项 VO (API Contract 1.2 / CG-02 / 回显表单必需)。 + * + * @author ruoyi + */ +public class TimetableLessonItemVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 课次主键标识 */ + private Long lessonId; + + /** 本期计划课程关联标识 (回显表单必需) */ + private Long planCourseId; + + /** 本期课程名称 */ + private String courseName; + + /** 授课教师工号/标识 (回显表单必需) */ + private String tId; + + /** 授课教师姓名 */ + private String teacherName; + + /** 课次开始时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** 课次结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** 场地类型 (1:普通教室, 2:实训场地) */ + private Integer venueType; + + /** 普通教室物理标识 (venueType=1 时有效,回显表单必需) */ + private String classRoomId; + + /** 普通教室名称 */ + private String classRoomName; + + /** 实训场地物理标识 (venueType=2 时有效,回显表单必需) */ + private String siteId; + + /** 实训场地名称 */ + private String siteName; + + /** 场地聚合展示名称 */ + private String venueName; + + /** 关联电子班牌 SN 号 (若有) */ + private String boardSn; + + /** 课次备注 (回显表单必需) */ + private String remark; + + public Long getLessonId() + { + return lessonId; + } + + public void setLessonId(Long lessonId) + { + this.lessonId = lessonId; + } + + public Long getPlanCourseId() + { + return planCourseId; + } + + public void setPlanCourseId(Long planCourseId) + { + this.planCourseId = planCourseId; + } + + public String getCourseName() + { + return courseName; + } + + public void setCourseName(String courseName) + { + this.courseName = courseName; + } + + public String getTId() + { + return tId; + } + + public void setTId(String tId) + { + this.tId = tId; + } + + public String getTeacherName() + { + return teacherName; + } + + public void setTeacherName(String teacherName) + { + this.teacherName = teacherName; + } + + public Date getStartTime() + { + return startTime; + } + + public void setStartTime(Date startTime) + { + this.startTime = startTime; + } + + public Date getEndTime() + { + return endTime; + } + + public void setEndTime(Date endTime) + { + this.endTime = endTime; + } + + public Integer getVenueType() + { + return venueType; + } + + public void setVenueType(Integer venueType) + { + this.venueType = venueType; + } + + public String getClassRoomId() + { + return classRoomId; + } + + public void setClassRoomId(String classRoomId) + { + this.classRoomId = classRoomId; + } + + public String getClassRoomName() + { + return classRoomName; + } + + public void setClassRoomName(String classRoomName) + { + this.classRoomName = classRoomName; + } + + public String getSiteId() + { + return siteId; + } + + public void setSiteId(String siteId) + { + this.siteId = siteId; + } + + public String getSiteName() + { + return siteName; + } + + public void setSiteName(String siteName) + { + this.siteName = siteName; + } + + public String getVenueName() + { + return venueName; + } + + public void setVenueName(String venueName) + { + this.venueName = venueName; + } + + public String getBoardSn() + { + return boardSn; + } + + public void setBoardSn(String boardSn) + { + this.boardSn = boardSn; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java new file mode 100644 index 000000000..7b8e14952 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java @@ -0,0 +1,120 @@ +package com.ruoyi.train.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainClassroomInfo; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.TrainTrainingSite; +import com.ruoyi.train.domain.vo.ConflictItemVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; + +/** + * 课次教学活动 Mapper 接口 (ISSUE-04-01)。 + * + * @author ruoyi + */ +public interface TrainLessonMapper +{ + /** + * 查询课次详情。 + * + * @param lessonId 课次ID + * @return 课次信息 + */ + TrainLesson selectTrainLessonById(Long lessonId); + + /** + * 新增课次。 + * + * @param trainLesson 课次信息 + * @return 结果 + */ + int insertTrainLesson(TrainLesson trainLesson); + + /** + * 修改课次。 + * + * @param trainLesson 课次信息 + * @return 结果 + */ + int updateTrainLesson(TrainLesson trainLesson); + + /** + * 删除课次。 + * + * @param lessonId 课次ID + * @return 结果 + */ + int deleteTrainLessonById(Long lessonId); + + /** + * 加行级排他锁 (FOR UPDATE) 锁定稳定教师主表行。 + * + * @param tId 教师工号 + * @return 教师主表记录 + */ + TrainTeacher lockTeacherById(@Param("tId") String tId); + + /** + * 加行级排他锁 (FOR UPDATE) 锁定普通教室主表行。 + * + * @param classRoomId 教室ID + * @return 教室主表记录 + */ + TrainClassroomInfo lockClassroomById(@Param("classRoomId") String classRoomId); + + /** + * 加行级排他锁 (FOR UPDATE) 锁定实训场地主表行。 + * + * @param siteId 实训场地ID + * @return 场地主表记录 + */ + TrainTrainingSite lockSiteById(@Param("siteId") String siteId); + + /** + * 统计该课次下的学员考勤打卡流水数。 + * + * @param lessonId 课次ID + * @return 考勤记录数 + */ + int countAttendanceByLessonId(@Param("lessonId") Long lessonId); + + /** + * 查询教师时间重叠冲突课次。 + */ + List selectTeacherConflicts(@Param("tId") String tId, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("excludeLessonId") Long excludeLessonId); + + /** + * 查询普通教室时间重叠冲突课次。 + */ + List selectClassroomConflicts(@Param("classRoomId") String classRoomId, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("excludeLessonId") Long excludeLessonId); + + /** + * 查询实训场地时间重叠冲突课次。 + */ + List selectSiteConflicts(@Param("siteId") String siteId, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("excludeLessonId") Long excludeLessonId); + + /** + * 查询本期培训计划时间重叠冲突课次。 + */ + List selectPlanConflicts(@Param("planId") String planId, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("excludeLessonId") Long excludeLessonId); + + /** + * 获取指定培训计划下的全部课次日程明细 (按开始时间升序排列)。 + */ + List selectScheduleLessonsByPlanId(@Param("planId") String planId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/SchedulingAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/SchedulingAppService.java new file mode 100644 index 000000000..2ed2c6cad --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/SchedulingAppService.java @@ -0,0 +1,58 @@ +package com.ruoyi.train.service; + +import com.ruoyi.train.domain.dto.LessonCreateDTO; +import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; +import com.ruoyi.train.domain.dto.LessonUpdateDTO; +import com.ruoyi.train.domain.vo.LessonConflictVO; +import com.ruoyi.train.domain.vo.PlanTimetableVO; + +/** + * 课次编排与冲突检测应用服务 (ISSUE-04-01 / EPIC-04)。 + * + * @author ruoyi + */ +public interface SchedulingAppService +{ + /** + * 新增课次 (UC-07)。 + * + * @param planId 培训计划ID + * @param dto 新增课次参数 + * @param operator 操作人员 + * @return 新增课次主键ID + */ + Long createLesson(String planId, LessonCreateDTO dto, String operator); + + /** + * 修改课次 (UC-08)。 + * + * @param lessonId 课次ID + * @param dto 修改课次参数 + * @param operator 操作人员 + */ + void updateLesson(Long lessonId, LessonUpdateDTO dto, String operator); + + /** + * 删除课次 (UC-09)。 + * + * @param lessonId 课次ID + * @param operator 操作人员 + */ + void deleteLesson(Long lessonId, String operator); + + /** + * 排课冲突检查预检 (UC-10)。 + * + * @param dto 冲突检测参数 + * @return 冲突检测结果 + */ + LessonConflictVO checkConflict(LessonConflictCheckDTO dto); + + /** + * 获取计划完整课程表聚合 (UC-11 / 编辑回显必需)。 + * + * @param planId 培训计划ID + * @return 完整课程表 VO + */ + PlanTimetableVO getPlanTimetable(String planId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java new file mode 100644 index 000000000..c84c49fd8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/SchedulingAppServiceImpl.java @@ -0,0 +1,516 @@ +package com.ruoyi.train.service.impl; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.constant.LessonErrorCode; +import com.ruoyi.train.constant.PlanErrorCode; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassroomInfo; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.TrainTrainingSite; +import com.ruoyi.train.domain.dto.LessonCreateDTO; +import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; +import com.ruoyi.train.domain.dto.LessonUpdateDTO; +import com.ruoyi.train.domain.vo.ConflictItemVO; +import com.ruoyi.train.domain.vo.LessonConflictVO; +import com.ruoyi.train.domain.vo.PlanTimetableVO; +import com.ruoyi.train.domain.vo.TimetableDayVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.train.service.SchedulingAppService; + +/** + * 课次编排与冲突检测应用服务实现类 (ISSUE-04-01 / EPIC-04)。 + * + * @author ruoyi + */ +@Service +public class SchedulingAppServiceImpl implements SchedulingAppService +{ + private static final Logger log = LoggerFactory.getLogger(SchedulingAppServiceImpl.class); + + private final TrainLessonMapper trainLessonMapper; + private final TrainClassPlanMapper trainClassPlanMapper; + private final TrainPlanCourseMapper trainPlanCourseMapper; + + @Autowired + public SchedulingAppServiceImpl(TrainLessonMapper trainLessonMapper, + TrainClassPlanMapper trainClassPlanMapper, + TrainPlanCourseMapper trainPlanCourseMapper) + { + this.trainLessonMapper = trainLessonMapper; + this.trainClassPlanMapper = trainClassPlanMapper; + this.trainPlanCourseMapper = trainPlanCourseMapper; + } + + /** + * 新增课次 (UC-07)。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long createLesson(String planId, LessonCreateDTO dto, String operator) + { + // 1. 前置基础验证 (时间、场地二选一、班级状态、课程归属) + validateBasicRules(planId, dto.getPlanCourseId(), dto.getVenueType(), dto.getClassRoomId(), + dto.getSiteId(), dto.getStartTime(), dto.getEndTime()); + + // 2. 锁定稳定物理主资源行 (教师优先,教室/实训场地随后,兼具存在性校验与防并发死锁) + Set lockKeys = new HashSet<>(); + lockKeys.add(new ResourceLockKey("1_TEACHER", dto.getTId())); + if (dto.getVenueType() == 1) + { + lockKeys.add(new ResourceLockKey("2_CLASSROOM", dto.getClassRoomId())); + } + else + { + lockKeys.add(new ResourceLockKey("2_SITE", dto.getSiteId())); + } + lockPhysicalResources(lockKeys); + + // 3. 落库新增课次 (排课冲突属于 Warning 告警级别,由 check-conflict 预检供管理员决策,不强制阻断入库) + TrainLesson lesson = new TrainLesson(); + lesson.setPlanCourseId(dto.getPlanCourseId()); + lesson.setTId(dto.getTId()); + lesson.setStartTime(dto.getStartTime()); + lesson.setEndTime(dto.getEndTime()); + lesson.setVenueType(dto.getVenueType()); + lesson.setClassRoomId(dto.getClassRoomId()); + lesson.setSiteId(dto.getSiteId()); + lesson.setRemark(dto.getRemark()); + lesson.setCreateBy(operator); + + trainLessonMapper.insertTrainLesson(lesson); + log.info("成功创建课次 lessonId={}, planId={}, operator={}", lesson.getLessonId(), planId, operator); + return lesson.getLessonId(); + } + + /** + * 修改课次 (UC-08)。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void updateLesson(Long lessonId, LessonUpdateDTO dto, String operator) + { + TrainLesson existingLesson = trainLessonMapper.selectTrainLessonById(lessonId); + if (existingLesson == null) + { + throw new ServiceException(LessonErrorCode.LESSON_NOT_FOUND_MESSAGE, LessonErrorCode.LESSON_NOT_FOUND); + } + + // 获取原计划ID + TrainPlanCourse existingPlanCourse = trainPlanCourseMapper.selectTrainPlanCourseById(existingLesson.getPlanCourseId()); + String planId = (existingPlanCourse != null) ? existingPlanCourse.getPlanId() : null; + + // 1. 前置基础验证 + validateBasicRules(planId, dto.getPlanCourseId(), dto.getVenueType(), dto.getClassRoomId(), + dto.getSiteId(), dto.getStartTime(), dto.getEndTime()); + + // 2. 考勤限制检测:已有考勤打卡记录,禁止修改关键时间 + int attendanceCount = trainLessonMapper.countAttendanceByLessonId(lessonId); + if (attendanceCount > 0) + { + if (!existingLesson.getStartTime().equals(dto.getStartTime()) || !existingLesson.getEndTime().equals(dto.getEndTime())) + { + throw new ServiceException(LessonErrorCode.LESSON_ALREADY_HAS_ATTENDANCE_MESSAGE, + LessonErrorCode.LESSON_ALREADY_HAS_ATTENDANCE); + } + } + + // 3. 收集所有涉及的旧资源与新资源,统一按升序获取锁,避免交叉死锁并校验资源有效性 + Set lockKeys = new HashSet<>(); + lockKeys.add(new ResourceLockKey("1_TEACHER", existingLesson.getTId())); + lockKeys.add(new ResourceLockKey("1_TEACHER", dto.getTId())); + + if (existingLesson.getVenueType() == 1) + { + lockKeys.add(new ResourceLockKey("2_CLASSROOM", existingLesson.getClassRoomId())); + } + else + { + lockKeys.add(new ResourceLockKey("2_SITE", existingLesson.getSiteId())); + } + + if (dto.getVenueType() == 1) + { + lockKeys.add(new ResourceLockKey("2_CLASSROOM", dto.getClassRoomId())); + } + else + { + lockKeys.add(new ResourceLockKey("2_SITE", dto.getSiteId())); + } + + lockPhysicalResources(lockKeys); + + // 4. 更新课次 (排课冲突属于 Warning 告警级别,由 check-conflict 预检供管理员决策,不强制阻断更新) + existingLesson.setPlanCourseId(dto.getPlanCourseId()); + existingLesson.setTId(dto.getTId()); + existingLesson.setStartTime(dto.getStartTime()); + existingLesson.setEndTime(dto.getEndTime()); + existingLesson.setVenueType(dto.getVenueType()); + existingLesson.setClassRoomId(dto.getClassRoomId()); + existingLesson.setSiteId(dto.getSiteId()); + existingLesson.setRemark(dto.getRemark()); + existingLesson.setUpdateBy(operator); + + trainLessonMapper.updateTrainLesson(existingLesson); + log.info("成功更新课次 lessonId={}, operator={}", lessonId, operator); + } + + /** + * 删除课次 (UC-09)。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteLesson(Long lessonId, String operator) + { + TrainLesson existingLesson = trainLessonMapper.selectTrainLessonById(lessonId); + if (existingLesson == null) + { + throw new ServiceException(LessonErrorCode.LESSON_NOT_FOUND_MESSAGE, LessonErrorCode.LESSON_NOT_FOUND); + } + + // 考勤约束:已有学员打卡考勤记录时严禁删除 + int attendanceCount = trainLessonMapper.countAttendanceByLessonId(lessonId); + if (attendanceCount > 0) + { + throw new ServiceException(LessonErrorCode.LESSON_ALREADY_HAS_ATTENDANCE_DELETE_MESSAGE, + LessonErrorCode.LESSON_ALREADY_HAS_ATTENDANCE); + } + + trainLessonMapper.deleteTrainLessonById(lessonId); + log.info("成功删除课次 lessonId={}, operator={}", lessonId, operator); + } + + /** + * 排课冲突检查预检 (UC-10)。 + */ + @Override + public LessonConflictVO checkConflict(LessonConflictCheckDTO dto) + { + LessonConflictVO result = new LessonConflictVO(); + + // 基础时间与场地互斥格式校验 + if (dto.getStartTime() == null || dto.getEndTime() == null || !dto.getStartTime().before(dto.getEndTime())) + { + result.setHasConflict(true); + result.setConflictType("TIME_INVALID"); + result.setConflictMessage(PlanErrorCode.PLAN_TIME_INVALID_MESSAGE); + return result; + } + + if (dto.getVenueType() == null || (dto.getVenueType() != 1 && dto.getVenueType() != 2)) + { + result.setHasConflict(true); + result.setConflictType("VENUE_INVALID"); + result.setConflictMessage(LessonErrorCode.VENUE_CONFIG_INVALID_MESSAGE); + return result; + } + + if (dto.getVenueType() == 1 && (StringUtils.isEmpty(dto.getClassRoomId()) || StringUtils.isNotEmpty(dto.getSiteId()))) + { + result.setHasConflict(true); + result.setConflictType("VENUE_INVALID"); + result.setConflictMessage(LessonErrorCode.VENUE_CONFIG_INVALID_MESSAGE); + return result; + } + + if (dto.getVenueType() == 2 && (StringUtils.isEmpty(dto.getSiteId()) || StringUtils.isNotEmpty(dto.getClassRoomId()))) + { + result.setHasConflict(true); + result.setConflictType("VENUE_INVALID"); + result.setConflictMessage(LessonErrorCode.VENUE_CONFIG_INVALID_MESSAGE); + return result; + } + + Long excludeLessonId = dto.getLessonId(); + + // 1. 检查教师冲突 + List teacherConflicts = trainLessonMapper.selectTeacherConflicts( + dto.getTId(), dto.getStartTime(), dto.getEndTime(), excludeLessonId); + if (teacherConflicts != null && !teacherConflicts.isEmpty()) + { + result.setHasConflict(true); + result.setConflictType("TEACHER"); + result.setConflictMessage(LessonErrorCode.LESSON_TEACHER_CONFLICT_MESSAGE); + result.setConflictingLessons(teacherConflicts); + return result; + } + + // 2. 检查教室冲突 + if (dto.getVenueType() == 1) + { + List roomConflicts = trainLessonMapper.selectClassroomConflicts( + dto.getClassRoomId(), dto.getStartTime(), dto.getEndTime(), excludeLessonId); + if (roomConflicts != null && !roomConflicts.isEmpty()) + { + result.setHasConflict(true); + result.setConflictType("CLASSROOM"); + result.setConflictMessage(LessonErrorCode.LESSON_CLASSROOM_CONFLICT_MESSAGE); + result.setConflictingLessons(roomConflicts); + return result; + } + } + + // 3. 检查实训场地冲突 + if (dto.getVenueType() == 2) + { + List siteConflicts = trainLessonMapper.selectSiteConflicts( + dto.getSiteId(), dto.getStartTime(), dto.getEndTime(), excludeLessonId); + if (siteConflicts != null && !siteConflicts.isEmpty()) + { + result.setHasConflict(true); + result.setConflictType("SITE"); + result.setConflictMessage(LessonErrorCode.LESSON_SITE_CONFLICT_MESSAGE); + result.setConflictingLessons(siteConflicts); + return result; + } + } + + // 4. 检查同计划课次冲突 + if (StringUtils.isNotEmpty(dto.getPlanId())) + { + List planConflicts = trainLessonMapper.selectPlanConflicts( + dto.getPlanId(), dto.getStartTime(), dto.getEndTime(), excludeLessonId); + if (planConflicts != null && !planConflicts.isEmpty()) + { + result.setHasConflict(true); + result.setConflictType("PLAN_OVERLAP"); + result.setConflictMessage(LessonErrorCode.LESSON_PLAN_OVERLAP_MESSAGE); + result.setConflictingLessons(planConflicts); + return result; + } + } + + // 无冲突 + result.setHasConflict(false); + result.setConflictType("NONE"); + result.setConflictMessage("排课时间无冲突"); + return result; + } + + /** + * 获取计划完整课程表聚合 (UC-11)。 + */ + @Override + @Transactional(readOnly = true) + public PlanTimetableVO getPlanTimetable(String planId) + { + TrainClassPlan plan = trainClassPlanMapper.selectTrainClassPlanByPlanId(planId); + if (plan == null) + { + throw new ServiceException(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND); + } + + PlanTimetableVO vo = new PlanTimetableVO(); + vo.setPlanId(plan.getPlanId()); + vo.setPlanName(plan.getPlanName()); + vo.setStartDate(plan.getStartTime()); + vo.setEndDate(plan.getEndTime()); + + List lessonList = trainLessonMapper.selectScheduleLessonsByPlanId(planId); + if (lessonList == null || lessonList.isEmpty()) + { + vo.setDays(new ArrayList<>()); + return vo; + } + + // 按日期聚合课次 + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + Map dayMap = new LinkedHashMap<>(); + + Calendar cal = Calendar.getInstance(); + for (TimetableLessonItemVO lesson : lessonList) + { + String dateStr = sdf.format(lesson.getStartTime()); + TimetableDayVO dayVO = dayMap.get(dateStr); + if (dayVO == null) + { + dayVO = new TimetableDayVO(); + dayVO.setDate(dateStr); + + cal.setTime(lesson.getStartTime()); + int dow = cal.get(Calendar.DAY_OF_WEEK); + int dayOfWeek = (dow == Calendar.SUNDAY) ? 7 : (dow - 1); + dayVO.setDayOfWeek(dayOfWeek); + dayVO.setLessons(new ArrayList<>()); + + dayMap.put(dateStr, dayVO); + } + dayVO.getLessons().add(lesson); + } + + vo.setDays(new ArrayList<>(dayMap.values())); + return vo; + } + + /** + * 基础规则验证。 + */ + private void validateBasicRules(String planId, Long planCourseId, Integer venueType, + String classRoomId, String siteId, Date startTime, Date endTime) + { + // 1. 时间检查: start < end + if (startTime == null || endTime == null || !startTime.before(endTime)) + { + throw new ServiceException(PlanErrorCode.PLAN_TIME_INVALID_MESSAGE, PlanErrorCode.PLAN_TIME_INVALID); + } + + // 2. 场地二选一互斥检查 + if (venueType == null || (venueType != 1 && venueType != 2)) + { + throw new ServiceException(LessonErrorCode.VENUE_CONFIG_INVALID_MESSAGE, LessonErrorCode.VENUE_CONFIG_INVALID); + } + if (venueType == 1) + { + if (StringUtils.isEmpty(classRoomId) || StringUtils.isNotEmpty(siteId)) + { + throw new ServiceException(LessonErrorCode.VENUE_CONFIG_INVALID_MESSAGE, LessonErrorCode.VENUE_CONFIG_INVALID); + } + } + else + { + if (StringUtils.isEmpty(siteId) || StringUtils.isNotEmpty(classRoomId)) + { + throw new ServiceException(LessonErrorCode.VENUE_CONFIG_INVALID_MESSAGE, LessonErrorCode.VENUE_CONFIG_INVALID); + } + } + + // 3. 培训计划检查 + if (StringUtils.isNotEmpty(planId)) + { + TrainClassPlan plan = trainClassPlanMapper.selectTrainClassPlanByPlanId(planId); + if (plan == null) + { + throw new ServiceException(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND); + } + if (plan.getPlanStatus() != null && (plan.getPlanStatus() == 2 || plan.getPlanStatus() == 3)) + { + throw new ServiceException(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED); + } + if (plan.getStartTime() != null && startTime.before(plan.getStartTime())) + { + throw new ServiceException("课次开始时间不能早于培训计划开始时间", PlanErrorCode.PLAN_TIME_INVALID); + } + if (plan.getEndTime() != null && endTime.after(plan.getEndTime())) + { + throw new ServiceException("课次结束时间不能晚于培训计划结束时间", PlanErrorCode.PLAN_TIME_INVALID); + } + } + + // 4. 课程归属检查 + if (planCourseId != null) + { + TrainPlanCourse planCourse = trainPlanCourseMapper.selectTrainPlanCourseById(planCourseId); + if (planCourse == null) + { + throw new ServiceException(PlanErrorCode.PLAN_COURSE_NOT_FOUND_MESSAGE, 40420); + } + if (StringUtils.isNotEmpty(planId) && !planId.equals(planCourse.getPlanId())) + { + throw new ServiceException(PlanErrorCode.PLAN_COURSE_NOT_FOUND_MESSAGE, 40420); + } + } + } + + /** + * 锁定稳定物理主资源行 (按字典序排序加锁杜绝死锁)。 + */ + private void lockPhysicalResources(Set keys) + { + List sortedKeys = new ArrayList<>(keys); + Collections.sort(sortedKeys); + + for (ResourceLockKey key : sortedKeys) + { + if ("1_TEACHER".equals(key.resourceType)) + { + TrainTeacher teacher = trainLessonMapper.lockTeacherById(key.resourceId); + if (teacher == null || "2".equals(teacher.getDelFlag())) + { + throw new ServiceException(LessonErrorCode.TEACHER_NOT_FOUND_MESSAGE, + LessonErrorCode.TEACHER_NOT_FOUND); + } + } + else if ("2_CLASSROOM".equals(key.resourceType)) + { + TrainClassroomInfo classroom = trainLessonMapper.lockClassroomById(key.resourceId); + if (classroom == null || "2".equals(classroom.getDelFlag())) + { + throw new ServiceException(LessonErrorCode.CLASSROOM_NOT_FOUND_MESSAGE, + LessonErrorCode.CLASSROOM_NOT_FOUND); + } + } + else if ("2_SITE".equals(key.resourceType)) + { + TrainTrainingSite site = trainLessonMapper.lockSiteById(key.resourceId); + if (site == null || "2".equals(site.getDelFlag())) + { + throw new ServiceException(LessonErrorCode.SITE_NOT_FOUND_MESSAGE, + LessonErrorCode.SITE_NOT_FOUND); + } + } + } + } + + /** + * 资源锁排序键。 + */ + private static class ResourceLockKey implements Comparable + { + final String resourceType; // "1_TEACHER", "2_CLASSROOM", "2_SITE" + final String resourceId; + + ResourceLockKey(String resourceType, String resourceId) + { + this.resourceType = resourceType; + this.resourceId = resourceId; + } + + String toLockString() + { + return resourceType + ":" + resourceId; + } + + @Override + public int compareTo(ResourceLockKey o) + { + return this.toLockString().compareTo(o.toLockString()); + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResourceLockKey that = (ResourceLockKey) o; + return Objects.equals(resourceType, that.resourceType) && + Objects.equals(resourceId, that.resourceId); + } + + @Override + public int hashCode() + { + return Objects.hash(resourceType, resourceId); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml new file mode 100644 index 000000000..a885c47c7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select lesson_id, plan_course_id, t_id, start_time, end_time, venue_type, class_room_id, site_id, create_by, create_time, update_by, update_time, remark + from train_lesson + + + + + + insert into train_lesson ( + lesson_id, + plan_course_id, + t_id, + start_time, + end_time, + venue_type, + class_room_id, + site_id, + create_by, + create_time, + remark + ) values ( + #{lessonId}, + #{planCourseId}, + #{tId}, + #{startTime}, + #{endTime}, + #{venueType}, + #{classRoomId}, + #{siteId}, + #{createBy}, + sysdate(), + #{remark} + ) + + + + update train_lesson + + plan_course_id = #{planCourseId}, + t_id = #{tId}, + start_time = #{startTime}, + end_time = #{endTime}, + venue_type = #{venueType}, + class_room_id = #{classRoomId}, + site_id = #{siteId}, + update_by = #{updateBy}, + update_time = sysdate(), + remark = #{remark}, + + where lesson_id = #{lessonId} + + + + delete from train_lesson where lesson_id = #{lessonId} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java new file mode 100644 index 000000000..47f74cd99 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java @@ -0,0 +1,1168 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.constant.LessonErrorCode; +import com.ruoyi.train.constant.PlanErrorCode; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassroomInfo; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.TrainTrainingSite; +import com.ruoyi.train.domain.dto.LessonCreateDTO; +import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; +import com.ruoyi.train.domain.dto.LessonUpdateDTO; +import com.ruoyi.train.domain.query.PlanQuery; +import com.ruoyi.train.domain.vo.ConflictItemVO; +import com.ruoyi.train.domain.vo.LessonConflictVO; +import com.ruoyi.train.domain.vo.PlanItemVO; +import com.ruoyi.train.domain.vo.PlanTimetableVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; + +/** + * ISSUE-04-01 课次编排与冲突检测全方位测试 (包含并发锁与防死锁排序验证)。 + * + * @author ruoyi + */ +public class SchedulingAppServiceTest +{ + private MockLessonMapper lessonMapper; + private MockPlanMapper planMapper; + private MockPlanCourseMapper planCourseMapper; + private SchedulingAppServiceImpl appService; + + private static final String PLAN_ID = "PLAN-2026-001"; + private static final Long PLAN_COURSE_ID = 101L; + private static final String TEACHER_ID = "T001"; + private static final String CLASSROOM_ID = "CR001"; + private static final String SITE_ID = "SITE001"; + + private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + @Before + public void setUp() throws Exception + { + lessonMapper = new MockLessonMapper(); + planMapper = new MockPlanMapper(); + planCourseMapper = new MockPlanCourseMapper(); + appService = new SchedulingAppServiceImpl(lessonMapper, planMapper, planCourseMapper); + + // 初始化培训计划 + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(PLAN_ID); + plan.setPlanName("2026高铁动车组司机专项班"); + plan.setStartTime(sdf.parse("2026-10-01 00:00:00")); + plan.setEndTime(sdf.parse("2026-10-31 23:59:59")); + plan.setPlanStatus(1); // 进行中 + planMapper.plans.put(PLAN_ID, plan); + + // 初始化本期课程 + TrainPlanCourse pc = new TrainPlanCourse(); + pc.setPlanCourseId(PLAN_COURSE_ID); + pc.setPlanId(PLAN_ID); + pc.setCId("C-CRH380"); + planCourseMapper.planCourses.put(PLAN_COURSE_ID, pc); + + // 初始化教师 + TrainTeacher teacher = new TrainTeacher(); + teacher.settId(TEACHER_ID); + teacher.settName("李建国"); + teacher.setDelFlag("0"); + teacher.setStatus(0); + lessonMapper.teachers.put(TEACHER_ID, teacher); + + // 初始化教室 + TrainClassroomInfo cr = new TrainClassroomInfo(); + cr.setClassRoomId(CLASSROOM_ID); + cr.setRoomName("第一多媒体教室"); + cr.setDelFlag("0"); + cr.setRoomStatus(0); + lessonMapper.classrooms.put(CLASSROOM_ID, cr); + + // 初始化实训场地 + TrainTrainingSite site = new TrainTrainingSite(); + site.setSiteId(SITE_ID); + site.setSiteName("CRH380A驾驶模拟实训仓"); + site.setDelFlag("0"); + lessonMapper.sites.put(SITE_ID, site); + } + + /** + * 1. 正常创建课次。 + */ + @Test + public void testNormalCreateLesson() throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + dto.setRemark("正常排课测试"); + + Long lessonId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(lessonId); + + TrainLesson created = lessonMapper.lessons.get(lessonId); + assertNotNull(created); + assertEquals(PLAN_COURSE_ID, created.getPlanCourseId()); + assertEquals(TEACHER_ID, created.getTId()); + assertEquals(CLASSROOM_ID, created.getClassRoomId()); + assertEquals("admin", created.getCreateBy()); + + // 验证调用了物理主资源锁 + assertTrue(lessonMapper.lockedKeys.contains("1_TEACHER:" + TEACHER_ID)); + assertTrue(lessonMapper.lockedKeys.contains("2_CLASSROOM:" + CLASSROOM_ID)); + } + + /** + * 2. planCourse 跨计划拦截。 + */ + @Test + public void testPlanCourseCrossPlan() throws Exception + { + // 创建属于另一个计划的课程 + TrainPlanCourse otherPc = new TrainPlanCourse(); + otherPc.setPlanCourseId(999L); + otherPc.setPlanId("OTHER_PLAN"); + planCourseMapper.planCourses.put(999L, otherPc); + + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(999L); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("应拦截跨计划课程排课"); + } + catch (ServiceException e) + { + assertEquals(40420, e.getCode().intValue()); + } + } + + /** + * 3. 时段合法性校验 start >= end。 + */ + @Test + public void testStartGteEnd() throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + // start > end + dto.setStartTime(sdf.parse("2026-10-10 11:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 09:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("应拦截 start > end"); + } + catch (ServiceException e) + { + assertEquals(PlanErrorCode.PLAN_TIME_INVALID, e.getCode().intValue()); + } + + // start == end + dto.setStartTime(sdf.parse("2026-10-10 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 09:00:00")); + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("应拦截 start == end"); + } + catch (ServiceException e) + { + assertEquals(PlanErrorCode.PLAN_TIME_INVALID, e.getCode().intValue()); + } + } + + /** + * 4. 课次时间超出培训计划周期。 + */ + @Test + public void testLessonOutOfPlanRange() throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + // 早于计划开班 2026-10-01 + dto.setStartTime(sdf.parse("2026-09-28 09:00:00")); + dto.setEndTime(sdf.parse("2026-09-28 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("应拦截早于计划开始时间的排课"); + } + catch (ServiceException e) + { + assertEquals(PlanErrorCode.PLAN_TIME_INVALID, e.getCode().intValue()); + } + } + + /** + * 5. 场地二选一互斥约束校验。 + */ + @Test + public void testVenueMutualExclusion() throws Exception + { + // 场景 A: venueType=1 但 classRoomId 为空 + LessonCreateDTO dto1 = new LessonCreateDTO(); + dto1.setPlanCourseId(PLAN_COURSE_ID); + dto1.setTId(TEACHER_ID); + dto1.setStartTime(sdf.parse("2026-10-10 09:00:00")); + dto1.setEndTime(sdf.parse("2026-10-10 11:00:00")); + dto1.setVenueType(1); + dto1.setClassRoomId(null); + try + { + appService.createLesson(PLAN_ID, dto1, "admin"); + fail("教室排课未填教室应报错"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.VENUE_CONFIG_INVALID, e.getCode().intValue()); + } + + // 场景 B: venueType=1 但同时填了 siteId + dto1.setClassRoomId(CLASSROOM_ID); + dto1.setSiteId(SITE_ID); + try + { + appService.createLesson(PLAN_ID, dto1, "admin"); + fail("教室排课同时填场地应报错"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.VENUE_CONFIG_INVALID, e.getCode().intValue()); + } + + // 场景 C: venueType=2 但 siteId 为空 + LessonCreateDTO dto2 = new LessonCreateDTO(); + dto2.setPlanCourseId(PLAN_COURSE_ID); + dto2.setTId(TEACHER_ID); + dto2.setStartTime(sdf.parse("2026-10-10 09:00:00")); + dto2.setEndTime(sdf.parse("2026-10-10 11:00:00")); + dto2.setVenueType(2); + dto2.setSiteId(null); + try + { + appService.createLesson(PLAN_ID, dto2, "admin"); + fail("场地排课未填场地应报错"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.VENUE_CONFIG_INVALID, e.getCode().intValue()); + } + } + + /** + * 6. 教师时间重叠冲突 (Warning 级别:checkConflict 预检告警,create 允许管理员保存)。 + */ + @Test + public void testTeacherOverlap() throws Exception + { + // 预设现有课次: 教师 T001 在 10:00-12:00 有课 + addExistingLesson(1L, "OTHER_PLAN", 201L, TEACHER_ID, 1, "CR_OTHER", null, + "2026-10-10 10:00:00", "2026-10-10 12:00:00"); + + // 尝试给教师 T001 排入 11:00-13:00 的新课次进行预检 + LessonConflictCheckDTO checkDTO = new LessonConflictCheckDTO(); + checkDTO.setPlanId(PLAN_ID); + checkDTO.setTId(TEACHER_ID); + checkDTO.setStartTime(sdf.parse("2026-10-10 11:00:00")); + checkDTO.setEndTime(sdf.parse("2026-10-10 13:00:00")); + checkDTO.setVenueType(1); + checkDTO.setClassRoomId(CLASSROOM_ID); + + // 预检必须返回冲突告警与明细 + LessonConflictVO conflictVO = appService.checkConflict(checkDTO); + assertTrue(conflictVO.getHasConflict()); + assertEquals("TEACHER", conflictVO.getConflictType()); + assertNotNull(conflictVO.getConflictingLessons()); + assertEquals(1, conflictVO.getConflictingLessons().size()); + + // 无 force 契约下,createLesson 属于管理员业务决策,不强制阻断,允许保存入库 + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 11:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 13:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + Long lessonId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(lessonId); + } + + /** + * 7. 教室时间重叠冲突 (Warning 级别:checkConflict 预检告警,create 允许管理员保存)。 + */ + @Test + public void testClassroomOverlap() throws Exception + { + // 预设教室 CR001 在 14:00-16:00 已被占用 + addExistingLesson(2L, "OTHER_PLAN", 202L, "T_OTHER", 1, CLASSROOM_ID, null, + "2026-10-10 14:00:00", "2026-10-10 16:00:00"); + + LessonConflictCheckDTO checkDTO = new LessonConflictCheckDTO(); + checkDTO.setPlanId(PLAN_ID); + checkDTO.setTId(TEACHER_ID); + checkDTO.setStartTime(sdf.parse("2026-10-10 15:00:00")); + checkDTO.setEndTime(sdf.parse("2026-10-10 17:00:00")); + checkDTO.setVenueType(1); + checkDTO.setClassRoomId(CLASSROOM_ID); + + LessonConflictVO conflictVO = appService.checkConflict(checkDTO); + assertTrue(conflictVO.getHasConflict()); + assertEquals("CLASSROOM", conflictVO.getConflictType()); + + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 15:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 17:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + Long lessonId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(lessonId); + } + + /** + * 8. 实训场地时间重叠冲突 (Warning 级别:checkConflict 预检告警,create 允许管理员保存)。 + */ + @Test + public void testSiteOverlap() throws Exception + { + // 预设场地 SITE001 在 09:00-11:00 已被占用 + addExistingLesson(3L, "OTHER_PLAN", 203L, "T_OTHER", 2, null, SITE_ID, + "2026-10-10 09:00:00", "2026-10-10 11:00:00"); + + LessonConflictCheckDTO checkDTO = new LessonConflictCheckDTO(); + checkDTO.setPlanId(PLAN_ID); + checkDTO.setTId(TEACHER_ID); + checkDTO.setStartTime(sdf.parse("2026-10-10 09:30:00")); + checkDTO.setEndTime(sdf.parse("2026-10-10 10:30:00")); + checkDTO.setVenueType(2); + checkDTO.setSiteId(SITE_ID); + + LessonConflictVO conflictVO = appService.checkConflict(checkDTO); + assertTrue(conflictVO.getHasConflict()); + assertEquals("SITE", conflictVO.getConflictType()); + + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 09:30:00")); + dto.setEndTime(sdf.parse("2026-10-10 10:30:00")); + dto.setVenueType(2); + dto.setSiteId(SITE_ID); + + Long lessonId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(lessonId); + } + + /** + * 9. 同培训计划班级时间重叠冲突 (Warning 级别:checkConflict 预检告警,create 允许管理员保存)。 + */ + @Test + public void testPlanOverlap() throws Exception + { + // 预设当前计划在 08:30-10:00 已有一门课程课次 + addExistingLesson(4L, PLAN_ID, PLAN_COURSE_ID, "T_OTHER", 1, "CR_OTHER", null, + "2026-10-10 08:30:00", "2026-10-10 10:00:00"); + + LessonConflictCheckDTO checkDTO = new LessonConflictCheckDTO(); + checkDTO.setPlanId(PLAN_ID); + checkDTO.setTId(TEACHER_ID); + checkDTO.setStartTime(sdf.parse("2026-10-10 09:00:00")); + checkDTO.setEndTime(sdf.parse("2026-10-10 11:00:00")); + checkDTO.setVenueType(1); + checkDTO.setClassRoomId(CLASSROOM_ID); + + LessonConflictVO conflictVO = appService.checkConflict(checkDTO); + assertTrue(conflictVO.getHasConflict()); + assertEquals("PLAN_OVERLAP", conflictVO.getConflictType()); + + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + Long lessonId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(lessonId); + } + + /** + * 10. 边界判定 end == start 不视为 overlap。 + */ + @Test + public void testBoundaryEndEqualsStartNotOverlap() throws Exception + { + // 现有课次: 08:00 - 10:00 + addExistingLesson(5L, PLAN_ID, PLAN_COURSE_ID, TEACHER_ID, 1, CLASSROOM_ID, null, + "2026-10-10 08:00:00", "2026-10-10 10:00:00"); + + // 新课次: 10:00 - 12:00 (前一课的 end 恰好等于新课的 start) + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-10 10:00:00")); + dto.setEndTime(sdf.parse("2026-10-10 12:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + Long newId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(newId); + } + + /** + * 11. 并发排课稳定物理主资源锁与并发有序落库验证。 + */ + @Test + public void testConcurrentFirstInsertLockStrategy() throws Exception + { + // 模拟多线程并发向同一时段排入课次 + int threadCount = 4; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + + AtomicInteger successCount = new AtomicInteger(0); + + final Date sTime = sdf.parse("2026-10-15 14:00:00"); + final Date eTime = sdf.parse("2026-10-15 16:00:00"); + + List> futures = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) + { + final int index = i; + futures.add(executor.submit(() -> { + readyLatch.countDown(); + try + { + startLatch.await(); + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sTime); + dto.setEndTime(eTime); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + appService.createLesson(PLAN_ID, dto, "thread-" + index); + successCount.incrementAndGet(); + } + catch (Exception e) + { + e.printStackTrace(); + } + finally + { + lessonMapper.releaseHeldLocks(); + } + })); + } + + readyLatch.await(); + startLatch.countDown(); + + for (Future f : futures) + { + f.get(); + } + executor.shutdown(); + + // 验证:在主资源排他锁与事务保护下,4个并发线程均有序安全创建成功 + assertEquals("多线程并发排课在主资源锁保护下全部有序创建成功", threadCount, successCount.get()); + } + + /** + * 12. Update 资源变更时的排序锁防死锁验证。 + */ + @Test + public void testUpdateResourceSortingLock() throws Exception + { + // 准备已有课次: 原教师 T002,原教室 CR002 + TrainTeacher t2 = new TrainTeacher(); + t2.settId("T002"); + t2.settName("王教授"); + t2.setDelFlag("0"); + t2.setStatus(0); + lessonMapper.teachers.put("T002", t2); + + TrainClassroomInfo cr2 = new TrainClassroomInfo(); + cr2.setClassRoomId("CR002"); + cr2.setRoomName("第二教室"); + cr2.setDelFlag("0"); + lessonMapper.classrooms.put("CR002", cr2); + + TrainLesson lesson = addExistingLesson(10L, PLAN_ID, PLAN_COURSE_ID, "T002", 1, "CR002", null, + "2026-10-18 09:00:00", "2026-10-18 11:00:00"); + + // 修改为: 新教师 T001 (字典序小于 T002),新教室 CR001 (字典序小于 CR002) + LessonUpdateDTO updateDTO = new LessonUpdateDTO(); + updateDTO.setPlanCourseId(PLAN_COURSE_ID); + updateDTO.setTId(TEACHER_ID); // T001 + updateDTO.setStartTime(sdf.parse("2026-10-18 14:00:00")); + updateDTO.setEndTime(sdf.parse("2026-10-18 16:00:00")); + updateDTO.setVenueType(1); + updateDTO.setClassRoomId(CLASSROOM_ID); // CR001 + + lessonMapper.lockSequence.clear(); + appService.updateLesson(10L, updateDTO, "admin"); + + // 验证加锁顺序:必须严格按照 "1_TEACHER:T001", "1_TEACHER:T002", "2_CLASSROOM:CR001", "2_CLASSROOM:CR002" 升序执行 + List expectedOrder = Arrays.asList( + "1_TEACHER:T001", + "1_TEACHER:T002", + "2_CLASSROOM:CR001", + "2_CLASSROOM:CR002" + ); + assertEquals(expectedOrder, lessonMapper.lockSequence); + } + + /** + * 13. 课次已有考勤记录禁止修改关键时间 (40044)。 + */ + @Test + public void testExistingAttendanceForbiddenCriticalTimeUpdate() throws Exception + { + Long lessonId = 20L; + addExistingLesson(lessonId, PLAN_ID, PLAN_COURSE_ID, TEACHER_ID, 1, CLASSROOM_ID, null, + "2026-10-20 09:00:00", "2026-10-20 11:00:00"); + + // 模拟已有打卡考勤 + lessonMapper.attendanceCounts.put(lessonId, 5); + + // 场景 A: 尝试修改关键开始时间 + LessonUpdateDTO dto1 = new LessonUpdateDTO(); + dto1.setPlanCourseId(PLAN_COURSE_ID); + dto1.setTId(TEACHER_ID); + dto1.setStartTime(sdf.parse("2026-10-20 09:30:00")); // 修改了时间 + dto1.setEndTime(sdf.parse("2026-10-20 11:00:00")); + dto1.setVenueType(1); + dto1.setClassRoomId(CLASSROOM_ID); + + try + { + appService.updateLesson(lessonId, dto1, "admin"); + fail("已有考勤修改时间应阻断"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.LESSON_ALREADY_HAS_ATTENDANCE, e.getCode().intValue()); + } + + // 场景 B: 保持关键时间不变,仅修改备注等允许更新 + LessonUpdateDTO dto2 = new LessonUpdateDTO(); + dto2.setPlanCourseId(PLAN_COURSE_ID); + dto2.setTId(TEACHER_ID); + dto2.setStartTime(sdf.parse("2026-10-20 09:00:00")); // 保持原时间 + dto2.setEndTime(sdf.parse("2026-10-20 11:00:00")); + dto2.setVenueType(1); + dto2.setClassRoomId(CLASSROOM_ID); + dto2.setRemark("允许修改备注信息"); + + appService.updateLesson(lessonId, dto2, "admin"); + assertEquals("允许修改备注信息", lessonMapper.lessons.get(lessonId).getRemark()); + } + + /** + * 14. 课次已有考勤记录禁止删除 (40044)。 + */ + @Test + public void testExistingAttendanceForbiddenDelete() throws Exception + { + Long lessonId = 30L; + addExistingLesson(lessonId, PLAN_ID, PLAN_COURSE_ID, TEACHER_ID, 1, CLASSROOM_ID, null, + "2026-10-21 09:00:00", "2026-10-21 11:00:00"); + + lessonMapper.attendanceCounts.put(lessonId, 1); + + try + { + appService.deleteLesson(lessonId, "admin"); + fail("已有考勤删除应阻断"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.LESSON_ALREADY_HAS_ATTENDANCE, e.getCode().intValue()); + } + + // 无考勤时可正常删除 + lessonMapper.attendanceCounts.put(lessonId, 0); + appService.deleteLesson(lessonId, "admin"); + assertFalse(lessonMapper.lessons.containsKey(lessonId)); + } + + /** + * 15. 排课冲突预检接口 (UC-10)。 + */ + @Test + public void testCheckConflictAPI() throws Exception + { + addExistingLesson(40L, PLAN_ID, PLAN_COURSE_ID, TEACHER_ID, 1, CLASSROOM_ID, null, + "2026-10-22 09:00:00", "2026-10-22 11:00:00"); + + LessonConflictCheckDTO checkDTO = new LessonConflictCheckDTO(); + checkDTO.setPlanId(PLAN_ID); + checkDTO.setTId(TEACHER_ID); + checkDTO.setStartTime(sdf.parse("2026-10-22 10:00:00")); + checkDTO.setEndTime(sdf.parse("2026-10-22 12:00:00")); + checkDTO.setVenueType(1); + checkDTO.setClassRoomId("CR_OTHER"); + + LessonConflictVO vo = appService.checkConflict(checkDTO); + assertTrue(vo.getHasConflict()); + assertEquals("TEACHER", vo.getConflictType()); + assertNotNull(vo.getConflictingLessons()); + assertEquals(1, vo.getConflictingLessons().size()); + + // 无冲突测试 + checkDTO.setStartTime(sdf.parse("2026-10-23 09:00:00")); + checkDTO.setEndTime(sdf.parse("2026-10-23 11:00:00")); + LessonConflictVO voNoConflict = appService.checkConflict(checkDTO); + assertFalse(voNoConflict.getHasConflict()); + assertEquals("NONE", voNoConflict.getConflictType()); + } + + /** + * 16. 完整课程表聚合查看 (UC-11) 与 CG-02 TimetableLessonItemVO 字段精确回显测试。 + */ + @Test + public void testGetPlanTimetable() throws Exception + { + // 排入两条不同日期的课次 + addExistingLesson(51L, PLAN_ID, PLAN_COURSE_ID, TEACHER_ID, 1, CLASSROOM_ID, null, + "2026-10-24 09:00:00", "2026-10-24 11:00:00"); + addExistingLesson(52L, PLAN_ID, PLAN_COURSE_ID, TEACHER_ID, 1, CLASSROOM_ID, null, + "2026-10-25 14:00:00", "2026-10-25 16:00:00"); + + // 模拟教室 CR001 仅绑定 1 个有效班牌 + lessonMapper.classroomBoards.put(CLASSROOM_ID, Collections.singletonList("BOARD-SN-001")); + + PlanTimetableVO timetable = appService.getPlanTimetable(PLAN_ID); + assertNotNull(timetable); + assertEquals(PLAN_ID, timetable.getPlanId()); + assertEquals(2, timetable.getDays().size()); + assertEquals("2026-10-24", timetable.getDays().get(0).getDate()); + assertEquals("2026-10-25", timetable.getDays().get(1).getDate()); + // 2026-10-24 是周六 (dayOfWeek = 6) + assertEquals(Integer.valueOf(6), timetable.getDays().get(0).getDayOfWeek()); + // 2026-10-25 是周日 (dayOfWeek = 7) + assertEquals(Integer.valueOf(7), timetable.getDays().get(1).getDayOfWeek()); + + // 验证 CG-02 裁决:返回类型为 TimetableLessonItemVO,且携带回显表单必需字段 + TimetableLessonItemVO lessonItem = timetable.getDays().get(0).getLessons().get(0); + assertNotNull(lessonItem); + assertEquals(Long.valueOf(51L), lessonItem.getLessonId()); + assertEquals(PLAN_COURSE_ID, lessonItem.getPlanCourseId()); + assertEquals(TEACHER_ID, lessonItem.getTId()); + assertEquals(Integer.valueOf(1), lessonItem.getVenueType()); + assertEquals(CLASSROOM_ID, lessonItem.getClassRoomId()); + // 恰好1个班牌时正常返回 + assertEquals("BOARD-SN-001", lessonItem.getBoardSn()); + + // 验证 CG-05 保守语义:当教室绑定多个班牌(>1)时,不擅自挑选,返回 null + lessonMapper.classroomBoards.put(CLASSROOM_ID, Arrays.asList("BOARD-SN-A", "BOARD-SN-B")); + PlanTimetableVO timetableMultiBoards = appService.getPlanTimetable(PLAN_ID); + assertNull(timetableMultiBoards.getDays().get(0).getLessons().get(0).getBoardSn()); + } + + /** + * 17. 教师物理资源不存在 (Hard Block 40401)。 + */ + @Test + public void testTeacherNotFoundHardBlock() throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId("NON_EXIST_TEACHER"); + dto.setStartTime(sdf.parse("2026-10-12 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-12 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("教师不存在应硬阻断"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.TEACHER_NOT_FOUND, e.getCode().intValue()); + } + } + + /** + * 18. 普通教室物理资源不存在 (Hard Block 40408)。 + */ + @Test + public void testClassroomNotFoundHardBlock() throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-12 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-12 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId("NON_EXIST_ROOM"); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("教室不存在应硬阻断"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.CLASSROOM_NOT_FOUND, e.getCode().intValue()); + } + } + + /** + * 19. 实训场地物理资源不存在 (Hard Block 40409)。 + */ + @Test + public void testSiteNotFoundHardBlock() throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(TEACHER_ID); + dto.setStartTime(sdf.parse("2026-10-12 09:00:00")); + dto.setEndTime(sdf.parse("2026-10-12 11:00:00")); + dto.setVenueType(2); + dto.setSiteId("NON_EXIST_SITE"); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("实训场地不存在应硬阻断"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.SITE_NOT_FOUND, e.getCode().intValue()); + } + } + + private TrainLesson addExistingLesson(Long lessonId, String planId, Long planCourseId, String tId, + Integer venueType, String classRoomId, String siteId, + String startTimeStr, String endTimeStr) throws Exception + { + TrainLesson lesson = new TrainLesson(); + lesson.setLessonId(lessonId); + lesson.setPlanCourseId(planCourseId); + lesson.setTId(tId); + lesson.setVenueType(venueType); + lesson.setClassRoomId(classRoomId); + lesson.setSiteId(siteId); + lesson.setStartTime(sdf.parse(startTimeStr)); + lesson.setEndTime(sdf.parse(endTimeStr)); + lessonMapper.lessons.put(lessonId, lesson); + + if (!planCourseMapper.planCourses.containsKey(planCourseId)) + { + TrainPlanCourse pc = new TrainPlanCourse(); + pc.setPlanCourseId(planCourseId); + pc.setPlanId(planId); + planCourseMapper.planCourses.put(planCourseId, pc); + } + return lesson; + } + + /** + * 模拟 Lesson Mapper。 + */ + private static class MockLessonMapper implements TrainLessonMapper + { + Map lessons = new HashMap<>(); + Map teachers = new HashMap<>(); + Map classrooms = new HashMap<>(); + Map sites = new HashMap<>(); + Map attendanceCounts = new HashMap<>(); + Set lockedKeys = new HashSet<>(); + List lockSequence = new ArrayList<>(); + AtomicLong idGen = new AtomicLong(1000L); + + Map resourceLocks = new java.util.concurrent.ConcurrentHashMap<>(); + ThreadLocal> heldLocks = ThreadLocal.withInitial(ArrayList::new); + + public void releaseHeldLocks() + { + List list = heldLocks.get(); + for (java.util.concurrent.locks.ReentrantLock l : list) + { + if (l.isHeldByCurrentThread()) + { + l.unlock(); + } + } + list.clear(); + } + + @Override + public TrainLesson selectTrainLessonById(Long lessonId) + { + return lessons.get(lessonId); + } + + @Override + public synchronized int insertTrainLesson(TrainLesson trainLesson) + { + if (trainLesson.getLessonId() == null) + { + trainLesson.setLessonId(idGen.incrementAndGet()); + } + lessons.put(trainLesson.getLessonId(), trainLesson); + return 1; + } + + @Override + public synchronized int updateTrainLesson(TrainLesson trainLesson) + { + lessons.put(trainLesson.getLessonId(), trainLesson); + return 1; + } + + @Override + public synchronized int deleteTrainLessonById(Long lessonId) + { + lessons.remove(lessonId); + return 1; + } + + @Override + public TrainTeacher lockTeacherById(String tId) + { + java.util.concurrent.locks.ReentrantLock lock = resourceLocks.computeIfAbsent("1_TEACHER:" + tId, k -> new java.util.concurrent.locks.ReentrantLock(true)); + lock.lock(); + heldLocks.get().add(lock); + + synchronized (this) + { + lockedKeys.add("1_TEACHER:" + tId); + lockSequence.add("1_TEACHER:" + tId); + return teachers.get(tId); + } + } + + @Override + public TrainClassroomInfo lockClassroomById(String classRoomId) + { + java.util.concurrent.locks.ReentrantLock lock = resourceLocks.computeIfAbsent("2_CLASSROOM:" + classRoomId, k -> new java.util.concurrent.locks.ReentrantLock(true)); + lock.lock(); + heldLocks.get().add(lock); + + synchronized (this) + { + lockedKeys.add("2_CLASSROOM:" + classRoomId); + lockSequence.add("2_CLASSROOM:" + classRoomId); + return classrooms.get(classRoomId); + } + } + + @Override + public TrainTrainingSite lockSiteById(String siteId) + { + java.util.concurrent.locks.ReentrantLock lock = resourceLocks.computeIfAbsent("2_SITE:" + siteId, k -> new java.util.concurrent.locks.ReentrantLock(true)); + lock.lock(); + heldLocks.get().add(lock); + + synchronized (this) + { + lockedKeys.add("2_SITE:" + siteId); + lockSequence.add("2_SITE:" + siteId); + return sites.get(siteId); + } + } + + @Override + public int countAttendanceByLessonId(Long lessonId) + { + return attendanceCounts.getOrDefault(lessonId, 0); + } + + @Override + public synchronized List selectTeacherConflicts(String tId, Date startTime, Date endTime, Long excludeLessonId) + { + List res = new ArrayList<>(); + for (TrainLesson l : lessons.values()) + { + if (excludeLessonId != null && excludeLessonId.equals(l.getLessonId())) continue; + if (tId.equals(l.getTId()) && isOverlap(l.getStartTime(), l.getEndTime(), startTime, endTime)) + { + res.add(toConflictItem(l)); + } + } + return res; + } + + @Override + public synchronized List selectClassroomConflicts(String classRoomId, Date startTime, Date endTime, Long excludeLessonId) + { + List res = new ArrayList<>(); + for (TrainLesson l : lessons.values()) + { + if (excludeLessonId != null && excludeLessonId.equals(l.getLessonId())) continue; + if (l.getVenueType() != null && l.getVenueType() == 1 && + classRoomId.equals(l.getClassRoomId()) && + isOverlap(l.getStartTime(), l.getEndTime(), startTime, endTime)) + { + res.add(toConflictItem(l)); + } + } + return res; + } + + @Override + public synchronized List selectSiteConflicts(String siteId, Date startTime, Date endTime, Long excludeLessonId) + { + List res = new ArrayList<>(); + for (TrainLesson l : lessons.values()) + { + if (excludeLessonId != null && excludeLessonId.equals(l.getLessonId())) continue; + if (l.getVenueType() != null && l.getVenueType() == 2 && + siteId.equals(l.getSiteId()) && + isOverlap(l.getStartTime(), l.getEndTime(), startTime, endTime)) + { + res.add(toConflictItem(l)); + } + } + return res; + } + + @Override + public synchronized List selectPlanConflicts(String planId, Date startTime, Date endTime, Long excludeLessonId) + { + List res = new ArrayList<>(); + for (TrainLesson l : lessons.values()) + { + if (excludeLessonId != null && excludeLessonId.equals(l.getLessonId())) continue; + if (isOverlap(l.getStartTime(), l.getEndTime(), startTime, endTime)) + { + res.add(toConflictItem(l)); + } + } + return res; + } + + Map> classroomBoards = new HashMap<>(); + + @Override + public List selectScheduleLessonsByPlanId(String planId) + { + List list = new ArrayList<>(); + for (TrainLesson l : lessons.values()) + { + TimetableLessonItemVO vo = new TimetableLessonItemVO(); + vo.setLessonId(l.getLessonId()); + vo.setPlanCourseId(l.getPlanCourseId()); + vo.setTId(l.getTId()); + vo.setStartTime(l.getStartTime()); + vo.setEndTime(l.getEndTime()); + vo.setVenueType(l.getVenueType()); + vo.setClassRoomId(l.getClassRoomId()); + vo.setSiteId(l.getSiteId()); + vo.setRemark(l.getRemark()); + + // CG-05 / 1:N 保守语义:恰好1个有效班牌返回SN,0或多个返回null + if (l.getVenueType() != null && l.getVenueType() == 1 && l.getClassRoomId() != null) + { + List boards = classroomBoards.get(l.getClassRoomId()); + if (boards != null && boards.size() == 1) + { + vo.setBoardSn(boards.get(0)); + } + else + { + vo.setBoardSn(null); + } + } + + list.add(vo); + } + list.sort((a, b) -> a.getStartTime().compareTo(b.getStartTime())); + return list; + } + + private boolean isOverlap(Date s1, Date e1, Date s2, Date e2) + { + return s1.before(e2) && e1.after(s2); + } + + private ConflictItemVO toConflictItem(TrainLesson l) + { + ConflictItemVO vo = new ConflictItemVO(); + vo.setLessonId(l.getLessonId()); + vo.setStartTime(l.getStartTime()); + vo.setEndTime(l.getEndTime()); + return vo; + } + } + + /** + * 模拟 Plan Mapper。 + */ + private static class MockPlanMapper implements TrainClassPlanMapper + { + Map plans = new HashMap<>(); + + @Override + public TrainClassPlan selectTrainClassPlanByPlanId(String planId) + { + return plans.get(planId); + } + + @Override + public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId) + { + return plans.get(planId); + } + + @Override + public List selectTrainClassPlanList(PlanQuery query) + { + return Collections.emptyList(); + } + + @Override + public int countTrainClassPlanByName(String planName, String excludePlanId) + { + return 0; + } + + @Override + public int insertTrainClassPlan(TrainClassPlan trainClassPlan) + { + plans.put(trainClassPlan.getPlanId(), trainClassPlan); + return 1; + } + + @Override + public int updateTrainClassPlan(TrainClassPlan trainClassPlan) + { + plans.put(trainClassPlan.getPlanId(), trainClassPlan); + return 1; + } + + @Override + public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan) + { + plans.put(trainClassPlan.getPlanId(), trainClassPlan); + return 1; + } + } + + /** + * 模拟 PlanCourse Mapper。 + */ + private static class MockPlanCourseMapper implements TrainPlanCourseMapper + { + Map planCourses = new HashMap<>(); + + @Override + public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId) + { + return planCourses.get(planCourseId); + } + + @Override + public List selectPlanCourseListByPlanId(String planId) + { + return Collections.emptyList(); + } + + @Override + public List selectExistingCIdsByPlanId(String planId, List cIds) + { + return Collections.emptyList(); + } + + @Override + public int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse) + { + planCourses.put(trainPlanCourse.getPlanCourseId(), trainPlanCourse); + return 1; + } + + @Override + public int batchInsertTrainPlanCourse(List list) + { + for (TrainPlanCourse pc : list) + { + planCourses.put(pc.getPlanCourseId(), pc); + } + return list.size(); + } + + @Override + public int deleteTrainPlanCourseById(Long planCourseId) + { + planCourses.remove(planCourseId); + return 1; + } + + @Override + public int countLessonsByPlanCourseId(Long planCourseId) + { + return 0; + } + + @Override + public int countScoreItemsByPlanCourseId(Long planCourseId) + { + return 0; + } + + @Override + public int countStudyDurationsByPlanCourseId(Long planCourseId) + { + return 0; + } + } +}