修复排课:停用教师禁止新排课并消除候选列表静默截断

停用教师仍出现在排课教师下拉且后端未校验 status;教师、教室、场地候选统一按
pageSize=100 拉取,数量超过 100 时会静默丢失合法候选。

- 后端在创建课次时强制校验目标教师在岗,修改课次仅在真的更换授课教师时校验,
  维持原教师不变的历史课次继续放行,既不自动删除课次也不自动替换教师
- 新增业务错误文案"该教师当前已停用,不能安排新的授课任务",不暴露内部状态码
- 新增只读端点 GET /train/lessons/scheduling-options 全量返回教师、教室、场地候选,
  不分页、服务端无条数上限;教师候选只返回在岗有效教师,且只下发下拉必需字段
- 前端排课面板改用该端点,编辑历史课次时为已停用的原教师补一条禁用选项,
  保证原教师名可见但不可被重新选中
- 排课冲突策略不变,仍为前端 Warning 加二次确认后允许保存

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wangzhibiaoandCursor committed 2026-09-22 17:04:10 +08:00
1 parent dc3990b29a
commit eb8ce6f85c
10 files changed
+1024 -23

No files matched your search

@@ -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 / 编辑回显必需)。
*/
@@ -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);
}
}
@@ -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 = "实训场地不存在或已无效";
@@ -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<String, Object> getSchedulingOptions();
}
@@ -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<String, TrainTeacher> 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<String, TrainTeacher> 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<String, Object> getSchedulingOptions()
{
Map<String, Object> 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<Map<String, Object>> buildTeacherOptions()
{
TrainTeacher query = new TrainTeacher();
query.setStatus(1);
List<TrainTeacher> teachers = trainTeacherMapper.selectTrainTeacherList(query);
List<Map<String, Object>> list = new ArrayList<>();
if (teachers == null)
{
return list;
}
for (TrainTeacher teacher : teachers)
{
Map<String, Object> 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<Map<String, Object>> buildClassroomOptions()
{
List<TrainClassroomInfo> classrooms = trainClassroomInfoMapper.selectTrainClassroomInfoList(new TrainClassroomInfo());
List<Map<String, Object>> list = new ArrayList<>();
if (classrooms == null)
{
return list;
}
for (TrainClassroomInfo classroom : classrooms)
{
Map<String, Object> 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<Map<String, Object>> buildSiteOptions()
{
List<TrainTrainingSite> sites = trainTrainingSiteMapper.selectTrainTrainingSiteList(new TrainTrainingSite());
List<Map<String, Object>> list = new ArrayList<>();
if (sites == null)
{
return list;
}
for (TrainTrainingSite site : sites)
{
Map<String, Object> 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<ResourceLockKey> keys)
private Map<String, TrainTeacher> lockPhysicalResources(Set<ResourceLockKey> keys)
{
List<ResourceLockKey> sortedKeys = new ArrayList<>(keys);
Collections.sort(sortedKeys);
Map<String, TrainTeacher> 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);
}
}
/**
@@ -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);
@@ -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();
@@ -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;
/**
* 停用教师排课守卫与排课候选项契约测试。
*
* <p>覆盖两条业务约定:
* 1) 已停用教师不得承接新的授课安排,但其名下历史课次仍可继续维护,系统不自动删课、不自动换人;
* 2) 排课候选项为全量只读接口,仅返回在岗教师且不存在任何隐式条数上限。</p>
*
* @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<String, Object> options = appService.getSchedulingOptions();
@SuppressWarnings("unchecked")
List<Map<String, Object>> teachers = (List<Map<String, Object>>) options.get("teachers");
@SuppressWarnings("unchecked")
List<Map<String, Object>> classrooms = (List<Map<String, Object>>) options.get("classrooms");
@SuppressWarnings("unchecked")
List<Map<String, Object>> sites = (List<Map<String, Object>>) options.get("sites");
// 全量返回,绝不在 100 条处静默截断
assertEquals(150, teachers.size());
assertEquals(120, classrooms.size());
assertEquals(110, sites.size());
// 停用与已删除教师均不出现在候选中
for (Map<String, Object> item : teachers)
{
String tId = (String) item.get("tId");
assertFalse("停用教师不得进入排课候选", tId.startsWith("T_OFF_"));
assertFalse("已删除教师不得进入排课候选", tId.startsWith("T_DEL_"));
}
// 字段精简:只含下拉必需字段,不下发手机号 / 邮箱等敏感信息
Map<String, Object> 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<Long, TrainLesson> lessons = new HashMap<>();
Map<String, TrainTeacher> teachers = new HashMap<>();
Map<String, TrainClassroomInfo> classrooms = new HashMap<>();
Map<String, TrainTrainingSite> 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<ConflictItemVO> selectTeacherConflicts(String tId, Date startTime, Date endTime, Long excludeLessonId)
{
return Collections.emptyList();
}
@Override
public List<ConflictItemVO> selectClassroomConflicts(String classRoomId, Date startTime, Date endTime, Long excludeLessonId)
{
return Collections.emptyList();
}
@Override
public List<ConflictItemVO> selectSiteConflicts(String siteId, Date startTime, Date endTime, Long excludeLessonId)
{
return Collections.emptyList();
}
@Override
public List<ConflictItemVO> selectPlanConflicts(String planId, Date startTime, Date endTime, Long excludeLessonId)
{
return Collections.emptyList();
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByPlanId(String planId)
{
return Collections.emptyList();
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByTeacherId(String tId, Date startDate, Date endDate)
{
return Collections.emptyList();
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByStudentId(String studentId, Date startDate, Date endDate)
{
return Collections.emptyList();
}
@Override
public int countTeacherInPlan(String tId, String planId)
{
return 0;
}
@Override
public List<TrainClassPlan> 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<String, TrainClassPlan> 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<PlanItemVO> 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<Long, TrainPlanCourse> planCourses = new HashMap<>();
@Override
public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId)
{
return planCourses.get(planCourseId);
}
@Override
public List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId)
{
return Collections.emptyList();
}
@Override
public List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds)
{
return Collections.emptyList();
}
@Override
public int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse)
{
planCourses.put(trainPlanCourse.getPlanCourseId(), trainPlanCourse);
return 1;
}
@Override
public int batchInsertTrainPlanCourse(List<TrainPlanCourse> 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<TrainTeacher> 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<TrainTeacher> selectTrainTeacherList(TrainTeacher query)
{
List<TrainTeacher> 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<TrainClassroomInfo> classrooms = new ArrayList<>();
@Override
public TrainClassroomInfo selectTrainClassroomInfoByClassRoomId(String classRoomId)
{
return null;
}
@Override
public List<TrainClassroomInfo> selectTrainClassroomInfoList(TrainClassroomInfo query)
{
List<TrainClassroomInfo> 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<TrainTrainingSite> sites = new ArrayList<>();
@Override
public TrainTrainingSite selectTrainTrainingSiteBySiteId(String siteId)
{
return null;
}
@Override
public List<TrainTrainingSite> selectTrainTrainingSiteList(TrainTrainingSite query)
{
List<TrainTrainingSite> 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;
}
}
}
+8
View File
@@ -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'
})
}
@@ -203,8 +203,9 @@
<el-option
v-for="t in teacherOptions"
:key="t.tId"
:label="`${t.name || t.tName} - ${t.workDept || t.orgName || '基地'}`"
:label="teacherOptionLabel(t)"
:value="t.tId"
:disabled="!!t.disabled"
/>
</el-select>
</el-form-item>
@@ -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 = {