新增培训:实现课次排课P0能力

This commit is contained in:
wangzhibiao committed 2026-09-14 15:19:28 +08:00
1 parent e340db872c
commit fb87c95506
17 files changed
+3500

No files matched your search

@@ -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));
}
}
@@ -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);
}
}
@@ -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()
{
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<ConflictItemVO> 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<ConflictItemVO> getConflictingLessons()
{
return conflictingLessons;
}
public void setConflictingLessons(List<ConflictItemVO> conflictingLessons)
{
this.conflictingLessons = conflictingLessons;
}
}
@@ -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<TimetableDayVO> 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<TimetableDayVO> getDays()
{
return days;
}
public void setDays(List<TimetableDayVO> days)
{
this.days = days;
}
}
@@ -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<TimetableLessonItemVO> 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<TimetableLessonItemVO> getLessons()
{
return lessons;
}
public void setLessons(List<TimetableLessonItemVO> lessons)
{
this.lessons = lessons;
}
}
@@ -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;
}
}
@@ -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<ConflictItemVO> selectTeacherConflicts(@Param("tId") String tId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime,
@Param("excludeLessonId") Long excludeLessonId);
/**
* 查询普通教室时间重叠冲突课次。
*/
List<ConflictItemVO> selectClassroomConflicts(@Param("classRoomId") String classRoomId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime,
@Param("excludeLessonId") Long excludeLessonId);
/**
* 查询实训场地时间重叠冲突课次。
*/
List<ConflictItemVO> selectSiteConflicts(@Param("siteId") String siteId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime,
@Param("excludeLessonId") Long excludeLessonId);
/**
* 查询本期培训计划时间重叠冲突课次。
*/
List<ConflictItemVO> selectPlanConflicts(@Param("planId") String planId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime,
@Param("excludeLessonId") Long excludeLessonId);
/**
* 获取指定培训计划下的全部课次日程明细 (按开始时间升序排列)。
*/
List<TimetableLessonItemVO> selectScheduleLessonsByPlanId(@Param("planId") String planId);
}
@@ -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);
}
@@ -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<ResourceLockKey> 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<ResourceLockKey> 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<ConflictItemVO> 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<ConflictItemVO> 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<ConflictItemVO> 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<ConflictItemVO> 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<TimetableLessonItemVO> lessonList = trainLessonMapper.selectScheduleLessonsByPlanId(planId);
if (lessonList == null || lessonList.isEmpty())
{
vo.setDays(new ArrayList<>());
return vo;
}
// 按日期聚合课次
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Map<String, TimetableDayVO> 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<ResourceLockKey> keys)
{
List<ResourceLockKey> 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<ResourceLockKey>
{
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);
}
}
}
@@ -0,0 +1,278 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainLessonMapper">
<resultMap type="TrainLesson" id="TrainLessonResult">
<result property="lessonId" column="lesson_id" />
<result property="planCourseId" column="plan_course_id" />
<result property="tId" column="t_id" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="venueType" column="venue_type" />
<result property="classRoomId" column="class_room_id" />
<result property="siteId" column="site_id" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<resultMap type="ConflictItemVO" id="ConflictItemResult">
<result property="lessonId" column="lesson_id" />
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="courseName" column="course_name" />
<result property="teacherName" column="teacher_name" />
<result property="venueName" column="venue_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
</resultMap>
<resultMap type="TimetableLessonItemVO" id="TimetableLessonItemResult">
<result property="lessonId" column="lesson_id" />
<result property="planCourseId" column="plan_course_id" />
<result property="courseName" column="course_name" />
<result property="tId" column="t_id" />
<result property="teacherName" column="teacher_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="venueType" column="venue_type" />
<result property="classRoomId" column="class_room_id" />
<result property="classRoomName" column="class_room_name" />
<result property="siteId" column="site_id" />
<result property="siteName" column="site_name" />
<result property="venueName" column="venue_name" />
<result property="boardSn" column="board_sn" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainLessonVo">
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
</sql>
<select id="selectTrainLessonById" parameterType="Long" resultMap="TrainLessonResult">
<include refid="selectTrainLessonVo"/>
where lesson_id = #{lessonId}
</select>
<insert id="insertTrainLesson" parameterType="TrainLesson" useGeneratedKeys="true" keyProperty="lessonId">
insert into train_lesson (
<if test="lessonId != null">lesson_id,</if>
plan_course_id,
t_id,
start_time,
end_time,
venue_type,
class_room_id,
site_id,
create_by,
create_time,
remark
) values (
<if test="lessonId != null">#{lessonId},</if>
#{planCourseId},
#{tId},
#{startTime},
#{endTime},
#{venueType},
#{classRoomId},
#{siteId},
#{createBy},
sysdate(),
#{remark}
)
</insert>
<update id="updateTrainLesson" parameterType="TrainLesson">
update train_lesson
<trim prefix="SET" suffixOverrides=",">
<if test="planCourseId != null">plan_course_id = #{planCourseId},</if>
<if test="tId != null and tId != ''">t_id = #{tId},</if>
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="venueType != null">venue_type = #{venueType},</if>
class_room_id = #{classRoomId},
site_id = #{siteId},
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate(),
<if test="remark != null">remark = #{remark},</if>
</trim>
where lesson_id = #{lessonId}
</update>
<delete id="deleteTrainLessonById" parameterType="Long">
delete from train_lesson where lesson_id = #{lessonId}
</delete>
<!-- 锁定稳定教师主表行 (FOR UPDATE) -->
<select id="lockTeacherById" resultType="com.ruoyi.train.domain.TrainTeacher">
select t_id, t_name, status, del_flag
from train_teacher
where t_id = #{tId}
for update
</select>
<!-- 锁定稳定教室主表行 (FOR UPDATE) -->
<select id="lockClassroomById" resultType="com.ruoyi.train.domain.TrainClassroomInfo">
select class_room_id, room_name, room_status, del_flag
from train_classroom_info
where class_room_id = #{classRoomId}
for update
</select>
<!-- 锁定稳定实训场地主表行 (FOR UPDATE) -->
<select id="lockSiteById" resultType="com.ruoyi.train.domain.TrainTrainingSite">
select site_id, site_name, capacity, del_flag
from train_training_site
where site_id = #{siteId}
for update
</select>
<!-- 统计课次已有考勤签到记录数 -->
<select id="countAttendanceByLessonId" resultType="int">
select count(*) from train_student_checkin where lesson_id = #{lessonId}
</select>
<!-- 查询教师重叠课次 -->
<select id="selectTeacherConflicts" resultMap="ConflictItemResult">
select l.lesson_id,
p.plan_id,
p.plan_name,
c.c_name as course_name,
t.t_name as teacher_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
l.start_time,
l.end_time
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
inner join train_class_plan p on pc.plan_id = p.plan_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
where l.t_id = #{tId}
and l.start_time &lt; #{endTime}
and l.end_time &gt; #{startTime}
<if test="excludeLessonId != null">
and l.lesson_id != #{excludeLessonId}
</if>
order by l.start_time asc
</select>
<!-- 查询教室重叠课次 -->
<select id="selectClassroomConflicts" resultMap="ConflictItemResult">
select l.lesson_id,
p.plan_id,
p.plan_name,
c.c_name as course_name,
t.t_name as teacher_name,
cr.room_name as venue_name,
l.start_time,
l.end_time
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
inner join train_class_plan p on pc.plan_id = p.plan_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
where l.venue_type = 1
and l.class_room_id = #{classRoomId}
and l.start_time &lt; #{endTime}
and l.end_time &gt; #{startTime}
<if test="excludeLessonId != null">
and l.lesson_id != #{excludeLessonId}
</if>
order by l.start_time asc
</select>
<!-- 查询实训场地重叠课次 -->
<select id="selectSiteConflicts" resultMap="ConflictItemResult">
select l.lesson_id,
p.plan_id,
p.plan_name,
c.c_name as course_name,
t.t_name as teacher_name,
ts.site_name as venue_name,
l.start_time,
l.end_time
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
inner join train_class_plan p on pc.plan_id = p.plan_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_training_site ts on l.site_id = ts.site_id
where l.venue_type = 2
and l.site_id = #{siteId}
and l.start_time &lt; #{endTime}
and l.end_time &gt; #{startTime}
<if test="excludeLessonId != null">
and l.lesson_id != #{excludeLessonId}
</if>
order by l.start_time asc
</select>
<!-- 查询同培训计划班级重叠课次 -->
<select id="selectPlanConflicts" resultMap="ConflictItemResult">
select l.lesson_id,
p.plan_id,
p.plan_name,
c.c_name as course_name,
t.t_name as teacher_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
l.start_time,
l.end_time
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
inner join train_class_plan p on pc.plan_id = p.plan_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
where pc.plan_id = #{planId}
and l.start_time &lt; #{endTime}
and l.end_time &gt; #{startTime}
<if test="excludeLessonId != null">
and l.lesson_id != #{excludeLessonId}
</if>
order by l.start_time asc
</select>
<!-- 查询指定计划下的课次日程列表 -->
<select id="selectScheduleLessonsByPlanId" resultMap="TimetableLessonItemResult">
select l.lesson_id,
l.plan_course_id,
c.c_name as course_name,
l.t_id,
t.t_name as teacher_name,
l.start_time,
l.end_time,
l.venue_type,
l.class_room_id,
cr.room_name as class_room_name,
l.site_id,
ts.site_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
b.board_sn,
l.remark
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
left join (
select bind_classroom_id,
case when count(*) = 1 then min(board_sn) else null end as board_sn
from train_electronic_board
where del_flag = '0'
group by bind_classroom_id
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
where pc.plan_id = #{planId}
order by l.start_time asc, l.lesson_id asc
</select>
</mapper>