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 index 62213e4e0..075587538 100644 --- 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 @@ -82,6 +82,17 @@ public class TrainLessonController extends BaseController return success(schedulingAppService.checkConflict(dto)); } + /** + * 获取排课候选资源选项 (教师 / 教室 / 实训场地)。 + * 排课表单下拉专用只读接口,一次性全量返回,不分页也不设条数上限。 + */ + @PreAuthorize("@ss.hasPermi('train:lesson:query')") + @GetMapping("/train/lessons/scheduling-options") + public AjaxResult getSchedulingOptions() + { + return success(schedulingAppService.getSchedulingOptions()); + } + /** * 获取计划完整课程表聚合 (UC-11 / 编辑回显必需)。 */ 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 index 45e9cd92e..1ab9c5f89 100644 --- 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 @@ -56,6 +56,12 @@ public class TrainLessonControllerTest 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()); + + // 6. 排课候选资源选项 (教师/教室/实训场地全量只读,不分页) + Method options = TrainLessonController.class.getMethod("getSchedulingOptions"); + assertNotNull(options.getAnnotation(GetMapping.class)); + assertEquals("/train/lessons/scheduling-options", options.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:lesson:query')", options.getAnnotation(PreAuthorize.class).value()); } @Test @@ -90,7 +96,7 @@ public class TrainLessonControllerTest } @Test - public void controllerMustOnlyExposeFrozenFiveEndpoints() + public void controllerMustOnlyExposeFrozenSixEndpoints() { int endpointCount = 0; for (Method method : TrainLessonController.class.getDeclaredMethods()) @@ -104,6 +110,7 @@ public class TrainLessonControllerTest endpointCount++; } } - assertEquals("Controller 必须不多不少正好暴露 5 个冻结端点", 5, endpointCount); + // 5 个原冻结端点 + 1 个排课候选资源选项只读端点 + assertEquals("Controller 必须不多不少正好暴露 6 个冻结端点", 6, 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 index de2e4be62..f1f14b6f4 100644 --- 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 @@ -25,6 +25,9 @@ public final class LessonErrorCode /** 排课场地配置不合法:普通教室与实训场地必须二选一 */ public static final int VENUE_CONFIG_INVALID = 40045; + /** 授课教师已停用:不能被指派新的授课任务 */ + public static final int TEACHER_DISABLED = 40046; + /** 排课课次不存在 */ public static final int LESSON_NOT_FOUND = 40440; @@ -46,6 +49,7 @@ public final class LessonErrorCode 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 TEACHER_DISABLED_MESSAGE = "该教师当前已停用,不能安排新的授课任务"; public static final String CLASSROOM_NOT_FOUND_MESSAGE = "教学教室不存在或已无效"; public static final String SITE_NOT_FOUND_MESSAGE = "实训场地不存在或已无效"; 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 index 2ed2c6cad..337cbe3ea 100644 --- 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 @@ -1,5 +1,6 @@ package com.ruoyi.train.service; +import java.util.Map; import com.ruoyi.train.domain.dto.LessonCreateDTO; import com.ruoyi.train.domain.dto.LessonConflictCheckDTO; import com.ruoyi.train.domain.dto.LessonUpdateDTO; @@ -55,4 +56,12 @@ public interface SchedulingAppService * @return 完整课程表 VO */ PlanTimetableVO getPlanTimetable(String planId); + + /** + * 获取排课候选资源选项 (教师 / 教室 / 实训场地)。 + * 供排课表单下拉一次性全量加载,服务端不做分页与任何隐式条数上限。 + * + * @return 候选资源集合,键为 teachers / classrooms / sites + */ + Map getSchedulingOptions(); } 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 index c84c49fd8..846e3eacc 100644 --- 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 @@ -35,8 +35,11 @@ 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.TrainClassroomInfoMapper; import com.ruoyi.train.mapper.TrainLessonMapper; import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.mapper.TrainTrainingSiteMapper; import com.ruoyi.train.service.SchedulingAppService; /** @@ -52,15 +55,24 @@ public class SchedulingAppServiceImpl implements SchedulingAppService private final TrainLessonMapper trainLessonMapper; private final TrainClassPlanMapper trainClassPlanMapper; private final TrainPlanCourseMapper trainPlanCourseMapper; + private final TrainTeacherMapper trainTeacherMapper; + private final TrainClassroomInfoMapper trainClassroomInfoMapper; + private final TrainTrainingSiteMapper trainTrainingSiteMapper; @Autowired public SchedulingAppServiceImpl(TrainLessonMapper trainLessonMapper, TrainClassPlanMapper trainClassPlanMapper, - TrainPlanCourseMapper trainPlanCourseMapper) + TrainPlanCourseMapper trainPlanCourseMapper, + TrainTeacherMapper trainTeacherMapper, + TrainClassroomInfoMapper trainClassroomInfoMapper, + TrainTrainingSiteMapper trainTrainingSiteMapper) { this.trainLessonMapper = trainLessonMapper; this.trainClassPlanMapper = trainClassPlanMapper; this.trainPlanCourseMapper = trainPlanCourseMapper; + this.trainTeacherMapper = trainTeacherMapper; + this.trainClassroomInfoMapper = trainClassroomInfoMapper; + this.trainTrainingSiteMapper = trainTrainingSiteMapper; } /** @@ -85,7 +97,10 @@ public class SchedulingAppServiceImpl implements SchedulingAppService { lockKeys.add(new ResourceLockKey("2_SITE", dto.getSiteId())); } - lockPhysicalResources(lockKeys); + Map lockedTeachers = lockPhysicalResources(lockKeys); + + // 2.1 在岗校验:新排课次必须指派在岗教师,已停用教师不得承接新的授课任务 + assertTeacherAvailableForNewAssignment(lockedTeachers.get(dto.getTId())); // 3. 落库新增课次 (排课冲突属于 Warning 告警级别,由 check-conflict 预检供管理员决策,不强制阻断入库) TrainLesson lesson = new TrainLesson(); @@ -159,7 +174,15 @@ public class SchedulingAppServiceImpl implements SchedulingAppService lockKeys.add(new ResourceLockKey("2_SITE", dto.getSiteId())); } - lockPhysicalResources(lockKeys); + Map lockedTeachers = lockPhysicalResources(lockKeys); + + // 3.1 在岗校验:仅当本次修改真的更换了授课教师时才要求新教师在岗。 + // 维持原教师不变的历史课次必须放行,否则教师停用后其名下课次将再也无法调整时间与场地, + // 系统既不会自动删除课次,也不会自动替换授课教师。 + if (!Objects.equals(existingLesson.getTId(), dto.getTId())) + { + assertTeacherAvailableForNewAssignment(lockedTeachers.get(dto.getTId())); + } // 4. 更新课次 (排课冲突属于 Warning 告警级别,由 check-conflict 预检供管理员决策,不强制阻断更新) existingLesson.setPlanCourseId(dto.getPlanCourseId()); @@ -363,6 +386,95 @@ public class SchedulingAppServiceImpl implements SchedulingAppService return vo; } + /** + * 获取排课候选资源选项 (教师 / 教室 / 实训场地)。 + * 一次性全量返回,不分页、不设条数上限,避免下拉候选被静默截断。 + */ + @Override + @Transactional(readOnly = true) + public Map getSchedulingOptions() + { + Map options = new LinkedHashMap<>(); + options.put("teachers", buildTeacherOptions()); + options.put("classrooms", buildClassroomOptions()); + options.put("sites", buildSiteOptions()); + return options; + } + + /** + * 组装教师候选:仅在岗有效教师 (status=1,Mapper 内置 del_flag='0' 过滤)。 + * 只下发下拉必需字段,手机号 / 邮箱等敏感信息一律不出网。 + */ + private List> buildTeacherOptions() + { + TrainTeacher query = new TrainTeacher(); + query.setStatus(1); + List teachers = trainTeacherMapper.selectTrainTeacherList(query); + + List> list = new ArrayList<>(); + if (teachers == null) + { + return list; + } + for (TrainTeacher teacher : teachers) + { + Map item = new LinkedHashMap<>(); + item.put("tId", teacher.gettId()); + item.put("tName", teacher.gettName()); + item.put("orgName", teacher.getOrgName()); + list.add(item); + } + return list; + } + + /** + * 组装普通教室候选 (沿用 del_flag='0' 语义,不额外收紧启用状态过滤)。 + */ + private List> buildClassroomOptions() + { + List classrooms = trainClassroomInfoMapper.selectTrainClassroomInfoList(new TrainClassroomInfo()); + + List> list = new ArrayList<>(); + if (classrooms == null) + { + return list; + } + for (TrainClassroomInfo classroom : classrooms) + { + Map item = new LinkedHashMap<>(); + item.put("classRoomId", classroom.getClassRoomId()); + item.put("roomName", classroom.getRoomName()); + item.put("roomLocation", classroom.getRoomLocation()); + item.put("capacity", classroom.getCapacity()); + list.add(item); + } + return list; + } + + /** + * 组装实训场地候选 (沿用 del_flag='0' 语义,不额外收紧启用状态过滤)。 + */ + private List> buildSiteOptions() + { + List sites = trainTrainingSiteMapper.selectTrainTrainingSiteList(new TrainTrainingSite()); + + List> list = new ArrayList<>(); + if (sites == null) + { + return list; + } + for (TrainTrainingSite site : sites) + { + Map item = new LinkedHashMap<>(); + item.put("siteId", site.getSiteId()); + item.put("siteName", site.getSiteName()); + item.put("siteLocation", site.getSiteLocation()); + item.put("capacity", site.getCapacity()); + list.add(item); + } + return list; + } + /** * 基础规则验证。 */ @@ -434,12 +546,16 @@ public class SchedulingAppServiceImpl implements SchedulingAppService /** * 锁定稳定物理主资源行 (按字典序排序加锁杜绝死锁)。 + * + * @param keys 待加锁的资源键集合 + * @return 已锁定的教师行,键为教师工号 (供调用方按新旧教师差异做在岗校验) */ - private void lockPhysicalResources(Set keys) + private Map lockPhysicalResources(Set keys) { List sortedKeys = new ArrayList<>(keys); Collections.sort(sortedKeys); + Map lockedTeachers = new LinkedHashMap<>(); for (ResourceLockKey key : sortedKeys) { if ("1_TEACHER".equals(key.resourceType)) @@ -450,6 +566,7 @@ public class SchedulingAppServiceImpl implements SchedulingAppService throw new ServiceException(LessonErrorCode.TEACHER_NOT_FOUND_MESSAGE, LessonErrorCode.TEACHER_NOT_FOUND); } + lockedTeachers.put(key.resourceId, teacher); } else if ("2_CLASSROOM".equals(key.resourceType)) { @@ -470,6 +587,21 @@ public class SchedulingAppServiceImpl implements SchedulingAppService } } } + return lockedTeachers; + } + + /** + * 校验教师可以承接新的授课安排 (已停用教师禁止被指派新课次)。 + * + * @param teacher 已锁定的教师主档行 + */ + private void assertTeacherAvailableForNewAssignment(TrainTeacher teacher) + { + if (teacher != null && teacher.getStatus() != null && teacher.getStatus() == 0) + { + throw new ServiceException(LessonErrorCode.TEACHER_DISABLED_MESSAGE, + LessonErrorCode.TEACHER_DISABLED); + } } /** diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/CoreBusinessScenarioMockTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/CoreBusinessScenarioMockTest.java index cd3afc408..9287559c2 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/CoreBusinessScenarioMockTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/CoreBusinessScenarioMockTest.java @@ -457,7 +457,8 @@ public class CoreBusinessScenarioMockTest setField(enrollmentAppService, "planMapper", planMapper); setField(enrollmentAppService, "studentMapper", studentMapper); - schedulingAppService = new SchedulingAppServiceImpl(lessonMapper, planMapper, planCourseMapper); + // 本用例不涉及排课候选项查询,故候选项相关 Mapper 不参与 + schedulingAppService = new SchedulingAppServiceImpl(lessonMapper, planMapper, planCourseMapper, null, null, null); boardService = new TrainElectronicBoardServiceImpl(); setField(boardService, "trainElectronicBoardMapper", boardMapper); 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 index 4012bc450..562683b28 100644 --- 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 @@ -73,7 +73,8 @@ public class SchedulingAppServiceTest lessonMapper = new MockLessonMapper(); planMapper = new MockPlanMapper(); planCourseMapper = new MockPlanCourseMapper(); - appService = new SchedulingAppServiceImpl(lessonMapper, planMapper, planCourseMapper); + // 本用例只覆盖排课核心能力,不涉及排课候选项查询,故候选项相关 Mapper 不参与 + appService = new SchedulingAppServiceImpl(lessonMapper, planMapper, planCourseMapper, null, null, null); // 初始化培训计划 TrainClassPlan plan = new TrainClassPlan(); @@ -96,7 +97,7 @@ public class SchedulingAppServiceTest teacher.settId(TEACHER_ID); teacher.settName("李建国"); teacher.setDelFlag("0"); - teacher.setStatus(0); + teacher.setStatus(1); // 1=正常在岗 lessonMapper.teachers.put(TEACHER_ID, teacher); // 初始化教室 @@ -533,7 +534,7 @@ public class SchedulingAppServiceTest t2.settId("T002"); t2.settName("王教授"); t2.setDelFlag("0"); - t2.setStatus(0); + t2.setStatus(1); // 1=正常在岗 lessonMapper.teachers.put("T002", t2); TrainClassroomInfo cr2 = new TrainClassroomInfo(); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java new file mode 100644 index 000000000..62d7311b1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingTeacherStatusAndOptionsTest.java @@ -0,0 +1,811 @@ +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.assertTrue; +import static org.junit.Assert.fail; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.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.LessonUpdateDTO; +import com.ruoyi.train.domain.query.PlanQuery; +import com.ruoyi.train.domain.vo.ConflictItemVO; +import com.ruoyi.train.domain.vo.PlanCourseItemVO; +import com.ruoyi.train.domain.vo.PlanItemVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainClassroomInfoMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.mapper.TrainTrainingSiteMapper; + +/** + * 停用教师排课守卫与排课候选项契约测试。 + * + *

覆盖两条业务约定: + * 1) 已停用教师不得承接新的授课安排,但其名下历史课次仍可继续维护,系统不自动删课、不自动换人; + * 2) 排课候选项为全量只读接口,仅返回在岗教师且不存在任何隐式条数上限。

+ * + * @author ruoyi + */ +public class SchedulingTeacherStatusAndOptionsTest +{ + private MockLessonMapper lessonMapper; + private MockPlanMapper planMapper; + private MockPlanCourseMapper planCourseMapper; + private MockTeacherMapper teacherMapper; + private MockClassroomMapper classroomMapper; + private MockSiteMapper siteMapper; + private SchedulingAppServiceImpl appService; + + private static final String PLAN_ID = "PLAN-2026-100"; + private static final Long PLAN_COURSE_ID = 501L; + /** 在岗教师 (status=1) */ + private static final String ACTIVE_TEACHER_ID = "T_ACTIVE"; + /** 已停用教师 (status=0) */ + private static final String DISABLED_TEACHER_ID = "T_DISABLED"; + /** 另一名已停用教师 (status=0) */ + private static final String OTHER_DISABLED_TEACHER_ID = "T_DISABLED_2"; + private static final String CLASSROOM_ID = "CR100"; + + 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(); + teacherMapper = new MockTeacherMapper(); + classroomMapper = new MockClassroomMapper(); + siteMapper = new MockSiteMapper(); + appService = new SchedulingAppServiceImpl(lessonMapper, planMapper, planCourseMapper, + teacherMapper, classroomMapper, siteMapper); + + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(PLAN_ID); + plan.setPlanName("2026接触网检修专项班"); + plan.setStartTime(sdf.parse("2026-11-01 00:00:00")); + plan.setEndTime(sdf.parse("2026-11-30 23:59:59")); + plan.setPlanStatus(1); + planMapper.plans.put(PLAN_ID, plan); + + TrainPlanCourse planCourse = new TrainPlanCourse(); + planCourse.setPlanCourseId(PLAN_COURSE_ID); + planCourse.setPlanId(PLAN_ID); + planCourseMapper.planCourses.put(PLAN_COURSE_ID, planCourse); + + lessonMapper.teachers.put(ACTIVE_TEACHER_ID, buildTeacher(ACTIVE_TEACHER_ID, "赵在岗", 1)); + lessonMapper.teachers.put(DISABLED_TEACHER_ID, buildTeacher(DISABLED_TEACHER_ID, "钱已停用", 0)); + lessonMapper.teachers.put(OTHER_DISABLED_TEACHER_ID, buildTeacher(OTHER_DISABLED_TEACHER_ID, "孙已停用", 0)); + + TrainClassroomInfo classroom = new TrainClassroomInfo(); + classroom.setClassRoomId(CLASSROOM_ID); + classroom.setRoomName("第一实训教室"); + classroom.setDelFlag("0"); + lessonMapper.classrooms.put(CLASSROOM_ID, classroom); + } + + /** + * 1. 停用教师禁止被指派新课次。 + */ + @Test + public void testCreateLessonRejectedForDisabledTeacher() throws Exception + { + LessonCreateDTO dto = buildCreateDTO(DISABLED_TEACHER_ID, + "2026-11-10 09:00:00", "2026-11-10 11:00:00"); + + try + { + appService.createLesson(PLAN_ID, dto, "admin"); + fail("停用教师新建课次应被拒绝"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.TEACHER_DISABLED, e.getCode().intValue()); + assertEquals("该教师当前已停用,不能安排新的授课任务", e.getMessage()); + } + + // 拒绝后不得残留任何课次 + assertTrue(lessonMapper.lessons.isEmpty()); + } + + /** + * 2. 在岗教师正常新建课次。 + */ + @Test + public void testCreateLessonSucceedsForActiveTeacher() throws Exception + { + LessonCreateDTO dto = buildCreateDTO(ACTIVE_TEACHER_ID, + "2026-11-10 09:00:00", "2026-11-10 11:00:00"); + + Long lessonId = appService.createLesson(PLAN_ID, dto, "admin"); + assertNotNull(lessonId); + assertEquals(ACTIVE_TEACHER_ID, lessonMapper.lessons.get(lessonId).getTId()); + } + + /** + * 3. 停用教师名下历史课次:不换教师时必须允许继续调整时间与教室。 + */ + @Test + public void testUpdateLessonKeepsDisabledTeacherAllowed() throws Exception + { + Long lessonId = 900L; + addExistingLesson(lessonId, DISABLED_TEACHER_ID, "2026-11-12 09:00:00", "2026-11-12 11:00:00"); + + LessonUpdateDTO dto = new LessonUpdateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(DISABLED_TEACHER_ID); // 维持原教师不变 + dto.setStartTime(sdf.parse("2026-11-12 14:00:00")); + dto.setEndTime(sdf.parse("2026-11-12 16:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + dto.setRemark("教师停用后仍可正常调整上课时间"); + + appService.updateLesson(lessonId, dto, "admin"); + + TrainLesson updated = lessonMapper.lessons.get(lessonId); + assertNotNull("历史课次不得被自动删除", updated); + assertEquals("历史课次不得被自动换教师", DISABLED_TEACHER_ID, updated.getTId()); + assertEquals(sdf.parse("2026-11-12 14:00:00"), updated.getStartTime()); + assertEquals(sdf.parse("2026-11-12 16:00:00"), updated.getEndTime()); + } + + /** + * 4. 历史课次改派给另一名已停用教师:必须拒绝。 + */ + @Test + public void testUpdateLessonRejectedWhenSwitchingToDisabledTeacher() throws Exception + { + Long lessonId = 901L; + addExistingLesson(lessonId, ACTIVE_TEACHER_ID, "2026-11-13 09:00:00", "2026-11-13 11:00:00"); + + LessonUpdateDTO dto = new LessonUpdateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(OTHER_DISABLED_TEACHER_ID); // 改派给另一名停用教师 + dto.setStartTime(sdf.parse("2026-11-13 09:00:00")); + dto.setEndTime(sdf.parse("2026-11-13 11:00:00")); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + + try + { + appService.updateLesson(lessonId, dto, "admin"); + fail("改派给停用教师应被拒绝"); + } + catch (ServiceException e) + { + assertEquals(LessonErrorCode.TEACHER_DISABLED, e.getCode().intValue()); + assertEquals("该教师当前已停用,不能安排新的授课任务", e.getMessage()); + } + + // 原教师保持不变 + assertEquals(ACTIVE_TEACHER_ID, lessonMapper.lessons.get(lessonId).getTId()); + } + + /** + * 5. 排课候选项:仅返回在岗教师,超过 100 条不截断,且不下发敏感字段。 + */ + @Test + public void testSchedulingOptionsReturnsAllActiveTeachersWithoutTruncation() + { + // 构造 150 名在岗教师 + 20 名停用教师 + 5 名已删除教师 + for (int i = 1; i <= 150; i++) + { + teacherMapper.teachers.add(buildTeacher("T_OK_" + i, "在岗教师" + i, 1)); + } + for (int i = 1; i <= 20; i++) + { + teacherMapper.teachers.add(buildTeacher("T_OFF_" + i, "停用教师" + i, 0)); + } + for (int i = 1; i <= 5; i++) + { + TrainTeacher removed = buildTeacher("T_DEL_" + i, "已删除教师" + i, 1); + removed.setDelFlag("2"); + teacherMapper.teachers.add(removed); + } + + // 教室与实训场地候选也一并构造超过 100 条 + for (int i = 1; i <= 120; i++) + { + TrainClassroomInfo classroom = new TrainClassroomInfo(); + classroom.setClassRoomId("CR_OK_" + i); + classroom.setRoomName("教室" + i); + classroom.setRoomLocation("综合楼" + i + "层"); + classroom.setCapacity(60L); + classroom.setDelFlag("0"); + classroomMapper.classrooms.add(classroom); + } + for (int i = 1; i <= 110; i++) + { + TrainTrainingSite site = new TrainTrainingSite(); + site.setSiteId("SITE_OK_" + i); + site.setSiteName("实训场地" + i); + site.setSiteLocation("实训区" + i); + site.setCapacity(30L); + site.setDelFlag("0"); + siteMapper.sites.add(site); + } + + Map options = appService.getSchedulingOptions(); + + @SuppressWarnings("unchecked") + List> teachers = (List>) options.get("teachers"); + @SuppressWarnings("unchecked") + List> classrooms = (List>) options.get("classrooms"); + @SuppressWarnings("unchecked") + List> sites = (List>) options.get("sites"); + + // 全量返回,绝不在 100 条处静默截断 + assertEquals(150, teachers.size()); + assertEquals(120, classrooms.size()); + assertEquals(110, sites.size()); + + // 停用与已删除教师均不出现在候选中 + for (Map item : teachers) + { + String tId = (String) item.get("tId"); + assertFalse("停用教师不得进入排课候选", tId.startsWith("T_OFF_")); + assertFalse("已删除教师不得进入排课候选", tId.startsWith("T_DEL_")); + } + + // 字段精简:只含下拉必需字段,不下发手机号 / 邮箱等敏感信息 + Map firstTeacher = teachers.get(0); + assertEquals(3, firstTeacher.size()); + assertTrue(firstTeacher.containsKey("tId")); + assertTrue(firstTeacher.containsKey("tName")); + assertTrue(firstTeacher.containsKey("orgName")); + assertFalse(firstTeacher.containsKey("tPhone")); + assertFalse(firstTeacher.containsKey("tEmail")); + + // 教室与场地保持下拉展示所需字段 + assertTrue(classrooms.get(0).containsKey("classRoomId")); + assertTrue(classrooms.get(0).containsKey("roomName")); + assertTrue(sites.get(0).containsKey("siteId")); + assertTrue(sites.get(0).containsKey("siteName")); + } + + private TrainTeacher buildTeacher(String tId, String tName, Integer status) + { + TrainTeacher teacher = new TrainTeacher(); + teacher.settId(tId); + teacher.settName(tName); + teacher.setOrgName("动车运用所"); + teacher.settPhone("13800000000"); + teacher.settEmail("teacher@example.com"); + teacher.setStatus(status); + teacher.setDelFlag("0"); + return teacher; + } + + private LessonCreateDTO buildCreateDTO(String tId, String startTimeStr, String endTimeStr) throws Exception + { + LessonCreateDTO dto = new LessonCreateDTO(); + dto.setPlanCourseId(PLAN_COURSE_ID); + dto.setTId(tId); + dto.setStartTime(sdf.parse(startTimeStr)); + dto.setEndTime(sdf.parse(endTimeStr)); + dto.setVenueType(1); + dto.setClassRoomId(CLASSROOM_ID); + return dto; + } + + private void addExistingLesson(Long lessonId, String tId, String startTimeStr, String endTimeStr) throws Exception + { + TrainLesson lesson = new TrainLesson(); + lesson.setLessonId(lessonId); + lesson.setPlanCourseId(PLAN_COURSE_ID); + lesson.setTId(tId); + lesson.setVenueType(1); + lesson.setClassRoomId(CLASSROOM_ID); + lesson.setStartTime(sdf.parse(startTimeStr)); + lesson.setEndTime(sdf.parse(endTimeStr)); + lessonMapper.lessons.put(lessonId, 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<>(); + AtomicLong idGen = new AtomicLong(2000L); + + @Override + public TrainLesson selectTrainLessonById(Long lessonId) + { + return lessons.get(lessonId); + } + + @Override + public int insertTrainLesson(TrainLesson trainLesson) + { + if (trainLesson.getLessonId() == null) + { + trainLesson.setLessonId(idGen.incrementAndGet()); + } + lessons.put(trainLesson.getLessonId(), trainLesson); + return 1; + } + + @Override + public int updateTrainLesson(TrainLesson trainLesson) + { + lessons.put(trainLesson.getLessonId(), trainLesson); + return 1; + } + + @Override + public int deleteTrainLessonById(Long lessonId) + { + lessons.remove(lessonId); + return 1; + } + + @Override + public TrainTeacher lockTeacherById(String tId) + { + return teachers.get(tId); + } + + @Override + public TrainClassroomInfo lockClassroomById(String classRoomId) + { + return classrooms.get(classRoomId); + } + + @Override + public TrainTrainingSite lockSiteById(String siteId) + { + return sites.get(siteId); + } + + @Override + public int countAttendanceByLessonId(Long lessonId) + { + return 0; + } + + @Override + public List selectTeacherConflicts(String tId, Date startTime, Date endTime, Long excludeLessonId) + { + return Collections.emptyList(); + } + + @Override + public List selectClassroomConflicts(String classRoomId, Date startTime, Date endTime, Long excludeLessonId) + { + return Collections.emptyList(); + } + + @Override + public List selectSiteConflicts(String siteId, Date startTime, Date endTime, Long excludeLessonId) + { + return Collections.emptyList(); + } + + @Override + public List selectPlanConflicts(String planId, Date startTime, Date endTime, Long excludeLessonId) + { + return Collections.emptyList(); + } + + @Override + public List selectScheduleLessonsByPlanId(String planId) + { + return Collections.emptyList(); + } + + @Override + public List selectScheduleLessonsByTeacherId(String tId, Date startDate, Date endDate) + { + return Collections.emptyList(); + } + + @Override + public List selectScheduleLessonsByStudentId(String studentId, Date startDate, Date endDate) + { + return Collections.emptyList(); + } + + @Override + public int countTeacherInPlan(String tId, String planId) + { + return 0; + } + + @Override + public List selectPlansByTeacherId(String tId, Integer planStatus) + { + return Collections.emptyList(); + } + + @Override + public int countLessonsByClassRoomId(String classRoomId) + { + return 0; + } + + @Override + public int countLessonsByTeacherId(String tId) + { + return 0; + } + } + + /** + * 模拟 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; + } + + @Override + public int countPlanCoursesByCId(String cId) + { + return 0; + } + + @Override + public int countStudentScoresByCId(String cId) + { + return 0; + } + } + + /** + * 模拟 Teacher Mapper:对齐 TrainTeacherMapper.xml 的 del_flag='0' 与可选 status 过滤语义, + * 且不施加任何条数上限。 + */ + private static class MockTeacherMapper implements TrainTeacherMapper + { + List teachers = new ArrayList<>(); + + @Override + public TrainTeacher selectTrainTeacherByTId(String tId) + { + for (TrainTeacher teacher : teachers) + { + if (tId != null && tId.equals(teacher.gettId()) && !"2".equals(teacher.getDelFlag())) + { + return teacher; + } + } + return null; + } + + @Override + public List selectTrainTeacherList(TrainTeacher query) + { + List result = new ArrayList<>(); + for (TrainTeacher teacher : teachers) + { + if ("2".equals(teacher.getDelFlag())) + { + continue; + } + if (query != null && query.getStatus() != null + && !query.getStatus().equals(teacher.getStatus())) + { + continue; + } + result.add(teacher); + } + return result; + } + + @Override + public int insertTrainTeacher(TrainTeacher trainTeacher) + { + teachers.add(trainTeacher); + return 1; + } + + @Override + public int updateTrainTeacher(TrainTeacher trainTeacher) + { + return 1; + } + + @Override + public TrainTeacher checkPhoneUnique(String tPhone) + { + return null; + } + + @Override + public int deleteTrainTeacherByTId(String tId) + { + return 1; + } + + @Override + public int deleteTrainTeacherRealByTId(String tId) + { + return 1; + } + + @Override + public int deleteTrainTeacherByTIds(String[] tIds) + { + return tIds == null ? 0 : tIds.length; + } + + @Override + public TrainTeacher selectTrainTeacherByUserId(Long userId) + { + return null; + } + + @Override + public int countByUserId(Long userId) + { + return 0; + } + + @Override + public int bindUserId(String tId, Long userId) + { + return 1; + } + + @Override + public int unbindUserId(String tId) + { + return 1; + } + } + + /** + * 模拟 Classroom Mapper:对齐 del_flag='0' 语义,不施加条数上限。 + */ + private static class MockClassroomMapper implements TrainClassroomInfoMapper + { + List classrooms = new ArrayList<>(); + + @Override + public TrainClassroomInfo selectTrainClassroomInfoByClassRoomId(String classRoomId) + { + return null; + } + + @Override + public List selectTrainClassroomInfoList(TrainClassroomInfo query) + { + List result = new ArrayList<>(); + for (TrainClassroomInfo classroom : classrooms) + { + if (!"2".equals(classroom.getDelFlag())) + { + result.add(classroom); + } + } + return result; + } + + @Override + public TrainClassroomInfo checkRoomNameUnique(String roomName, String excludeClassRoomId) + { + return null; + } + + @Override + public int insertTrainClassroomInfo(TrainClassroomInfo trainClassroomInfo) + { + classrooms.add(trainClassroomInfo); + return 1; + } + + @Override + public int updateTrainClassroomInfo(TrainClassroomInfo trainClassroomInfo) + { + return 1; + } + + @Override + public int deleteTrainClassroomInfoByClassRoomId(String classRoomId) + { + return 1; + } + + @Override + public int deleteTrainClassroomInfoByClassRoomIds(String[] classRoomIds) + { + return classRoomIds == null ? 0 : classRoomIds.length; + } + } + + /** + * 模拟 Site Mapper:对齐 del_flag='0' 语义,不施加条数上限。 + */ + private static class MockSiteMapper implements TrainTrainingSiteMapper + { + List sites = new ArrayList<>(); + + @Override + public TrainTrainingSite selectTrainTrainingSiteBySiteId(String siteId) + { + return null; + } + + @Override + public List selectTrainTrainingSiteList(TrainTrainingSite query) + { + List result = new ArrayList<>(); + for (TrainTrainingSite site : sites) + { + if (!"2".equals(site.getDelFlag())) + { + result.add(site); + } + } + return result; + } + + @Override + public TrainTrainingSite checkSiteNameUnique(String siteName, String excludeSiteId) + { + return null; + } + + @Override + public int insertTrainTrainingSite(TrainTrainingSite trainTrainingSite) + { + sites.add(trainTrainingSite); + return 1; + } + + @Override + public int updateTrainTrainingSite(TrainTrainingSite trainTrainingSite) + { + return 1; + } + + @Override + public int deleteTrainTrainingSiteBySiteId(String siteId) + { + return 1; + } + + @Override + public int deleteTrainTrainingSiteBySiteIds(String[] siteIds) + { + return siteIds == null ? 0 : siteIds.length; + } + } +} diff --git a/build_ui/src/api/train/timetable.js b/build_ui/src/api/train/timetable.js index 5b0f4d333..67100343d 100644 --- a/build_ui/src/api/train/timetable.js +++ b/build_ui/src/api/train/timetable.js @@ -42,3 +42,11 @@ export function checkLessonConflict(data) { data: data }) } + +// 4.6 获取排课候选资源选项 (教师/教室/实训场地,全量返回不分页) 【P0】 +export function getSchedulingOptions() { + return request({ + url: '/train/lessons/scheduling-options', + method: 'get' + }) +} diff --git a/build_ui/src/views/train/plan/components/TimetablePanel.vue b/build_ui/src/views/train/plan/components/TimetablePanel.vue index 15c515eb9..34467955a 100644 --- a/build_ui/src/views/train/plan/components/TimetablePanel.vue +++ b/build_ui/src/views/train/plan/components/TimetablePanel.vue @@ -203,8 +203,9 @@ @@ -355,12 +356,10 @@ import { addLesson, updateLesson, removeLesson, - checkLessonConflict + checkLessonConflict, + getSchedulingOptions } from '@/api/train/timetable' import { listPlanCourses } from '@/api/train/planCourse' -import { listTeacher } from '@/api/train/teacher' -import { listClassroom } from '@/api/train/classroom' -import { listSite } from '@/api/train/site' export default { name: 'TimetablePanel', @@ -488,14 +487,12 @@ export default { listPlanCourses(this.planId).then(res => { this.planCourseOptions = res.data || [] }) - listTeacher({ pageNum: 1, pageSize: 100 }).then(res => { - this.teacherOptions = res.rows || [] - }) - listClassroom({ pageNum: 1, pageSize: 100 }).then(res => { - this.classroomOptions = res.rows || [] - }) - listSite({ pageNum: 1, pageSize: 100 }).then(res => { - this.siteOptions = res.rows || [] + // 排课候选统一走专用只读接口全量加载,不分页,避免候选被静默截断 + getSchedulingOptions().then(res => { + const data = res.data || {} + this.teacherOptions = data.teachers || [] + this.classroomOptions = data.classrooms || [] + this.siteOptions = data.sites || [] }) }, handleVenueTypeChange(val) { @@ -514,8 +511,28 @@ export default { this.dialog.open = true this.conflictResult = null }, + // 教师下拉展示文案:已停用的原授课教师仅保留姓名可见,明确标注停用状态 + teacherOptionLabel(t) { + const name = t.name || t.tName || t.tId + if (t.disabled) { + return `${name}(已停用)` + } + return `${name} - ${t.workDept || t.orgName || '基地'}` + }, + // 编辑历史课次时,原授课教师若已停用则不在候选列表中, + // 临时补一条禁用选项保证原教师名可见且不可被重新选中,绝不自动换人 + ensureCurrentTeacherVisible(row) { + if (!row || !row.tId) return + if (this.teacherOptions.some(t => t.tId === row.tId)) return + this.teacherOptions = this.teacherOptions.concat([{ + tId: row.tId, + tName: row.teacherName || row.tId, + disabled: true + }]) + }, // ---- 打开修改排课 (不发明 lesson detail API,直接从已有VO回显) ---- handleEditLesson(row) { + this.ensureCurrentTeacherVisible(row) this.dialog.isEdit = true this.dialog.title = '编辑课次信息' this.lessonForm = {