From e8a58b19fe581df4e883fbda7b05b6b8a197b4bc Mon Sep 17 00:00:00 2001
From: liweijie
Date: Tue, 15 Sep 2026 14:42:43 +0800
Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E8=BF=87AI=E6=AF=94=E5=AF=B9=E5=8E=9F?=
=?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=96=87=E6=A1=A3=EF=BC=8C=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=8A=9F=E8=83=BD=E5=AE=8C=E5=96=84=E6=96=B9?=
=?UTF-8?q?=E6=A1=88=EF=BC=8C=E6=A0=B9=E6=8D=AE=E6=96=B9=E6=A1=88=E8=BF=9B?=
=?UTF-8?q?=E8=A1=8C=E5=A2=9E=E8=A1=A5=EF=BC=8C=E5=AE=8C=E5=96=84=E4=BB=8E?=
=?UTF-8?q?=E5=AD=A6=E6=9C=9F=E6=96=B0=E5=A2=9E=E5=88=B0=E6=9C=80=E7=BB=88?=
=?UTF-8?q?=E7=9A=84=E6=8E=92=E8=AF=BE=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 5 +
.../jwgl/SchedulingWindowController.java | 80 ++
.../jwgl/StudentTeamTaskController.java | 16 +-
.../jwgl/TaskBookFillController.java | 89 ++
.../jwgl/TeacherCalendarController.java | 73 ++
.../jwgl/TeachingAllocationController.java | 82 ++
.../allocation/AllocationGroupRequest.java | 18 +
.../dto/allocation/AllocationSaveItem.java | 34 +
.../scheduling/SchedulingArrangeRequest.java | 55 ++
.../scheduling/SchedulingCellOpRequest.java | 17 +
.../dto/taskbook/TaskBookBhListRequest.java | 15 +
.../dto/taskbook/TaskBookFillRequest.java | 22 +
.../dto/taskbook/TaskBookRoomRequest.java | 19 +
.../dto/taskbook/TaskBookTeacherRequest.java | 24 +
.../com/roomroot/jwgl/entity/SSKCXYD.java | 13 +
.../jwgl/mapper/ElectiveCourseMapper.xml | 3 +-
.../com/roomroot/jwgl/mapper/JYLMapper.java | 12 +
.../jwgl/mapper/TeachingTaskMapper.java | 7 +
.../jwgl/mapper/TeachingTaskMapper.xml | 37 +
.../com/roomroot/jwgl/service/JYLService.java | 29 +
.../service/RunningCoursePublishService.java | 25 +
.../jwgl/service/SchedulingWindowService.java | 39 +
.../jwgl/service/StudentTeamTaskService.java | 14 +-
.../jwgl/service/TaskBookFillService.java | 77 ++
.../service/TeachingAllocationService.java | 56 ++
.../impl/ClassEventCalendarServiceImpl.java | 4 +-
.../impl/CourseRunningImportServiceImpl.java | 22 +-
.../impl/ElectiveCourseServiceImpl.java | 8 +-
.../service/impl/ElectiveServiceImpl.java | 45 +-
.../jwgl/service/impl/JYLServiceImpl.java | 109 +++
.../impl/RunningCoursePublishServiceImpl.java | 255 ++++++
.../impl/SchedulingWindowServiceImpl.java | 808 ++++++++++++++++++
.../impl/StudentTeamTaskServiceImpl.java | 226 ++++-
.../service/impl/TaskBookFillServiceImpl.java | 378 ++++++++
.../impl/TeachingAllocationServiceImpl.java | 655 ++++++++++++++
.../impl/TeachingTaskManageServiceImpl.java | 2 +-
.../impl/TimetableConflictServiceImpl.java | 247 +++++-
.../roomroot/jwgl/utils/SemesterCodeUtil.java | 35 +-
.../jwgl/vo/allocation/AllocationTaskVO.java | 77 ++
.../jwgl/vo/allocation/AllocationViewVO.java | 44 +
.../jwgl/vo/allocation/AllocationWeekVO.java | 31 +
.../jwgl/vo/elective/SemesterInitVO.java | 3 +-
.../jwgl/vo/scheduling/SchedulingViewVO.java | 98 +++
.../jwgl/vo/taskbook/TaskBookRowVO.java | 79 ++
.../TimetableConflictDetailVO.java | 6 +
.../src/api/studentRecords/classCalendar.js | 8 +-
frontend/src/api/teachBusiness/allocation.js | 59 ++
frontend/src/api/teachBusiness/taskBook.js | 69 ++
.../components/SchoolCalendarEditor.vue | 44 +
.../shiftSemester/ClassCalendarDialog.vue | 39 +-
.../TeachingAllocationDialog.vue | 452 ++++++++++
.../views/teachOffice/shiftSemester/index.vue | 21 +-
.../src/views/teachOffice/taskPlan/index.vue | 24 +-
frontend/vue.config.js | 2 +-
54 files changed, 4577 insertions(+), 134 deletions(-)
create mode 100644 backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java
create mode 100644 backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TaskBookFillController.java
create mode 100644 backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeacherCalendarController.java
create mode 100644 backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeachingAllocationController.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationGroupRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationSaveItem.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingArrangeRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingCellOpRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookBhListRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookFillRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookRoomRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookTeacherRequest.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/JYLMapper.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/JYLService.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/RunningCoursePublishService.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/SchedulingWindowService.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TaskBookFillService.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TeachingAllocationService.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingAllocationServiceImpl.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationTaskVO.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationViewVO.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationWeekVO.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java
create mode 100644 backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/taskbook/TaskBookRowVO.java
create mode 100644 frontend/src/api/teachBusiness/allocation.js
create mode 100644 frontend/src/api/teachBusiness/taskBook.js
create mode 100644 frontend/src/views/teachOffice/shiftSemester/TeachingAllocationDialog.vue
diff --git a/.gitignore b/.gitignore
index 3a5b5774..beaed137 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,8 @@ htmlcov/
deploy/
.reasonix/
reasonix.toml
+.workbuddy/
+
+# Agent scratch (screenshot/temp working dirs, local maven repo)
+output/*/working/
+m2repo/
diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java
new file mode 100644
index 00000000..2fa1cbd0
--- /dev/null
+++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/SchedulingWindowController.java
@@ -0,0 +1,80 @@
+package com.roomroot.web.controller.jwgl;
+
+import com.roomroot.jwgl.dto.scheduling.SchedulingArrangeRequest;
+import com.roomroot.jwgl.dto.scheduling.SchedulingCellOpRequest;
+import com.roomroot.jwgl.service.SchedulingWindowService;
+import com.roomroot.jwgl.unit.Result;
+import com.roomroot.jwgl.vo.scheduling.SchedulingViewVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 实施排课窗口 Controller(阶段 5.3)。
+ *
+ * 组合查询 + 写操作:安排所选节次 / 删除所选节次 / 删除课程全部节次 / 彻底删除运行课程。
+ * 硬冲突格忽略(双占、校历/班历/场地历/教员历不可排);软提示(配当周次不符、非正课)随返回值给出。
+ */
+@RestController
+@RequestMapping("/schedulingWindow")
+public class SchedulingWindowController {
+
+ @Autowired
+ private SchedulingWindowService schedulingWindowService;
+
+ /**
+ * 排课窗组合视图:班次头 + 课程列表(排满标绿)+ 周次×星期×节次格子。
+ */
+ @GetMapping("/view")
+ public Result view(@RequestParam("xydxqbh") String xydxqbh) {
+ return Result.success(schedulingWindowService.view(xydxqbh));
+ }
+
+ /**
+ * 安排所选节次。硬冲突格忽略并列出,软提示格照常安排并给出提示。
+ */
+ @PostMapping("/arrange")
+ public Result
+ */
+public interface TaskBookFillService {
+
+ /**
+ * 任务书填报列表:某教学任务下全部课程任务行(排序 课程 → 责任教员 → 课次)。
+ */
+ List list(String jxrwbh);
+
+ /**
+ * 手动合班:所选行写入同一编组(新组号 = 现有最大编组 + 1)。
+ * 校验 科目、学时、课类型、成绩分制 相同,任一不同则拒绝并说明。
+ *
+ * @return 编组号
+ */
+ Integer merge(TaskBookBhListRequest request);
+
+ /**
+ * 按预设合班:所选行按课程科目分组,组内按 班次学期.预设编班号 写入同一编组;
+ * 没有预设编班号的行各成一组(不与别人强并)。
+ *
+ * @return 每组合班结果:groupNo / kbh / ysbbh / 行数
+ */
+ List> mergeByPreset(TaskBookBhListRequest request);
+
+ /**
+ * 拆班:所选行编组置 0。
+ *
+ * @return 实际拆开行数
+ */
+ int split(TaskBookBhListRequest request);
+
+ /**
+ * 责任教员指定(unit=责任单位教员 / academy=全院教员 / plan=应用教研室计划教员)。
+ * 该行编组非 0 时,同合班组其它行同步 jybh。
+ *
+ * @return 实际更新行数
+ */
+ int setTeacher(TaskBookTeacherRequest request);
+
+ /**
+ * 场地指定:其它教室(useSpecial=false,jsbh 必传)或 班次专用教室(useSpecial=true)。
+ * 该行编组非 0 时,同合班组其它行同步 jsbh。
+ *
+ * @return 实际更新行数
+ */
+ int setRoom(TaskBookRoomRequest request);
+
+ /**
+ * 填报字段:计划教员(jysjhjybh)、场地(jsbh,同合班同步)、排课建议(jysjhbz)。
+ *
+ * @return 实际更新行数
+ */
+ int fill(TaskBookFillRequest request);
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TeachingAllocationService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TeachingAllocationService.java
new file mode 100644
index 00000000..f0b191c9
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TeachingAllocationService.java
@@ -0,0 +1,56 @@
+package com.roomroot.jwgl.service;
+
+import com.roomroot.jwgl.dto.allocation.AllocationGroupRequest;
+import com.roomroot.jwgl.dto.allocation.AllocationSaveItem;
+import com.roomroot.jwgl.vo.allocation.AllocationViewVO;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 教学配当(阶段 3)。
+ *
+ * 给出「哪一周上多少学时、谁和谁同开同结」,作为排课的粗约束;
+ * 不直接写实施课程表(SSKCB)日期节次。
+ */
+public interface TeachingAllocationService {
+
+ /**
+ * 打开某班次学期的配当编辑器视图:周轴(每周可排正课)+ 任务行(含铺学时结果)。
+ */
+ AllocationViewVO view(String xydxqbh);
+
+ /**
+ * 保存配档字段:单条(列表 1 个元素)或多选批量。
+ * 元素 saveGroup=true 时按「同科目+同配档编组+同学期代号」整组一起改(同开同结)。
+ *
+ * @return 实际更新的任务行数
+ */
+ int save(List items);
+
+ /**
+ * 优选序数上移/下移。先把该班次学期的配档序号按当前展示顺序归一化为 1..n,
+ * 再交换相邻两条。direction: up / down。
+ *
+ * @return 归一化后的任务编号顺序(供前端刷新)
+ */
+ List move(String xydxqbh, String bh, String direction);
+
+ /**
+ * 编组管理面板:同科目全部开课班次(跨班次学期)。
+ */
+ List> groups(String kbh);
+
+ /**
+ * 编组管理:把所选任务统一改编组号。
+ *
+ * @return 实际更新行数
+ */
+ int setGroup(AllocationGroupRequest request);
+
+ /**
+ * 导出配当 Excel(批量:每行一个任务,含班次名称与学期代号列)。
+ */
+ void exportExcel(List xydxqbhList, HttpServletResponse response) throws Exception;
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ClassEventCalendarServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ClassEventCalendarServiceImpl.java
index 6414c909..cd600e08 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ClassEventCalendarServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ClassEventCalendarServiceImpl.java
@@ -241,8 +241,10 @@ public class ClassEventCalendarServiceImpl implements ClassEventCalendarService
.eq(JQB::getJqsj, source.getJqsj())
.eq(JQB::getCourseClass, source.getCourseClass()));
if (matched.isEmpty()) {
+ // 目标班次该格没有记录时按源格新建。可排课必须沿用源格:
+ // 之前固定传 0 会把「可排课」丢掉,使目标班次凭空多出不可排的格子。
insertCell(target, day, source.getCourseClass(), source.getJqmc(),
- source.getJc(), 0, source.getBzxs(), source.getZdpk(), source.getZk(), source.getBz());
+ source.getJc(), source.getKpk(), source.getBzxs(), source.getZdpk(), source.getZk(), source.getBz());
return true;
}
for (JQB row : matched) {
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/CourseRunningImportServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/CourseRunningImportServiceImpl.java
index 20e6bebc..c08b0a04 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/CourseRunningImportServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/CourseRunningImportServiceImpl.java
@@ -371,27 +371,17 @@ public class CourseRunningImportServiceImpl implements CourseRunningImportServic
// 步骤2:遍历课程列表,逐个处理导入
for (SSKC course : courseList) {
try {
- // 步骤3:将课程转换为课次列表(根据总学时计算课次数)
- List lessonList = convertToLessons(course, nd);
+ // 阶段 5.2 停用 convertToLessons:导入不再自动生成课次(实施_课程表),
+ // 课次改由排课窗口按节次安排(SchedulingWindowService.arrange)。
+ // 实施_课程 与 实施_课程学员队 数据在导入前已存在,此处仅记录导入日志。
- // 步骤4:遍历课次列表,写入数据库
- for (SSKCB lesson : lessonList) {
- // 写入实施_课程表主记录
- sskcbMapper.insert(lesson);
-
- // 写入关联数据:学员队、教室、教员
- insertSSKCBXYD(lesson, course.getBh(), nd);
- insertSSKCBJS(lesson, nd);
- insertSSKCBFZJY(lesson, nd);
- }
-
- // 步骤5:记录导入操作日志
+ // 步骤3:记录导入操作日志
recordOperationLog(course.getBh(), "导入", "导入课程:" + course.getKmbh());
- // 步骤6:更新成功计数
+ // 步骤4:更新成功计数
successCount++;
} catch (Exception e) {
- // 步骤7:记录失败的课程编号
+ // 步骤5:记录失败的课程编号
failedList.add(course.getBh());
}
}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveCourseServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveCourseServiceImpl.java
index 28a0d666..21057006 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveCourseServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveCourseServiceImpl.java
@@ -26,6 +26,7 @@ import com.roomroot.jwgl.mapper.XYDQBMapper;
import com.roomroot.jwgl.service.ElectiveCourseService;
import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
import com.roomroot.jwgl.utils.UuidUtil;
import com.roomroot.jwgl.vo.jys.ElectiveCourseStudentVO;
import com.roomroot.jwgl.vo.jys.ElectiveCourseVO;
@@ -106,7 +107,10 @@ public class ElectiveCourseServiceImpl implements ElectiveCourseService {
XYDRWB task = new XYDRWB();
task.setBh(UuidUtil.getOriginalUUID());
task.setDelFlag(0);
- task.setNd(dto.getNd() != null ? dto.getNd() : LocalDate.now().getYear());
+ // 任务表/实施/课程表的「年度」口径是 6 位学期代号(见 SemesterCodeUtil 类注释);
+ // 前端不传 nd 时按当前日期推断春/夏/秋学期,避免落成 4 位年份导致年度连接断裂
+ task.setNd(dto.getNd() != null ? dto.getNd()
+ : SemesterCodeUtil.resolve(LocalDate.now().getYear(), SemesterCodeUtil.periodOf(LocalDate.now())));
task.setXydbh(xydbh);
task.setKbh(course.getKbh());
task.setXqdc(dto.getXqdc());
@@ -245,7 +249,7 @@ public class ElectiveCourseServiceImpl implements ElectiveCourseService {
XYDRWB task = new XYDRWB();
task.setBh(UuidUtil.getOriginalUUID());
task.setDelFlag(0);
- task.setNd(LocalDate.now().getYear());
+ task.setNd(SemesterCodeUtil.resolve(LocalDate.now().getYear(), SemesterCodeUtil.periodOf(LocalDate.now())));
task.setXydbh(xydbh);
task.setKbh(course.getKbh());
task.setJybh(jybh);
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveServiceImpl.java
index 8eac2517..b4eaf324 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/ElectiveServiceImpl.java
@@ -12,6 +12,7 @@ import com.roomroot.jwgl.service.ElectiveService;
import com.roomroot.jwgl.unit.BusinessException;
import com.roomroot.jwgl.unit.PageQuery;
import com.roomroot.jwgl.unit.PageResult;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
import com.roomroot.jwgl.utils.UuidUtil;
import com.roomroot.jwgl.vo.elective.*;
import jakarta.annotation.Resource;
@@ -238,11 +239,16 @@ public class ElectiveServiceImpl implements ElectiveService {
// 判断学制是否半年以内
boolean halfYear = isHalfYear(rx, by);
- // 学期第次:半年以内固定第一学期;半年以上优先用窗口已选学期第次,否则按入学日期推断
+ // 学期第次:半年以内固定第一学期;半年以上优先用窗口已选学期第次,
+ // 未选/非法时按开学日期推断「学年内学期类别」(1 春季 / 2 夏季 / 3 秋季)。
+ // 注意:学期第次不是「专业总第几学期」——人培(专业教学计划表)的学期第次才是专业总学期口径,
+ // 不能把按半年推算出的 4~8 写进班次学期,否则学期代号(年度×100+学期第次)会越过 1/2/3 契约。
if (halfYear) {
xqdc = 1;
} else if (xqdc == null || xqdc < 1 || xqdc > 3) {
- xqdc = inferXqdc(rx, by, LocalDate.now());
+ LocalDate preset = parseDate(presetKxrq);
+ LocalDate ref = preset != null ? preset : (rx != null ? rx : LocalDate.now());
+ xqdc = SemesterCodeUtil.periodOf(ref);
}
// 人才培养方案总学期数(半年一学期,向上取整)
Integer totalXqdc = calcTotalXqdc(rx, by);
@@ -294,7 +300,10 @@ public class ElectiveServiceImpl implements ElectiveService {
LocalDate by = clazz.getByrq() != null ? clazz.getByrq().toLocalDate() : null;
boolean halfYear = isHalfYear(rx, by);
- // 学期第次:半年以内只能第一学期;半年以上为空时智能推断
+ // 学期第次:半年以内只能第一学期;半年以上为空时按开学日期推断学年内学期类别,
+ // 传入值必须是 1/2/3(与 ClassSemesterServiceImpl.assertXqdc 同一契约)。
+ // 注意:学期第次是学年内学期类别(1 春季 / 2 夏季 / 3 秋季),
+ // 不能用「半年一学期」推算出的专业总学期(可达 6~8)写入,否则会生成非法学期代号。
Integer xqdc = xydndxqjbxxb.getXqdc();
if (halfYear) {
if (xqdc != null && xqdc != 1) {
@@ -302,7 +311,10 @@ public class ElectiveServiceImpl implements ElectiveService {
}
xqdc = 1;
} else if (xqdc == null) {
- xqdc = inferXqdc(rx, by, LocalDate.now());
+ LocalDate ref = xydndxqjbxxb.getKxrq() != null ? xydndxqjbxxb.getKxrq() : rx;
+ xqdc = SemesterCodeUtil.periodOf(ref);
+ } else if (xqdc < 1 || xqdc > 3) {
+ throw new BusinessException("学期第次只能是 1(春季)、2(夏季)、3(秋季),当前传入:" + xqdc);
}
xydndxqjbxxb.setXqdc(xqdc);
@@ -382,31 +394,6 @@ public class ElectiveServiceImpl implements ElectiveService {
return Math.max(1, (int) ((totalMonths + 5) / 6));
}
- /**
- * 根据班次入学日期智能推断指定日期所在的学期第次(每半年为一个学期)
- *
- * 半年以内的班次返回 1;半年以上的班次按 入学日期 到 指定日期 经过的月份
- * 每 6 个月为 1 个学期递增,且不超过人才培养方案总学期数。
- *
- */
- private Integer inferXqdc(LocalDate rx, LocalDate by, LocalDate target) {
- if (rx == null) {
- return 1;
- }
- if (target.isBefore(rx)) {
- return 1;
- }
- // 半年以内固定第一学期
- if (by != null && isHalfYear(rx, by)) {
- return 1;
- }
- long months = ChronoUnit.MONTHS.between(rx, target);
- int xqdc = (int) (months / 6) + 1;
- // 不超过总学期数
- Integer total = calcTotalXqdc(rx, by);
- return Math.min(xqdc, total);
- }
-
/**
* 获取某班次指定学期第次的上一学期专用教室(用于第二以上学期的默认预设)
*
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java
new file mode 100644
index 00000000..65eda53d
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/JYLServiceImpl.java
@@ -0,0 +1,109 @@
+package com.roomroot.jwgl.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.roomroot.common.utils.StringUtils;
+import com.roomroot.jwgl.entity.JYL;
+import com.roomroot.jwgl.mapper.JYLMapper;
+import com.roomroot.jwgl.service.JYLService;
+import com.roomroot.jwgl.unit.PageQuery;
+import com.roomroot.jwgl.unit.PageResult;
+import org.springframework.stereotype.Service;
+
+import jakarta.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 教员历 Service 实现。交互口径与教学场地历一致:新增生成编号 + delFlag=0,删除为软删。
+ */
+@Service
+public class JYLServiceImpl implements JYLService {
+
+ @Resource
+ private JYLMapper jylMapper;
+
+ @Override
+ public void add(JYL row) {
+ if (row.getJybh() == null || row.getJybh().isEmpty()) {
+ throw new IllegalArgumentException("请指定教员");
+ }
+ row.setBh(UUID.randomUUID().toString());
+ if (row.getKpk() == null) {
+ row.setKpk(0);
+ }
+ row.setCjsj(LocalDateTime.now());
+ row.setDelFlag(0);
+ jylMapper.insert(row);
+ }
+
+ @Override
+ public void delete(String bh) {
+ JYL row = jylMapper.selectById(bh);
+ if (row != null) {
+ row.setDelFlag(1);
+ jylMapper.updateById(row);
+ }
+ }
+
+ @Override
+ public void update(JYL row) {
+ row.setDelFlag(null);
+ jylMapper.updateById(row);
+ }
+
+ @Override
+ public JYL getById(String bh) {
+ return jylMapper.selectById(bh);
+ }
+
+ @Override
+ public PageResult pageList(PageQuery query, JYL condition) {
+ PageQuery q = query == null ? new PageQuery() : query;
+ int pageNum = q.getPageNum() == null ? 1 : q.getPageNum();
+ int pageSize = q.getPageSize() == null ? 10 : q.getPageSize();
+ com.baomidou.mybatisplus.extension.plugins.pagination.Page page =
+ new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pageNum, pageSize);
+ jylMapper.selectPage(page, buildWrapper(condition));
+ return new PageResult<>(page.getRecords(), page.getTotal(), pageNum, pageSize);
+ }
+
+ @Override
+ public List list(JYL condition) {
+ return jylMapper.selectList(buildWrapper(condition));
+ }
+
+ @Override
+ public List listByJybh(String jybh) {
+ return jylMapper.selectList(new LambdaQueryWrapper()
+ .eq(JYL::getJybh, jybh)
+ .eq(JYL::getDelFlag, 0)
+ .orderByAsc(JYL::getRq)
+ .orderByAsc(JYL::getJc));
+ }
+
+ @Override
+ public List listByRq(String rq) {
+ return jylMapper.selectList(new LambdaQueryWrapper()
+ .apply("DATE(rq) = {0}", rq)
+ .eq(JYL::getDelFlag, 0));
+ }
+
+ private LambdaQueryWrapper buildWrapper(JYL condition) {
+ LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
+ qw.eq(JYL::getDelFlag, 0);
+ if (condition != null) {
+ if (StringUtils.isNotEmpty(condition.getJybh())) {
+ qw.eq(JYL::getJybh, condition.getJybh());
+ }
+ if (StringUtils.isNotEmpty(condition.getMc())) {
+ qw.like(JYL::getMc, condition.getMc());
+ }
+ if (condition.getKpk() != null) {
+ qw.eq(JYL::getKpk, condition.getKpk());
+ }
+ }
+ qw.orderByAsc(JYL::getRq).orderByAsc(JYL::getJc);
+ return qw;
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java
new file mode 100644
index 00000000..c3519f7a
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/RunningCoursePublishServiceImpl.java
@@ -0,0 +1,255 @@
+package com.roomroot.jwgl.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.roomroot.common.exception.ServiceException;
+import com.roomroot.jwgl.entity.JXRW;
+import com.roomroot.jwgl.entity.SSKC;
+import com.roomroot.jwgl.entity.SSKCB;
+import com.roomroot.jwgl.entity.SSKCBFZJY;
+import com.roomroot.jwgl.entity.SSKCBJS;
+import com.roomroot.jwgl.entity.SSKCBXYD;
+import com.roomroot.jwgl.entity.SSKCXYD;
+import com.roomroot.jwgl.entity.XYDB;
+import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
+import com.roomroot.jwgl.entity.XYDRWB;
+import com.roomroot.jwgl.mapper.ClassSemesterMapper;
+import com.roomroot.jwgl.mapper.SSKCBFZJYMapper;
+import com.roomroot.jwgl.mapper.SSKCBJSMapper;
+import com.roomroot.jwgl.mapper.SSKCBMapper;
+import com.roomroot.jwgl.mapper.SSKCBXYDMapper;
+import com.roomroot.jwgl.mapper.SSKCMapper;
+import com.roomroot.jwgl.mapper.SSKCXYDMapper;
+import com.roomroot.jwgl.mapper.TeachingTaskMapper;
+import com.roomroot.jwgl.mapper.XYDBMapper;
+import com.roomroot.jwgl.service.RunningCoursePublishService;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
+import com.roomroot.jwgl.utils.TeachingTaskStatus;
+import com.roomroot.jwgl.utils.UuidUtil;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
+import static com.roomroot.jwgl.utils.BasicManagementConstants.NOT_FOUND;
+
+/**
+ * 运行课表批量发布/撤回实现(阶段 5.2)。
+ *
+ * 发布 = 班次学期的课程任务(学员队任务表)按 合班组(编组 bz2)聚合写成
+ * 实施_课程 + 实施_课程学员队;不生成 实施_课程表 空壳课次。
+ * 课次由阶段 5.3 排课窗逐格安排。
+ */
+@Slf4j
+@Service
+public class RunningCoursePublishServiceImpl implements RunningCoursePublishService {
+
+ @Resource
+ private ClassSemesterMapper classSemesterMapper;
+
+ @Resource
+ private com.roomroot.jwgl.mapper.StudentTeamTaskMapper studentTeamTaskMapper;
+
+ @Resource
+ private TeachingTaskMapper jxrwMapper;
+
+ @Resource
+ private XYDBMapper xydbMapper;
+
+ @Resource
+ private SSKCMapper sskcMapper;
+
+ @Resource
+ private SSKCXYDMapper sskcxydMapper;
+
+ @Resource
+ private SSKCBMapper sskcbMapper;
+
+ @Resource
+ private SSKCBXYDMapper sskcbxydMapper;
+
+ @Resource
+ private SSKCBJSMapper sskcbjsMapper;
+
+ @Resource
+ private SSKCBFZJYMapper sskcbfzjyMapper;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int publish(String xydxqbh) {
+ XYDNDXQJBXXB semester = requireSemester(xydxqbh);
+ Integer ndCode = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+ XYDB team = xydbMapper.selectById(semester.getXydbh());
+
+ // 门禁:任务书已发布且未结束
+ assertTaskBookPublished(semester);
+
+ List tasks = studentTeamTaskMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getXydxqbh, semester.getBh())
+ .eq(XYDRWB::getDelFlag, 0));
+ if (tasks.isEmpty()) {
+ throw new ServiceException("该班次学期没有课程任务可发布", NOT_FOUND);
+ }
+
+ // 幂等:已有发布记录则拒绝
+ List published = sskcxydMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getXydbh, semester.getXydbh())
+ .eq(SSKCXYD::getNd, ndCode));
+ if (!published.isEmpty()) {
+ throw new ServiceException("该班次学期已发布 " + published.size() + " 条运行课程,请先撤回再重新发布", BAD_REQUEST);
+ }
+
+ // 整批校验:责任教员、默认场地非空,缺项全部列出
+ List missing = new ArrayList<>();
+ for (XYDRWB task : tasks) {
+ List lack = new ArrayList<>();
+ if (task.getJybh() == null || task.getJybh().isEmpty()) {
+ lack.add("责任教员");
+ }
+ if (task.getJsbh() == null || task.getJsbh().isEmpty()) {
+ lack.add("默认场地");
+ }
+ if (!lack.isEmpty()) {
+ missing.add(task.getJc() + "(" + teamName(team) + ")缺 " + String.join("、", lack));
+ }
+ }
+ if (!missing.isEmpty()) {
+ throw new ServiceException("发布失败,以下课程缺项:" + String.join(";", missing), BAD_REQUEST);
+ }
+
+ // 按合班组聚合:bz2 非 0 同组一条实施课程,未合班每任务一条
+ Map> groups = new LinkedHashMap<>();
+ for (XYDRWB task : tasks) {
+ String key = task.getBz2() != null && task.getBz2() != 0
+ ? "G" + task.getBz2() + "|" + task.getKbh()
+ : "S" + task.getBh();
+ groups.computeIfAbsent(key, k -> new ArrayList<>()).add(task);
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ int count = 0;
+ for (List group : groups.values()) {
+ XYDRWB head = group.get(0);
+ SSKC sskc = new SSKC();
+ sskc.setBh(UuidUtil.getUUID());
+ sskc.setNd(ndCode);
+ sskc.setJybh(head.getJybh());
+ sskc.setKmbh(head.getKbh());
+ sskc.setJsbh(head.getJsbh());
+ sskc.setXs(head.getXs());
+ sskc.setKlx(head.getKlx());
+ sskc.setCjfz(head.getCjfz());
+ sskc.setZks(head.getZks());
+ sskc.setXf(head.getXf());
+ sskc.setLlxs(head.getLlxs());
+ sskc.setSjxs(head.getSjxs());
+ sskc.setBjrxypjf(head.getBjrxypjf());
+ sskc.setCjsj(now);
+ sskc.setBdsj(now);
+ sskcMapper.insert(sskc);
+
+ for (XYDRWB task : group) {
+ SSKCXYD link = new SSKCXYD();
+ link.setBh(UuidUtil.getUUID());
+ link.setSskcbh(sskc.getBh());
+ link.setXydbh(task.getXydbh());
+ link.setJc(team != null ? team.getJc() : null);
+ link.setRs(team != null ? team.getXydrs() : null);
+ link.setNd(ndCode);
+ link.setXqdc(semester.getXqdc());
+ link.setXf(task.getXf());
+ sskcxydMapper.insert(link);
+ }
+ count++;
+ }
+ return count;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int withdraw(String xydxqbh) {
+ XYDNDXQJBXXB semester = requireSemester(xydxqbh);
+ Integer ndCode = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+
+ // 该班次学期的发布关联
+ List links = sskcxydMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getXydbh, semester.getXydbh())
+ .eq(SSKCXYD::getNd, ndCode));
+ if (links.isEmpty()) {
+ throw new ServiceException("该班次学期没有已发布的运行课程", NOT_FOUND);
+ }
+ List sskcBhs = links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
+
+ int removed = 0;
+ for (String sskcbh : sskcBhs) {
+ // 删除该课程的课次及其教室/教员/学员队关联
+ List lessons = sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getSskcbh, sskcbh));
+ for (SSKCB lesson : lessons) {
+ sskcbxydMapper.delete(new LambdaQueryWrapper()
+ .eq(SSKCBXYD::getSskcbbh, lesson.getBh()));
+ sskcbjsMapper.delete(new LambdaQueryWrapper()
+ .eq(SSKCBJS::getSskcbbh, lesson.getBh()));
+ sskcbfzjyMapper.delete(new LambdaQueryWrapper()
+ .eq(SSKCBFZJY::getSskcbbh, lesson.getBh()));
+ }
+ sskcbMapper.delete(new LambdaQueryWrapper()
+ .eq(SSKCB::getSskcbh, sskcbh));
+ // TODO(业务确认):课次已有调课/请假记录时应先确认,当前直接随撤回删除
+
+ // 删除本班次的关联行
+ sskcxydMapper.delete(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getSskcbh, sskcbh)
+ .eq(SSKCXYD::getXydbh, semester.getXydbh())
+ .eq(SSKCXYD::getNd, ndCode));
+ // 合班课程可能仍被其它班次引用:仅当不再有任何关联时删除 实施_课程 本体
+ Long remain = sskcxydMapper.selectCount(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getSskcbh, sskcbh));
+ if (remain == null || remain == 0) {
+ sskcMapper.deleteById(sskcbh);
+ }
+ removed++;
+ }
+ return removed;
+ }
+
+ // ==================== 公共 ====================
+
+ private XYDNDXQJBXXB requireSemester(String xydxqbh) {
+ if (xydxqbh == null || xydxqbh.isEmpty()) {
+ throw new ServiceException("请指定班次学期", BAD_REQUEST);
+ }
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
+ if (semester == null) {
+ throw new ServiceException("班次学期不存在:" + xydxqbh, NOT_FOUND);
+ }
+ return semester;
+ }
+
+ private void assertTaskBookPublished(XYDNDXQJBXXB semester) {
+ if (semester.getJxrwbh() == null || semester.getJxrwbh().isEmpty()) {
+ throw new ServiceException("该班次学期没有关联教学任务,不能发布", BAD_REQUEST);
+ }
+ JXRW jxrw = jxrwMapper.selectById(semester.getJxrwbh());
+ if (jxrw == null || Integer.valueOf(1).equals(jxrw.getDelFlag())) {
+ throw new ServiceException("关联教学任务不存在,不能发布", BAD_REQUEST);
+ }
+ if (!TeachingTaskStatus.isPublished(jxrw.getZt())) {
+ throw new ServiceException("教学任务尚未发布,不能发布运行课表", BAD_REQUEST);
+ }
+ if (TeachingTaskStatus.isEnded(jxrw.getZt())) {
+ throw new ServiceException("教学任务已结束,不能发布运行课表", BAD_REQUEST);
+ }
+ }
+
+ private String teamName(XYDB team) {
+ return team != null && team.getXydmc() != null ? team.getXydmc() : "-";
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java
new file mode 100644
index 00000000..ea80c00e
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/SchedulingWindowServiceImpl.java
@@ -0,0 +1,808 @@
+package com.roomroot.jwgl.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.roomroot.common.exception.ServiceException;
+import com.roomroot.common.utils.SecurityUtils;
+import com.roomroot.jwgl.dto.scheduling.SchedulingArrangeRequest;
+import com.roomroot.jwgl.dto.scheduling.SchedulingCellOpRequest;
+import com.roomroot.jwgl.entity.JQB;
+import com.roomroot.jwgl.entity.JYB;
+import com.roomroot.jwgl.entity.JXCDL;
+import com.roomroot.jwgl.entity.JYL;
+import com.roomroot.jwgl.entity.KCJXYX_CZRZ;
+import com.roomroot.jwgl.entity.SSKC;
+import com.roomroot.jwgl.entity.SSKCB;
+import com.roomroot.jwgl.entity.SSKCBFZJY;
+import com.roomroot.jwgl.entity.SSKCBJS;
+import com.roomroot.jwgl.entity.SSKCBXYD;
+import com.roomroot.jwgl.entity.SSKCXYD;
+import com.roomroot.jwgl.entity.XYDB;
+import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
+import com.roomroot.jwgl.entity.XQXLB;
+import com.roomroot.jwgl.mapper.XYDBMapper;
+import com.roomroot.jwgl.mapper.ClassRoomCalendarMapper;
+import com.roomroot.jwgl.mapper.ClassSemesterMapper;
+import com.roomroot.jwgl.mapper.JQBMapper;
+import com.roomroot.jwgl.mapper.JYBMapper;
+import com.roomroot.jwgl.mapper.JYLMapper;
+import com.roomroot.jwgl.mapper.KBMapper;
+import com.roomroot.jwgl.mapper.KCJXYX_CZRZMapper;
+import com.roomroot.jwgl.mapper.SSKCBFZJYMapper;
+import com.roomroot.jwgl.mapper.SSKCBJSMapper;
+import com.roomroot.jwgl.mapper.SSKCBMapper;
+import com.roomroot.jwgl.mapper.SSKCBXYDMapper;
+import com.roomroot.jwgl.mapper.SSKCMapper;
+import com.roomroot.jwgl.mapper.SSKCXYDMapper;
+import com.roomroot.jwgl.mapper.SemesterCalendarMapper;
+import com.roomroot.jwgl.service.SchedulingWindowService;
+import com.roomroot.jwgl.utils.JwglRoleHelper;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
+import com.roomroot.jwgl.utils.UuidUtil;
+import com.roomroot.jwgl.vo.scheduling.SchedulingViewVO;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
+import static com.roomroot.jwgl.utils.BasicManagementConstants.FORBIDDEN;
+import static com.roomroot.jwgl.utils.BasicManagementConstants.NOT_FOUND;
+
+/**
+ * 实施排课窗口实现(阶段 5.3)。
+ *
+ * 冲突口径:硬冲突(格忽略)= 教员/教室/班次双占、校历/班历/场地历/教员历不可排;
+ * 软提示(照常安排)= 非正课、配当周次不符。
+ */
+@Slf4j
+@Service
+public class SchedulingWindowServiceImpl implements SchedulingWindowService {
+
+ /** 默认一节 2 学时 */
+ private static final int HOURS_PER_LESSON = 2;
+
+ @Resource
+ private ClassSemesterMapper classSemesterMapper;
+
+ @Resource
+ private KBMapper kbMapper;
+
+ @Resource
+ private com.roomroot.jwgl.service.TeachingAllocationService teachingAllocationService;
+
+ @Resource
+ private JYBMapper jybMapper;
+
+ @Resource
+ private XYDBMapper xydbMapper;
+
+ @Resource
+ private SSKCMapper sskcMapper;
+
+ @Resource
+ private SSKCXYDMapper sskcxydMapper;
+
+ @Resource
+ private SSKCBMapper sskcbMapper;
+
+ @Resource
+ private SSKCBXYDMapper sskcbxydMapper;
+
+ @Resource
+ private SSKCBJSMapper sskcbjsMapper;
+
+ @Resource
+ private SSKCBFZJYMapper sskcbfzjyMapper;
+
+ @Resource
+ private SemesterCalendarMapper semesterCalendarMapper;
+
+ @Resource
+ private JQBMapper jqbMapper;
+
+ @Resource
+ private ClassRoomCalendarMapper classRoomCalendarMapper;
+
+ @Resource
+ private JYLMapper jylMapper;
+
+ @Resource
+ private KCJXYX_CZRZMapper czrzMapper;
+
+ @Resource
+ private JwglRoleHelper roleHelper;
+
+ @Resource
+ private com.roomroot.jwgl.mapper.StudentTeamTaskMapper studentTeamTaskMapper;
+
+ // ==================== 视图 ====================
+
+ @Override
+ public SchedulingViewVO view(String xydxqbh) {
+ Ctx ctx = loadCtx(xydxqbh);
+ SchedulingViewVO vo = new SchedulingViewVO();
+ vo.setXydxqbh(ctx.semester.getBh());
+ vo.setXydmc(ctx.team != null ? ctx.team.getXydmc() : ctx.semester.getXydbh());
+ vo.setZyjsbh(ctx.semester.getZyjsbh());
+ vo.setNdCode(ctx.ndCode);
+ vo.setKxrq(ctx.semester.getKxrq());
+ vo.setJsrq(ctx.semester.getJsrq());
+
+ // 课程列表(本班次已发布的运行课程)
+ List links = ctx.links;
+ List sskcIds = links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
+ Map courses = sskcIds.isEmpty() ? Map.of()
+ : sskcMapper.selectBatchIds(sskcIds).stream().collect(Collectors.toMap(SSKC::getBh, c -> c, (a, b) -> a));
+ List lessons = ctx.lessons;
+ Map> lessonsByCourse = lessons.stream()
+ .collect(Collectors.groupingBy(SSKCB::getSskcbh));
+ for (String sskcbh : sskcIds) {
+ SSKC course = courses.get(sskcbh);
+ if (course == null) continue;
+ SchedulingViewVO.Course c = new SchedulingViewVO.Course();
+ c.setSskcbh(course.getBh());
+ c.setKbh(course.getKmbh());
+ c.setKcmc(resolveCourseName(course.getKmbh()));
+ c.setKlx(course.getKlx());
+ c.setXs(course.getXs());
+ c.setZks(course.getZks());
+ c.setJybh(course.getJybh());
+ c.setJsbh(course.getJsbh());
+ List cl = lessonsByCourse.getOrDefault(sskcbh, List.of());
+ c.setScheduledHours(cl.stream().mapToInt(l -> HOURS_PER_LESSON + (l.getJcdj() == null ? 0 : l.getJcdj())).sum());
+ c.setFull(course.getXs() != null && c.getScheduledHours() >= course.getXs());
+ c.setEditable(canEdit(course, ctx));
+ vo.getCourses().add(c);
+ }
+
+ // 时间格
+ buildGrid(ctx, vo);
+ return vo;
+ }
+
+ /** 周次×星期×节次格子:叠加校历/班历不可排标记与已排课次 */
+ private void buildGrid(Ctx ctx, SchedulingViewVO vo) {
+ LocalDate kxrq = ctx.semester.getKxrq();
+ LocalDate jsrq = ctx.semester.getJsrq();
+ if (kxrq == null || jsrq == null) {
+ throw new ServiceException("班次学期缺少开学/结束日期,无法生成排课时间区", BAD_REQUEST);
+ }
+ // 历表不可排数据(本学期代号范围内)
+ Set schoolHard = new HashSet<>(); // 校历不可排 date#jc
+ Set schoolSoft = new HashSet<>(); // 非正课
+ List schoolRows = semesterCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(XQXLB::getNd, ctx.ndCode));
+ for (XQXLB row : schoolRows) {
+ if (row.getJqsj() == null) continue;
+ for (Integer jc : parsePeriods(row.getCourseClass())) {
+ String key = row.getJqsj() + "#" + jc;
+ if (Boolean.FALSE.equals(row.getKpk())) schoolHard.add(key);
+ else if (Boolean.FALSE.equals(row.getZk())) schoolSoft.add(key);
+ }
+ }
+ Set classHard = new HashSet<>();
+ Set classSoft = new HashSet<>();
+ List classRows = jqbMapper.selectList(new LambdaQueryWrapper()
+ .eq(JQB::getXydxqbh, ctx.semester.getBh()));
+ for (JQB row : classRows) {
+ if (row.getJqsj() == null) continue;
+ String date = intDate(row.getJqsj());
+ for (Integer jc : parsePeriods(row.getCourseClass())) {
+ String key = date + "#" + jc;
+ if (row.getKpk() != null && row.getKpk() == 0) classHard.add(key);
+ else if (row.getZk() != null && row.getZk() == 0) classSoft.add(key);
+ }
+ }
+ // 已排课次
+ Map> lessonsByGrid = ctx.lessons.stream()
+ .filter(l -> l.getRq() != null && l.getJc() != null)
+ .collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
+ Map courseNames = new HashMap<>();
+ Map courseTeachers = new HashMap<>();
+ for (SSKC course : sskcMapper.selectBatchIds(ctx.links.stream().map(SSKCXYD::getSskcbh).distinct().toList())) {
+ courseNames.put(course.getBh(), resolveCourseName(course.getKmbh()));
+ courseTeachers.put(course.getBh(), safe(course.getJybh()));
+ }
+
+ LocalDate weekStart = kxrq.with(DayOfWeek.MONDAY);
+ int weekNo = 1;
+ int[] periodCount = {8};
+ while (!weekStart.isAfter(jsrq)) {
+ SchedulingViewVO.Week week = new SchedulingViewVO.Week();
+ week.setWeekNo(weekNo);
+ week.setStartDate(weekStart);
+ week.setEndDate(weekStart.plusDays(6).isAfter(jsrq) ? jsrq : weekStart.plusDays(6));
+ for (int d = 0; d < 7; d++) {
+ LocalDate date = weekStart.plusDays(d);
+ if (date.isBefore(kxrq) || date.isAfter(jsrq)) continue;
+ SchedulingViewVO.Day day = new SchedulingViewVO.Day();
+ day.setDate(date);
+ day.setWeekday(date.getDayOfWeek().getValue());
+ for (int jc = 1; jc <= periodCount[0]; jc++) {
+ SchedulingViewVO.Cell cell = new SchedulingViewVO.Cell();
+ cell.setJc(jc);
+ String key = date.toString() + "#" + jc;
+ if (schoolHard.contains(key) || classHard.contains(key)) {
+ cell.setUnavailable(true);
+ cell.setReason("不可排课(" + (schoolHard.contains(key) ? "校历" : "班历") + ")");
+ } else {
+ if (schoolSoft.contains(key) || classSoft.contains(key)) {
+ cell.setWarning("非正课时段");
+ }
+ cell.setUnavailable(false);
+ }
+ for (SSKCB lesson : lessonsByGrid.getOrDefault(key, List.of())) {
+ SchedulingViewVO.Lesson l = new SchedulingViewVO.Lesson();
+ l.setBh(lesson.getBh());
+ l.setSskcbh(lesson.getSskcbh());
+ l.setKcmc(courseNames.getOrDefault(lesson.getSskcbh(), "-"));
+ l.setJxnr(lesson.getJxnr());
+ l.setJxff(lesson.getJxff());
+ l.setJcdj(lesson.getJcdj());
+ l.setYcxx(lesson.getYcxx());
+ l.setJyxm(courseTeachers.getOrDefault(lesson.getSskcbh(), "-"));
+ cell.getLessons().add(l);
+ }
+ day.getCells().add(cell);
+ }
+ week.getDays().add(day);
+ }
+ vo.getWeeks().add(week);
+ weekNo++;
+ weekStart = weekStart.plusDays(7);
+ }
+ vo.setTotalWeeks(weekNo - 1);
+ }
+
+ // ==================== 安排所选节次 ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map arrange(SchedulingArrangeRequest request) {
+ if (request == null || request.getSskcbh() == null || request.getCells() == null || request.getCells().isEmpty()) {
+ throw new ServiceException("请指定课程与要安排的节次", BAD_REQUEST);
+ }
+ SSKC course = requireCourse(request.getSskcbh());
+ Ctx ctx = loadCtxForCourse(course);
+ if (!canEdit(course, ctx)) {
+ throw new ServiceException("没有该课程的排课权限", FORBIDDEN);
+ }
+ String jsbh = request.getJsbh() != null && !request.getJsbh().isEmpty() ? request.getJsbh() : course.getJsbh();
+ if (jsbh == null || jsbh.isEmpty()) {
+ throw new ServiceException("请指定场地(课程未设置默认场地)", BAD_REQUEST);
+ }
+ String jybh = request.getJybh() != null && !request.getJybh().isEmpty() ? request.getJybh() : course.getJybh();
+
+ // 硬冲突判定所需数据
+ List semesterLessons = ctx.lessons;
+ Map> byGrid = semesterLessons.stream()
+ .filter(l -> l.getRq() != null && l.getJc() != null)
+ .collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
+ // 课次 → 学员队 / 教室 / 教员(无占用课次时跳过,避免空 IN())
+ Map> teamsOfLesson = new HashMap<>();
+ Map> roomsOfLesson = new HashMap<>();
+ Map> teachersOfLesson = new HashMap<>();
+ List occupantIds = byGrid.values().stream().flatMap(List::stream)
+ .map(SSKCB::getBh).distinct().toList();
+ if (!occupantIds.isEmpty()) {
+ for (SSKCBXYD l : sskcbxydMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBXYD::getSskcbbh, occupantIds))) {
+ teamsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getXydbh());
+ }
+ for (SSKCBJS l : sskcbjsMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBJS::getSskcbbh, occupantIds))) {
+ roomsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getJsbh());
+ }
+ for (SSKCBFZJY l : sskcbfzjyMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCBFZJY::getSskcbbh, occupantIds))) {
+ teachersOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getFzjybh());
+ }
+ }
+ // 场地历 / 教员历 不可排
+ Map roomBlocked = new HashMap<>();
+ for (JXCDL row : classRoomCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(JXCDL::getKpk, false).eq(JXCDL::getDelFlag, 0))) {
+ if (row.getRq() != null && row.getJsbh() != null) {
+ roomBlocked.put(row.getRq().toLocalDate().toString() + "#" + row.getJc() + "#" + row.getJsbh(), "场地历不可用");
+ }
+ }
+ Map teacherBlocked = new HashMap<>();
+ for (JYL row : jylMapper.selectList(new LambdaQueryWrapper()
+ .eq(JYL::getKpk, 0).eq(JYL::getDelFlag, 0))) {
+ if (row.getRq() != null && row.getJybh() != null) {
+ teacherBlocked.put(row.getRq().toLocalDate().toString() + "#" + row.getJc() + "#" + row.getJybh(), "教员历不可用");
+ }
+ }
+ // 校历/班历不可排(节次级)
+ Set schoolHard = calendarHardKeys(ctx, true);
+ Set classHard = calendarHardKeys(ctx, false);
+
+ // 配当周次(软提示):该课程配当分布覆盖的周
+ Set allocatedWeeks = allocatedWeeks(ctx, course);
+
+ LocalDateTime now = LocalDateTime.now();
+ int arranged = 0;
+ List> ignored = new ArrayList<>();
+ List> warnings = new ArrayList<>();
+ for (SchedulingArrangeRequest.Cell cell : request.getCells()) {
+ if (cell.getRq() == null || cell.getJc() == null) continue;
+ String key = cell.getRq() + "#" + cell.getJc();
+ List occupants = byGrid.getOrDefault(key, List.of());
+ // 硬冲突:不可排课
+ if (schoolHard.contains(key)) {
+ ignored.add(ignoredCell(cell, "校历不可排课"));
+ continue;
+ }
+ if (classHard.contains(key)) {
+ ignored.add(ignoredCell(cell, "班历不可排课"));
+ continue;
+ }
+ String roomKey = key + "#" + jsbh;
+ if (roomBlocked.containsKey(roomKey)) {
+ ignored.add(ignoredCell(cell, "场地历不可用"));
+ continue;
+ }
+ if (jybh != null && teacherBlocked.containsKey(key + "#" + jybh)) {
+ ignored.add(ignoredCell(cell, "教员历不可用"));
+ continue;
+ }
+ // 硬冲突:双占(本课程的已有课次视为可覆盖)
+ boolean ownOccupied = false;
+ boolean busy = false;
+ String busyReason = null;
+ for (SSKCB occupant : occupants) {
+ boolean own = occupant.getSskcbh().equals(course.getBh());
+ if (own) {
+ ownOccupied = true;
+ continue;
+ }
+ if (teamsOfLesson.getOrDefault(occupant.getBh(), List.of()).stream()
+ .anyMatch(t -> Objects.equals(t, ctx.semester.getXydbh()))) {
+ busy = true; busyReason = "班次该节已有其它课程";
+ break;
+ }
+ if (roomsOfLesson.getOrDefault(occupant.getBh(), List.of()).contains(jsbh)) {
+ busy = true; busyReason = "教室该节已被占用";
+ break;
+ }
+ if (jybh != null && teachersOfLesson.getOrDefault(occupant.getBh(), List.of()).contains(jybh)) {
+ busy = true; busyReason = "教员该节已有课";
+ break;
+ }
+ }
+ if (busy) {
+ ignored.add(ignoredCell(cell, busyReason));
+ continue;
+ }
+ // 软提示
+ LocalDate date = LocalDate.parse(cell.getRq());
+ int weekNo = weekNoOf(ctx, date);
+ if (allocatedWeeks != null && !allocatedWeeks.isEmpty() && !allocatedWeeks.contains(weekNo)) {
+ warnings.add(warnCell(cell, "配当周次不符(第 " + weekNo + " 周不在该课配当范围)"));
+ }
+ if (!ownOccupied) {
+ SSKCB lesson = new SSKCB();
+ lesson.setBh(UuidUtil.getUUID());
+ lesson.setSskcbh(course.getBh());
+ lesson.setJhsjap(0);
+ lesson.setJxnr(request.getJxnr());
+ lesson.setJxyd(request.getJxyd());
+ lesson.setJxff(request.getJxff() == null || request.getJxff().isEmpty() ? "理论讲授" : request.getJxff());
+ lesson.setRq(date.atStartOfDay());
+ lesson.setJc(cell.getJc());
+ lesson.setNd(ctx.ndCode);
+ lesson.setSczt(0);
+ lesson.setJcdj(request.getJcdj() == null ? 0 : request.getJcdj());
+ lesson.setJxbzbz(request.getJxbzbz());
+ lesson.setYcxx(request.getYcxx());
+ lesson.setBzms(request.getBzms());
+ lesson.setCjsj(now);
+ lesson.setBdsj(now);
+ sskcbMapper.insert(lesson);
+ // 学员队 / 教室 / 主讲教员
+ for (SSKCXYD link : ctx.links) {
+ if (!course.getBh().equals(link.getSskcbh())) continue;
+ SSKCBXYD lxyd = new SSKCBXYD();
+ lxyd.setBh(UuidUtil.getUUID());
+ lxyd.setSskcbbh(lesson.getBh());
+ lxyd.setXydbh(link.getXydbh());
+ lxyd.setRs(link.getRs());
+ lxyd.setJc(link.getJc());
+ lxyd.setNd(ctx.ndCode);
+ lxyd.setRq(lesson.getRq());
+ lxyd.setJc2(cell.getJc());
+ sskcbxydMapper.insert(lxyd);
+ }
+ SSKCBJS ljs = new SSKCBJS();
+ ljs.setBh(UuidUtil.getUUID());
+ ljs.setSskcbbh(lesson.getBh());
+ ljs.setJsbh(jsbh);
+ ljs.setNd(ctx.ndCode);
+ ljs.setRq(lesson.getRq());
+ ljs.setJc(cell.getJc());
+ sskcbjsMapper.insert(ljs);
+ if (jybh != null) {
+ SSKCBFZJY lfz = new SSKCBFZJY();
+ lfz.setBh(UuidUtil.getUUID());
+ lfz.setSskcbbh(lesson.getBh());
+ lfz.setFzjybh(jybh);
+ lfz.setZjy(1);
+ lfz.setNd(ctx.ndCode);
+ lfz.setRq(lesson.getRq());
+ lfz.setJc(cell.getJc());
+ sskcbfzjyMapper.insert(lfz);
+ }
+ arranged++;
+ logOperation(course, "安排", "安排 " + cell.getRq() + " 第" + cell.getJc() + "节"
+ + (ownOccupied ? "(覆盖原课次)" : ""),
+ request);
+ } else {
+ // 覆盖本课程已有课次的内容
+ for (SSKCB occupant : occupants) {
+ if (!occupant.getSskcbh().equals(course.getBh())) continue;
+ occupant.setJxnr(request.getJxnr());
+ occupant.setJxyd(request.getJxyd());
+ occupant.setJxff(request.getJxff());
+ occupant.setJcdj(request.getJcdj() == null ? 0 : request.getJcdj());
+ occupant.setJxbzbz(request.getJxbzbz());
+ occupant.setBdsj(now);
+ sskcbMapper.updateById(occupant);
+ arranged++;
+ }
+ }
+ }
+ Map result = new HashMap<>();
+ result.put("arranged", arranged);
+ result.put("ignored", ignored);
+ result.put("warnings", warnings);
+ return result;
+ }
+
+ // ==================== 删除 ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int deleteCells(SchedulingCellOpRequest request) {
+ if (request == null || request.getSskcbh() == null || request.getCells() == null || request.getCells().isEmpty()) {
+ throw new ServiceException("请指定课程与要删除的节次", BAD_REQUEST);
+ }
+ SSKC course = requireCourse(request.getSskcbh());
+ Ctx ctx = loadCtxForCourse(course);
+ if (!canEdit(course, ctx)) {
+ throw new ServiceException("没有该课程的排课权限", FORBIDDEN);
+ }
+ Set keys = request.getCells().stream()
+ .filter(c -> c.getRq() != null && c.getJc() != null)
+ .map(c -> c.getRq() + "#" + c.getJc())
+ .collect(Collectors.toSet());
+ int count = 0;
+ for (SSKCB lesson : sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getSskcbh, course.getBh()))) {
+ if (lesson.getRq() == null || lesson.getJc() == null) continue;
+ String key = lesson.getRq().toLocalDate().toString() + "#" + lesson.getJc();
+ if (!keys.contains(key)) continue;
+ // 已提交实施计划的课次不在排课窗直改
+ if (lesson.getJhsjap() != null && lesson.getJhsjap() == 1) {
+ throw new ServiceException(lesson.getRq().toLocalDate() + " 第" + lesson.getJc()
+ + "节已提交实施计划,请走调课申请", BAD_REQUEST);
+ }
+ deleteLessonChildren(lesson.getBh());
+ sskcbMapper.deleteById(lesson.getBh());
+ logOperation(course, "删除", "删除课次 " + key, null);
+ count++;
+ }
+ return count;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int clearCourse(String sskcbh) {
+ SSKC course = requireCourse(sskcbh);
+ Ctx ctx = loadCtxForCourse(course);
+ if (!canEdit(course, ctx)) {
+ throw new ServiceException("没有该课程的排课权限", FORBIDDEN);
+ }
+ List lessons = sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getSskcbh, course.getBh()));
+ for (SSKCB lesson : lessons) {
+ if (lesson.getJhsjap() != null && lesson.getJhsjap() == 1) {
+ throw new ServiceException("课程存在已提交实施计划的课次,请先通过调课流程处理", BAD_REQUEST);
+ }
+ }
+ for (SSKCB lesson : lessons) {
+ deleteLessonChildren(lesson.getBh());
+ sskcbMapper.deleteById(lesson.getBh());
+ }
+ logOperation(course, "删除", "清空课程全部节次(" + lessons.size() + " 节)", null);
+ return lessons.size();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int deleteCourse(String sskcbh) {
+ if (roleHelper.isTeacher()) {
+ throw new ServiceException("教员不能彻底删除运行课程", FORBIDDEN);
+ }
+ SSKC course = requireCourse(sskcbh);
+ Ctx ctx = loadCtxForCourse(course);
+ if (!canEdit(course, ctx)) {
+ throw new ServiceException("没有该课程的排课权限", FORBIDDEN);
+ }
+ for (SSKCB lesson : sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getSskcbh, course.getBh()))) {
+ deleteLessonChildren(lesson.getBh());
+ sskcbMapper.deleteById(lesson.getBh());
+ }
+ sskcxydMapper.delete(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getSskcbh, course.getBh()));
+ sskcMapper.deleteById(course.getBh());
+ logOperation(course, "删除", "彻底删除运行课程", null);
+ return 1;
+ }
+
+ @Override
+ public List> logs(String sskcbh, String czlx) {
+ LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
+ qw.eq(KCJXYX_CZRZ::getTybh, sskcbh);
+ if (czlx != null && !czlx.isEmpty()) {
+ qw.eq(KCJXYX_CZRZ::getCzlx, czlx);
+ }
+ qw.orderByDesc(KCJXYX_CZRZ::getCzsj);
+ return czrzMapper.selectList(qw).stream().limit(200)
+ .map(r -> {
+ Map m = new HashMap();
+ m.put("czlx", r.getCzlx());
+ m.put("cznr", r.getCznr());
+ m.put("czrbh", r.getCzrbh());
+ m.put("czsj", r.getCzsj());
+ return m;
+ }).collect(Collectors.toList());
+ }
+
+ // ==================== 公共 ====================
+
+ /** 上下文:班次学期 + 学员队 + 6 位代号 + 本班次已发布课程与课次 */
+ private class Ctx {
+ XYDNDXQJBXXB semester;
+ XYDB team;
+ Integer ndCode;
+ List links;
+ List lessons;
+ }
+
+ private Ctx loadCtx(String xydxqbh) {
+ if (xydxqbh == null || xydxqbh.isEmpty()) {
+ throw new ServiceException("请指定班次学期", BAD_REQUEST);
+ }
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
+ if (semester == null) {
+ throw new ServiceException("班次学期不存在:" + xydxqbh, NOT_FOUND);
+ }
+ Ctx ctx = new Ctx();
+ ctx.semester = semester;
+ ctx.team = xydbMapper.selectById(semester.getXydbh());
+ ctx.ndCode = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+ ctx.links = sskcxydMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getXydbh, semester.getXydbh())
+ .eq(SSKCXYD::getNd, ctx.ndCode));
+ List ids = ctx.links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
+ ctx.lessons = ids.isEmpty() ? new ArrayList<>() : sskcbMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCB::getSskcbh, ids)
+ .eq(SSKCB::getSczt, 0));
+ return ctx;
+ }
+
+ private Ctx loadCtxForCourse(SSKC course) {
+ // 通过任一关联学员队反查班次学期
+ List links = sskcxydMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getSskcbh, course.getBh()));
+ if (links.isEmpty()) {
+ throw new ServiceException("该运行课程没有关联学员队", NOT_FOUND);
+ }
+ Ctx ctx = loadCtxByTeam(links.get(0).getXydbh(), links.get(0).getNd());
+ if (ctx.semester == null) {
+ throw new ServiceException("未找到该课程所属班次学期", NOT_FOUND);
+ }
+ // 冲突判定需要全班次本学期全部课次(含其它课程),仅课程自身课次会漏判双占
+ List teamLinks = sskcxydMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCXYD::getXydbh, ctx.semester.getXydbh())
+ .eq(SSKCXYD::getNd, ctx.ndCode));
+ ctx.links = teamLinks;
+ List ids = teamLinks.stream().map(SSKCXYD::getSskcbh).distinct().toList();
+ ctx.lessons = ids.isEmpty() ? new ArrayList<>() : sskcbMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCB::getSskcbh, ids)
+ .eq(SSKCB::getSczt, 0));
+ return ctx;
+ }
+
+ private Ctx loadCtxByTeam(String xydbh, Integer ndCode) {
+ List semesters = classSemesterMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDNDXQJBXXB::getXydbh, xydbh)
+ .eq(XYDNDXQJBXXB::getDelFlag, 0));
+ XYDNDXQJBXXB match = semesters.stream()
+ .filter(s -> ndCode.equals(SemesterCodeUtil.resolve(s.getNd(), s.getXqdc())))
+ .findFirst().orElse(null);
+ Ctx ctx = new Ctx();
+ ctx.semester = match;
+ ctx.team = xydbMapper.selectById(xydbh);
+ ctx.ndCode = ndCode;
+ return ctx;
+ }
+
+ private SSKC requireCourse(String sskcbh) {
+ if (sskcbh == null || sskcbh.isEmpty()) {
+ throw new ServiceException("请指定运行课程", BAD_REQUEST);
+ }
+ SSKC course = sskcMapper.selectById(sskcbh);
+ if (course == null) {
+ throw new ServiceException("运行课程不存在:" + sskcbh, NOT_FOUND);
+ }
+ return course;
+ }
+
+ /**
+ * 排课权限:管理员/机关全部;教研室主任=责任教员属本室;教员=本人责任课程且班次开放教员排课。
+ */
+ private boolean canEdit(SSKC course, Ctx ctx) {
+ if (roleHelper.isAdmin() || roleHelper.isDepartmentPersonnel()) {
+ return true;
+ }
+ if (roleHelper.isResearchOffice()) {
+ String officeId = roleHelper.findResearchOfficeId();
+ if (officeId == null) return false;
+ return course.getJybh() != null && jyInOffice(course.getJybh(), officeId);
+ }
+ if (roleHelper.isTeacher()) {
+ if (ctx.semester != null && ctx.semester.getKfjypk() != null && ctx.semester.getKfjypk() == 0) {
+ return false; // 开放教员排课关闭,教员只读
+ }
+ String teacherId = roleHelper.findTeacherId();
+ return teacherId != null && teacherId.equals(course.getJybh());
+ }
+ return false;
+ }
+
+ /** 教员是否属指定教研室(教员表.教研室代号) */
+ private boolean jyInOffice(String jybh, String officeId) {
+ JYB teacher = jybMapper.selectById(jybh);
+ return teacher != null && officeId.equals(teacher.getJysdh());
+ }
+
+ private void deleteLessonChildren(String sskcbbh) {
+ sskcbxydMapper.delete(new LambdaQueryWrapper().eq(SSKCBXYD::getSskcbbh, sskcbbh));
+ sskcbjsMapper.delete(new LambdaQueryWrapper().eq(SSKCBJS::getSskcbbh, sskcbbh));
+ sskcbfzjyMapper.delete(new LambdaQueryWrapper().eq(SSKCBFZJY::getSskcbbh, sskcbbh));
+ }
+
+ /** 校历/班历不可排格键集合 */
+ private Set calendarHardKeys(Ctx ctx, boolean school) {
+ Set keys = new HashSet<>();
+ if (school) {
+ for (XQXLB row : semesterCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(XQXLB::getNd, ctx.ndCode).eq(XQXLB::getKpk, false))) {
+ if (row.getJqsj() == null) continue;
+ for (Integer jc : parsePeriods(row.getCourseClass())) keys.add(row.getJqsj() + "#" + jc);
+ }
+ } else {
+ for (JQB row : jqbMapper.selectList(new LambdaQueryWrapper()
+ .eq(JQB::getXydxqbh, ctx.semester.getBh())
+ .eq(JQB::getKpk, 0))) {
+ if (row.getJqsj() == null) continue;
+ String date = intDate(row.getJqsj());
+ for (Integer jc : parsePeriods(row.getCourseClass())) keys.add(date + "#" + jc);
+ }
+ }
+ return keys;
+ }
+
+ /** 该课程配当分布覆盖的周号集合(无配当返回 null = 不提示) */
+ private Set allocatedWeeks(Ctx ctx, SSKC course) {
+ try {
+ var view = teachingAllocationService.view(ctx.semester.getBh());
+ for (var task : view.getTasks()) {
+ if (Objects.equals(task.getKbh(), course.getKmbh()) && task.getDistribution() != null
+ && !task.getDistribution().isEmpty()) {
+ return task.getDistribution().stream().map(d -> d.getWeek()).collect(Collectors.toSet());
+ }
+ }
+ } catch (Exception e) {
+ log.debug("[排课窗] 读取配当失败,忽略软提示: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ private int weekNoOf(Ctx ctx, LocalDate date) {
+ LocalDate start = ctx.semester.getKxrq().with(DayOfWeek.MONDAY);
+ int week = 1;
+ while (!start.isAfter(date)) {
+ if (!date.isBefore(start) && !date.isAfter(start.plusDays(6))) return week;
+ start = start.plusDays(7);
+ week++;
+ }
+ return week;
+ }
+
+ private Map ignoredCell(SchedulingArrangeRequest.Cell cell, String reason) {
+ Map m = new HashMap<>();
+ m.put("rq", cell.getRq());
+ m.put("jc", cell.getJc());
+ m.put("reason", reason);
+ return m;
+ }
+
+ private Map warnCell(SchedulingArrangeRequest.Cell cell, String reason) {
+ Map m = new HashMap<>();
+ m.put("rq", cell.getRq());
+ m.put("jc", cell.getJc());
+ m.put("reason", reason);
+ return m;
+ }
+
+ private void logOperation(SSKC course, String czlx, String cznr, SchedulingArrangeRequest ignored) {
+ try {
+ KCJXYX_CZRZ row = new KCJXYX_CZRZ();
+ row.setBh(UuidUtil.getUUID());
+ row.setTybh(course.getBh());
+ row.setCzlx(czlx);
+ row.setCznr(cznr);
+ try {
+ row.setCzrbh(SecurityUtils.getUsername());
+ } catch (Exception ignore) {
+ row.setCzrbh("harness");
+ }
+ row.setCzsj(LocalDateTime.now());
+ czrzMapper.insert(row);
+ } catch (Exception e) {
+ log.warn("[排课窗] 写排课日志失败: {}", e.getMessage());
+ }
+ }
+
+ private String resolveCourseName(String kbh) {
+ if (kbh == null) return "";
+ var kb = kbMapper.selectById(kbh);
+ return kb != null && kb.getKmc() != null ? kb.getKmc() : kbh;
+ }
+
+ private static String intDate(Integer d) {
+ String s = String.valueOf(d);
+ return s.length() == 8 ? s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8) : s;
+ }
+
+ private List parsePeriods(String label) {
+ List out = new ArrayList<>();
+ if (label == null) return out;
+ for (String token : label.split("[,,]")) {
+ String t = token.trim();
+ if (t.contains("-")) {
+ String[] parts = t.split("-");
+ try {
+ int a = Integer.parseInt(parts[0].trim());
+ int b = Integer.parseInt(parts[1].trim());
+ for (int i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i);
+ } catch (Exception ignored) { }
+ } else {
+ try {
+ out.add(Integer.parseInt(t));
+ } catch (Exception ignored) { }
+ }
+ }
+ return out;
+ }
+
+ private static String safe(String s) {
+ return s == null || s.isEmpty() ? "-" : s;
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java
index fa456581..4227b6f5 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/StudentTeamTaskServiceImpl.java
@@ -22,6 +22,7 @@ import com.roomroot.jwgl.mapper.XYDBMapper;
import com.roomroot.jwgl.mapper.ZYJXJHBMapper;
import com.roomroot.jwgl.service.StudentTeamTaskService;
import com.roomroot.jwgl.service.TeachingTaskWriteGuard;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
import com.roomroot.jwgl.utils.TeachingTaskStatus;
import com.roomroot.jwgl.utils.UuidUtil;
import com.roomroot.jwgl.vo.PlanCourseVO;
@@ -75,12 +76,16 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
private SSKCXYDMapper sskcxydMapper;
@Override
+ @Transactional(rollbackFor = Exception.class)
public void add(XYDRWB xydrwb) {
if (xydrwb != null) {
teachingTaskWriteGuard.assertCourseTaskWritable(xydrwb.getXydxqbh());
}
xydrwb.setBh(UuidUtil.getUUID());
xydrwb.setDelFlag(0);
+ // 新增表单允许留空「计划课次序号 / 序号标识 / 编组」等列,这里统一补齐非空约束,
+ // 否则最小表单直接插入会违反库中的非空约束而报错。
+ applyRequiredDefaults(xydrwb, nextSequence(xydrwb.getXydxqbh()));
xydrwbMapper.insert(xydrwb);
}
@@ -186,50 +191,41 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
@Transactional(rollbackFor = Exception.class)
@Override
- public int autoGenerateRequiredCourses(String xydbh, Integer xqdc) {
- // 1. 根据学员队编号查询学员队表,获取专业代号
- XYDB xydb = xydbMapper.selectById(xydbh);
- if (xydb == null || xydb.getZydh() == null) {
+ public int autoGenerateRequiredCourses(String xydxqbh) {
+ // 1. 按班次学期主键定位目标学期:它在库里唯一,不会跨学年串写
+ if (xydxqbh == null || xydxqbh.isEmpty()) {
return 0;
}
- String zydh = xydb.getZydh();
-
- // 2. 根据专业代号和学期第次查询专业教学计划表(停用=0)
- LambdaQueryWrapper planWrapper = new LambdaQueryWrapper<>();
- planWrapper.eq(ZYJXJHB::getZydh, zydh)
- .eq(ZYJXJHB::getXqdc, xqdc)
- .eq(ZYJXJHB::getTy, 0);
- List planList = zyjxjhbMapper.selectList(planWrapper);
- if (CollectionUtils.isEmpty(planList)) {
- return 0;
- }
-
- // 3. 根据学员队编号和学期第次查询学员队期表,获取年度和学员队学期编号
- LambdaQueryWrapper semesterWrapper = new LambdaQueryWrapper<>();
- semesterWrapper.eq(XYDNDXQJBXXB::getXydbh, xydbh)
- .eq(XYDNDXQJBXXB::getXqdc, xqdc)
- .eq(XYDNDXQJBXXB::getDelFlag, 0)
- .orderByDesc(XYDNDXQJBXXB::getNd)
- .last("LIMIT 1");
- XYDNDXQJBXXB semester = classSemesterMapper.selectOne(semesterWrapper);
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
if (semester == null) {
return 0;
}
- Integer nd = semester.getNd();
- String xydxqbh = semester.getBh();
+ String xydbh = semester.getXydbh();
+ Integer xqdc = semester.getXqdc();
+ if (xydbh == null || xqdc == null) {
+ return 0;
+ }
+
+ // 任务表/实施/课程表的「年度」口径是 6 位学期代号(见 SemesterCodeUtil 类注释),
+ // 不能直接落班次学期的 4 位年份,否则与实施链路、选修课程映射的年度连接全部断裂
+ Integer nd = SemesterCodeUtil.resolve(semester.getNd(), xqdc);
teachingTaskWriteGuard.assertCourseTaskWritable(xydxqbh);
+ // 2. 匹配人培:匹配不到时给出明确原因,而不是静默返回 0 被当成「本学期无必修课」
+ PlanMatch planMatch = matchPlanCourses(xydbh, xqdc);
+ if (planMatch.problem() != null) {
+ throw new ServiceException("自动生成必修课程失败:" + planMatch.problem(), BAD_REQUEST);
+ }
+ List planList = planMatch.matched();
+ if (planList.isEmpty()) {
+ return 0;
+ }
+
// 4. 查询当前班次已存在的课程编号集合
- LambdaQueryWrapper existWrapper = new LambdaQueryWrapper<>();
- existWrapper.eq(XYDRWB::getXydxqbh, xydxqbh)
- .eq(XYDRWB::getDelFlag, 0);
- List existList = xydrwbMapper.selectList(existWrapper);
- List existKbhs = existList.stream()
- .map(XYDRWB::getKbh)
- .filter(kbh -> kbh != null)
- .collect(Collectors.toList());
+ Set existKbhs = currentCourseCodes(xydxqbh);
// 5. 只添加缺少的课程
+ int seq = nextSequence(xydxqbh);
int count = 0;
for (ZYJXJHB plan : planList) {
if (existKbhs.contains(plan.getKbh())) {
@@ -242,6 +238,8 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
newRecord.setXydbh(xydbh);
newRecord.setKbh(plan.getKbh());
newRecord.setXqdc(xqdc);
+ // 阶段 4:新建任务默认写入班次专用教室作为场地
+ newRecord.setJsbh(semester.getZyjsbh());
newRecord.setKlx(plan.getKlx());
newRecord.setXs(plan.getXs());
newRecord.setJc(plan.getJc());
@@ -255,14 +253,16 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
newRecord.setKsksbxs(plan.getKsksbxs());
newRecord.setJysdh(plan.getJysdh());
newRecord.setXydxqbh(xydxqbh);
- newRecord.setJhkcxh(count+1);
- newRecord.setXhbs(count+1);
- newRecord.setBz2(0);
- newRecord.setPdtbykxxbz(1);
- newRecord.setPdqyzdyxs(0);
+ newRecord.setJhkcxh(seq);
+ newRecord.setXhbs(seq);
newRecord.setDelFlag(0);
+ // 补齐全表的非空约束列(编组、配当同班异课选修编组、配档启用自定义学时等),
+ // 一次到位,避免遗漏导致插入时违反非空约束。
+ applyRequiredDefaults(newRecord, seq);
xydrwbMapper.insert(newRecord);
+ existKbhs.add(plan.getKbh());
+ seq++;
count++;
}
return count;
@@ -282,6 +282,7 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
source.setXydbh(newXydbh);
source.setDelFlag(0);
teachingTaskWriteGuard.assertCourseTaskWritable(source.getXydxqbh());
+ applyRequiredDefaults(source, nextSequence(source.getXydxqbh()));
xydrwbMapper.insert(source);
newBhList.add(newBh);
}
@@ -294,17 +295,80 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
if (CollectionUtils.isEmpty(xydxqbhList)) {
return 0;
}
- int count = 0;
+ // 先统一预检,任何一个班次匹配不到人培课程都直接报错,
+ // 避免部分写入后因异常被整体回滚、却又没有留下任何提示。
+ List problems = new ArrayList<>();
for (String xydxqbh : xydxqbhList) {
XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
if (semester == null || semester.getXydbh() == null || semester.getXqdc() == null) {
+ problems.add("班次学期 " + xydxqbh + " 不存在或缺少学员队编号/学期第次");
continue;
}
- count += autoGenerateRequiredCourses(semester.getXydbh(), semester.getXqdc());
+ PlanMatch match = matchPlanCourses(semester.getXydbh(), semester.getXqdc());
+ if (match.problem() != null) {
+ problems.add(semesterLabel(semester) + ":" + match.problem());
+ }
+ }
+ if (!problems.isEmpty()) {
+ throw new ServiceException("自动生成必修课程未执行,以下班次无法匹配到人培课程:"
+ + String.join(";", problems), BAD_REQUEST);
+ }
+ int count = 0;
+ for (String xydxqbh : xydxqbhList) {
+ // 直接按班次学期编号生成,避免再按 (学员队, 学期第次) 反查时落到别的学年
+ count += autoGenerateRequiredCourses(xydxqbh);
}
return count;
}
+ /** 人培匹配结果:matched 为匹配到的课程;problem 非空表示无法生成的原因。 */
+ private record PlanMatch(List matched, String problem) {
+ static PlanMatch ok(List list) { return new PlanMatch(list, null); }
+ static PlanMatch fail(String why) { return new PlanMatch(List.of(), why); }
+ }
+
+ /** 班次学期在报错信息里的可读标签(学员队名称 + 年度/学期第次)。 */
+ private String semesterLabel(XYDNDXQJBXXB semester) {
+ XYDB clazz = xydbMapper.selectById(semester.getXydbh());
+ String name = clazz != null && clazz.getXydmc() != null ? clazz.getXydmc() : semester.getXydbh();
+ return name + " " + semester.getNd() + " 年度学期第次 " + semester.getXqdc();
+ }
+
+ /**
+ * 按专业代号 + 学期第次匹配人培(专业教学计划表,停用=0)。
+ *
+ * 口径说明(经业务确认保留等值匹配):人培的「学期第次」是专业总第几学期
+ * (1..学期数,学期数 = 学年制 × 2,见专业表);而班次学期的「学期第次」是
+ * 学年内学期类别(1 春季 / 2 夏季 / 3 秋季,见 ClassSemesterServiceImpl.assertXqdc)。
+ * 两者口径不同,因此匹配不到时必须把原因讲清楚,不能静默返回 0。
+ */
+ private PlanMatch matchPlanCourses(String xydbh, Integer xqdc) {
+ XYDB clazz = xydbMapper.selectById(xydbh);
+ String zydh = clazz == null ? null : clazz.getZydh();
+ if (zydh == null || zydh.isEmpty()) {
+ return PlanMatch.fail("学员队 " + xydbh + " 未关联专业");
+ }
+ List allEnabled = zyjxjhbMapper.selectList(new LambdaQueryWrapper()
+ .eq(ZYJXJHB::getZydh, zydh)
+ .eq(ZYJXJHB::getTy, 0));
+ if (allEnabled.isEmpty()) {
+ return PlanMatch.fail("专业 " + zydh + " 尚无启用状态的人才培养方案数据");
+ }
+ List matched = new ArrayList<>();
+ for (ZYJXJHB plan : allEnabled) {
+ if (xqdc != null && xqdc.equals(plan.getXqdc())) {
+ matched.add(plan);
+ }
+ }
+ if (matched.isEmpty()) {
+ return PlanMatch.fail(String.format(
+ "专业 %s 的人培中没有「学期第次 = %d」的课程(人培的学期第次是专业总第几学期 1..学期数,"
+ + "而班次学期的学期第次是学年内学期类别 1 春季 / 2 夏季 / 3 秋季,两者口径不同,"
+ + "请核对人培数据或改用「计划内必修添加」手动选取)", zydh, xqdc));
+ }
+ return PlanMatch.ok(matched);
+ }
+
@Override
public List listPlanCourses(String xydxqbh, boolean otherOnly) {
XYDNDXQJBXXB semester = requireSemester(xydxqbh);
@@ -350,10 +414,12 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
}
XYDRWB row = new XYDRWB();
row.setBh(UuidUtil.getUUID());
- row.setNd(semester.getNd());
+ row.setNd(SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc()));
row.setXydbh(semester.getXydbh());
row.setXydxqbh(xydxqbh);
row.setXqdc(semester.getXqdc());
+ // 阶段 4:新建任务默认写入班次专用教室作为场地
+ row.setJsbh(semester.getZyjsbh());
row.setKbh(course.getKbh());
row.setJc(course.getJc());
row.setXs(course.getXs());
@@ -366,6 +432,7 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
row.setKsks(course.getKsks());
row.setJysdh(course.getJysdh());
row.setDelFlag(0);
+ applyRequiredDefaults(row, nextSequence(xydxqbh));
xydrwbMapper.insert(row);
}
@@ -380,7 +447,9 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
if (currentCourseCodes(xydxqbh).contains(plan.getKbh())) {
throw new ServiceException("当前学期已有该课程", CONFLICT);
}
- xydrwbMapper.insert(fromPlan(semester, plan));
+ XYDRWB row = fromPlan(semester, plan);
+ applyRequiredDefaults(row, nextSequence(xydxqbh));
+ xydrwbMapper.insert(row);
}
@Transactional(rollbackFor = Exception.class)
@@ -443,6 +512,7 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
return 0;
}
int count = 0;
+ int seq = nextSequence(targetXydxqbh);
for (String bh : bhList) {
XYDRWB source = xydrwbMapper.selectById(bh);
if (source == null) {
@@ -451,10 +521,15 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
source.setBh(UuidUtil.getUUID());
source.setXydbh(semester.getXydbh());
source.setXydxqbh(semester.getBh());
- source.setNd(semester.getNd());
+ source.setNd(SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc()));
source.setXqdc(semester.getXqdc());
source.setDelFlag(0);
+ // 粘贴到新班次后应排在末尾,不复用源班的课次序号,避免与目标班次已有行重号
+ source.setJhkcxh(seq);
+ source.setXhbs(seq);
+ applyRequiredDefaults(source, seq);
xydrwbMapper.insert(source);
+ seq++;
count++;
}
return count;
@@ -463,10 +538,12 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
private XYDRWB fromPlan(XYDNDXQJBXXB semester, ZYJXJHB plan) {
XYDRWB row = new XYDRWB();
row.setBh(UuidUtil.getUUID());
- row.setNd(semester.getNd());
+ row.setNd(SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc()));
row.setXydbh(semester.getXydbh());
row.setXydxqbh(semester.getBh());
row.setXqdc(semester.getXqdc());
+ // 阶段 4:新建任务默认写入班次专用教室作为场地
+ row.setJsbh(semester.getZyjsbh());
row.setKbh(plan.getKbh());
row.setKlx(plan.getKlx());
row.setXs(plan.getXs());
@@ -540,6 +617,65 @@ public class StudentTeamTaskServiceImpl implements StudentTeamTaskService {
.collect(Collectors.toCollection(HashSet::new));
}
+ /**
+ * 补齐学员队任务表里「非空且无库默认值」的列。
+ *
+ * 这些列漏填时插入会直接违反非空约束:计划课次序号、序号标识、编组、
+ * 配当同班异课选修编组、配档启用自定义学时、学时、课类型、教研室代号。
+ * 其余非空列在库里有默认值,交给库处理。
+ */
+ private void applyRequiredDefaults(XYDRWB row, Integer sequence) {
+ if (row == null) {
+ return;
+ }
+ if (row.getJhkcxh() == null) {
+ row.setJhkcxh(sequence == null ? 1 : sequence);
+ }
+ if (row.getXhbs() == null) {
+ row.setXhbs(row.getJhkcxh());
+ }
+ if (row.getBz2() == null) {
+ row.setBz2(0);
+ }
+ if (row.getPdtbykxxbz() == null) {
+ row.setPdtbykxxbz(1);
+ }
+ if (row.getPdqyzdyxs() == null) {
+ row.setPdqyzdyxs(0);
+ }
+ if (row.getXs() == null) {
+ row.setXs(0);
+ }
+ if (row.getKlx() == null) {
+ row.setKlx("");
+ }
+ if (row.getJysdh() == null) {
+ row.setJysdh("");
+ }
+ }
+
+ /**
+ * 取该班次学期下一个可用的课次序号。计划课次序号与序号标识都要非空,故取两者较大值 +1。
+ */
+ private int nextSequence(String xydxqbh) {
+ if (xydxqbh == null || xydxqbh.isEmpty()) {
+ return 1;
+ }
+ int max = 0;
+ List rows = xydrwbMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getXydxqbh, xydxqbh)
+ .eq(XYDRWB::getDelFlag, 0));
+ for (XYDRWB r : rows) {
+ if (r.getJhkcxh() != null && r.getJhkcxh() > max) {
+ max = r.getJhkcxh();
+ }
+ if (r.getXhbs() != null && r.getXhbs() > max) {
+ max = r.getXhbs();
+ }
+ }
+ return max + 1;
+ }
+
private XYDNDXQJBXXB requireSemester(String xydxqbh) {
XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
if (semester == null) {
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java
new file mode 100644
index 00000000..a5f80e56
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TaskBookFillServiceImpl.java
@@ -0,0 +1,378 @@
+package com.roomroot.jwgl.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.roomroot.common.exception.ServiceException;
+import com.roomroot.jwgl.dto.taskbook.TaskBookBhListRequest;
+import com.roomroot.jwgl.dto.taskbook.TaskBookFillRequest;
+import com.roomroot.jwgl.dto.taskbook.TaskBookRoomRequest;
+import com.roomroot.jwgl.dto.taskbook.TaskBookTeacherRequest;
+import com.roomroot.jwgl.entity.JYB;
+import com.roomroot.jwgl.entity.JXRW;
+import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
+import com.roomroot.jwgl.entity.XYDRWB;
+import com.roomroot.jwgl.mapper.ClassSemesterMapper;
+import com.roomroot.jwgl.mapper.JYBMapper;
+import com.roomroot.jwgl.mapper.StudentTeamTaskMapper;
+import com.roomroot.jwgl.mapper.TeachingTaskMapper;
+import com.roomroot.jwgl.service.TaskBookFillService;
+import com.roomroot.jwgl.service.TeachingTaskWriteGuard;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
+import com.roomroot.jwgl.utils.TeachingTaskStatus;
+import com.roomroot.jwgl.vo.taskbook.TaskBookRowVO;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
+import static com.roomroot.jwgl.utils.BasicManagementConstants.CONFLICT;
+import static com.roomroot.jwgl.utils.BasicManagementConstants.NOT_FOUND;
+
+/**
+ * 教研室任务书填报实现(阶段 4)。
+ */
+@Service
+public class TaskBookFillServiceImpl implements TaskBookFillService {
+
+ @Resource
+ private StudentTeamTaskMapper taskMapper;
+
+ @Resource
+ private ClassSemesterMapper classSemesterMapper;
+
+ @Resource
+ private TeachingTaskMapper jxrwMapper;
+
+ @Resource
+ private JYBMapper jybMapper;
+
+ @Resource
+ private TeachingTaskWriteGuard guard;
+
+ // ==================== 列表 ====================
+
+ @Override
+ public List list(String jxrwbh) {
+ if (jxrwbh == null || jxrwbh.isEmpty()) {
+ throw new ServiceException("请指定教学任务", BAD_REQUEST);
+ }
+ JXRW jxrw = jxrwMapper.selectById(jxrwbh);
+ if (jxrw == null || Integer.valueOf(1).equals(jxrw.getDelFlag())) {
+ throw new ServiceException("教学任务不存在:" + jxrwbh, NOT_FOUND);
+ }
+ List rows = jxrwMapper.selectTaskBookRows(jxrwbh);
+ boolean frozen = isFrozen(jxrw);
+ rows.forEach(r -> r.setFrozen(frozen));
+ return rows;
+ }
+
+ /** 填报门禁:仅「已发布且未结束」的教学任务可填报,其余只读 */
+ private boolean isFrozen(JXRW jxrw) {
+ return !TeachingTaskStatus.isPublished(jxrw.getZt()) || TeachingTaskStatus.isEnded(jxrw.getZt());
+ }
+
+ // ==================== 合班 / 拆班 ====================
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public Integer merge(TaskBookBhListRequest request) {
+ List rows = loadRows(request);
+ assertAllFillable(rows);
+ assertSameXq(rows);
+ assertMergeCompatible(rows);
+ int groupNo = nextGroupNo();
+ LocalDateTime now = LocalDateTime.now();
+ for (XYDRWB row : rows) {
+ row.setBz2(groupNo);
+ row.setBdsj(now);
+ taskMapper.updateById(row);
+ }
+ return groupNo;
+ }
+
+ /** 合班只允许同一学期(6 位学期代号相同):跨学期合班没有业务意义 */
+ private void assertSameXq(List rows) {
+ Integer first = xqCodeOf(rows.get(0));
+ for (XYDRWB row : rows) {
+ if (!Objects.equals(xqCodeOf(row), first)) {
+ throw new ServiceException("任务 " + row.getBh() + "(" + row.getXydbh() + ")与第一行不在同一学期,不能合班",
+ BAD_REQUEST);
+ }
+ }
+ }
+
+ private Integer xqCodeOf(XYDRWB row) {
+ XYDNDXQJBXXB semester = requireSemester(row.getXydxqbh());
+ return SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+ }
+
+ /** 手动合班校验:科目、学时、课类型、成绩分制必须相同 */
+ private void assertMergeCompatible(List rows) {
+ XYDRWB first = rows.get(0);
+ for (XYDRWB row : rows) {
+ List diff = new ArrayList<>();
+ if (!Objects.equals(row.getKbh(), first.getKbh())) {
+ diff.add("科目(" + row.getKbh() + " ≠ " + first.getKbh() + ")");
+ }
+ if (!Objects.equals(row.getXs(), first.getXs())) {
+ diff.add("学时(" + row.getXs() + " ≠ " + first.getXs() + ")");
+ }
+ if (!Objects.equals(row.getKlx(), first.getKlx())) {
+ diff.add("课类型(" + row.getKlx() + " ≠ " + first.getKlx() + ")");
+ }
+ if (!Objects.equals(norm(row.getCjfz()), norm(first.getCjfz()))) {
+ diff.add("成绩分制(" + row.getCjfz() + " ≠ " + first.getCjfz() + ")");
+ }
+ if (!diff.isEmpty()) {
+ throw new ServiceException("任务 " + row.getBh() + "(" + row.getXydbh() + ")与第一行不合班:" + String.join("、", diff),
+ BAD_REQUEST);
+ }
+ }
+ }
+
+ private static String norm(String s) {
+ return s == null || s.isEmpty() ? "" : s.trim();
+ }
+
+ private int nextGroupNo() {
+ int max = 0;
+ for (Integer bz2 : taskMapper.selectList(new LambdaQueryWrapper()
+ .isNotNull(XYDRWB::getBz2)).stream().map(XYDRWB::getBz2).toList()) {
+ if (bz2 != null && bz2 > max) {
+ max = bz2;
+ }
+ }
+ return max + 1;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public List> mergeByPreset(TaskBookBhListRequest request) {
+ List rows = loadRows(request);
+ assertAllFillable(rows);
+ assertSameXq(rows);
+ // 行按 课程科目(kbh) → 预设编班号(ysbbh,空则各班次学期独立) 分组
+ Map> groups = new LinkedHashMap<>();
+ for (XYDRWB row : rows) {
+ XYDNDXQJBXXB semester = requireSemester(row.getXydxqbh());
+ Integer ysbbh = semester.getYsbbh();
+ String key = row.getKbh() + "|" + (ysbbh == null ? "row:" + row.getXydxqbh() : ysbbh);
+ groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
+ }
+ LocalDateTime now = LocalDateTime.now();
+ int groupNo = nextGroupNo();
+ List> result = new ArrayList<>();
+ for (Map.Entry> entry : groups.entrySet()) {
+ List group = entry.getValue();
+ for (XYDRWB row : group) {
+ row.setBz2(groupNo);
+ row.setBdsj(now);
+ taskMapper.updateById(row);
+ }
+ Map item = new LinkedHashMap<>();
+ item.put("groupNo", groupNo);
+ item.put("kbh", group.get(0).getKbh());
+ item.put("ysbbh", requireSemester(group.get(0).getXydxqbh()).getYsbbh());
+ item.put("count", group.size());
+ result.add(item);
+ groupNo++;
+ }
+ return result;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public int split(TaskBookBhListRequest request) {
+ List rows = loadRows(request);
+ assertAllFillable(rows);
+ // TODO(阶段 5):行已发布到运行课表的须先撤回,再拆班。阶段 5 落地后在此校验。
+ LocalDateTime now = LocalDateTime.now();
+ int count = 0;
+ for (XYDRWB row : rows) {
+ if (row.getBz2() != null && row.getBz2() != 0) {
+ row.setBz2(0);
+ row.setBdsj(now);
+ taskMapper.updateById(row);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ // ==================== 责任教员 / 场地 / 填报 ====================
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public int setTeacher(TaskBookTeacherRequest request) {
+ if (request == null || request.getBh() == null || request.getBh().isEmpty()) {
+ throw new ServiceException("请指定课程任务", BAD_REQUEST);
+ }
+ XYDRWB row = requireRow(request.getBh());
+ assertFillable(row);
+ String mode = request.getMode() == null ? "" : request.getMode();
+ String jybh;
+ if ("plan".equalsIgnoreCase(mode)) {
+ // 应用教研室计划教员:jybh ← 该行 jysjhjybh
+ jybh = row.getJysjhjybh();
+ if (jybh == null || jybh.isEmpty()) {
+ throw new ServiceException("该行没有教研室计划教员,请先在填报字段中指定", BAD_REQUEST);
+ }
+ } else if ("unit".equalsIgnoreCase(mode) || "academy".equalsIgnoreCase(mode)) {
+ jybh = request.getJybh();
+ if (jybh == null || jybh.isEmpty()) {
+ throw new ServiceException("请选择教员", BAD_REQUEST);
+ }
+ JYB teacher = jybMapper.selectById(jybh);
+ if (teacher == null) {
+ throw new ServiceException("教员不存在:" + jybh, NOT_FOUND);
+ }
+ if ("unit".equalsIgnoreCase(mode)) {
+ if (row.getJysdh() == null || row.getJysdh().isEmpty()) {
+ throw new ServiceException("该行没有归属教研室,无法按责任单位指定", BAD_REQUEST);
+ }
+ if (!row.getJysdh().equals(teacher.getJysdh())) {
+ throw new ServiceException("教员 " + teacher.getJyxm() + " 不属于该行教研室,不能按责任单位指定", BAD_REQUEST);
+ }
+ }
+ } else {
+ throw new ServiceException("指定方式只支持 unit / academy / plan", BAD_REQUEST);
+ }
+ return updateGroupField(row, "jybh", jybh);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public int setRoom(TaskBookRoomRequest request) {
+ if (request == null || request.getBh() == null || request.getBh().isEmpty()) {
+ throw new ServiceException("请指定课程任务", BAD_REQUEST);
+ }
+ XYDRWB row = requireRow(request.getBh());
+ assertFillable(row);
+ String jsbh;
+ if (Boolean.TRUE.equals(request.getUseSpecial())) {
+ XYDNDXQJBXXB semester = requireSemester(row.getXydxqbh());
+ jsbh = semester.getZyjsbh();
+ if (jsbh == null || jsbh.isEmpty()) {
+ throw new ServiceException("该班次学期没有专用教室,请改选其它教室", BAD_REQUEST);
+ }
+ } else {
+ jsbh = request.getJsbh();
+ if (jsbh == null || jsbh.isEmpty()) {
+ throw new ServiceException("请选择教室,或选择使用班次专用教室", BAD_REQUEST);
+ }
+ }
+ return updateGroupField(row, "jsbh", jsbh);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public int fill(TaskBookFillRequest request) {
+ if (request == null || request.getBh() == null || request.getBh().isEmpty()) {
+ throw new ServiceException("请指定课程任务", BAD_REQUEST);
+ }
+ XYDRWB row = requireRow(request.getBh());
+ assertFillable(row);
+ LocalDateTime now = LocalDateTime.now();
+ int count;
+ if (request.getJysjhjybh() != null) {
+ row.setJysjhjybh(request.getJysjhjybh());
+ }
+ if (request.getJysjhbz() != null) {
+ row.setJysjhbz(request.getJysjhbz());
+ }
+ row.setBdsj(now);
+ taskMapper.updateById(row);
+ count = 1;
+ // 场地按合班组同步
+ if (request.getJsbh() != null) {
+ count = updateGroupField(row, "jsbh", request.getJsbh());
+ }
+ return count;
+ }
+
+ /**
+ * 同合班组联动更新:行编组非 0 时,同编组全部行(全局组号唯一)同步该字段;
+ * 编组为 0(未合班)只改本行。
+ */
+ private int updateGroupField(XYDRWB row, String field, String value) {
+ List targets;
+ if (row.getBz2() != null && row.getBz2() != 0) {
+ targets = taskMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getBz2, row.getBz2())
+ .eq(XYDRWB::getDelFlag, 0));
+ } else {
+ targets = List.of(row);
+ }
+ LocalDateTime now = LocalDateTime.now();
+ for (XYDRWB target : targets) {
+ if ("jybh".equals(field)) {
+ target.setJybh(value);
+ } else if ("jsbh".equals(field)) {
+ target.setJsbh(value);
+ } else {
+ throw new ServiceException("不支持同步字段:" + field, BAD_REQUEST);
+ }
+ target.setBdsj(now);
+ taskMapper.updateById(target);
+ }
+ return targets.size();
+ }
+
+ // ==================== 公共 ====================
+
+ private List loadRows(TaskBookBhListRequest request) {
+ if (request == null || request.getBhList() == null || request.getBhList().isEmpty()) {
+ throw new ServiceException("请先选择课程任务行", BAD_REQUEST);
+ }
+ List rows = new ArrayList<>();
+ for (String bh : request.getBhList()) {
+ rows.add(requireRow(bh));
+ }
+ return rows;
+ }
+
+ private XYDRWB requireRow(String bh) {
+ XYDRWB row = taskMapper.selectById(bh);
+ if (row == null || Integer.valueOf(1).equals(row.getDelFlag())) {
+ throw new ServiceException("课程任务不存在:" + bh, NOT_FOUND);
+ }
+ return row;
+ }
+
+ private XYDNDXQJBXXB requireSemester(String xydxqbh) {
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
+ if (semester == null) {
+ throw new ServiceException("班次学期不存在:" + xydxqbh, NOT_FOUND);
+ }
+ return semester;
+ }
+
+ /** 填报门禁:仅「已发布且未结束」的教学任务可填报 */
+ private void assertFillable(XYDRWB row) {
+ XYDNDXQJBXXB semester = requireSemester(row.getXydxqbh());
+ JXRW jxrw = semester.getJxrwbh() == null || semester.getJxrwbh().isEmpty()
+ ? null : jxrwMapper.selectById(semester.getJxrwbh());
+ if (jxrw == null || Integer.valueOf(1).equals(jxrw.getDelFlag())) {
+ throw new ServiceException("该行所属教学任务不存在,不能填报", BAD_REQUEST);
+ }
+ if (TeachingTaskStatus.isEnded(jxrw.getZt())) {
+ throw new ServiceException("教学任务已结束,不能再修改任务书或课程任务", CONFLICT);
+ }
+ if (!TeachingTaskStatus.isPublished(jxrw.getZt())) {
+ throw new ServiceException("教学任务尚未发布,不能填报", BAD_REQUEST);
+ }
+ guard.assertTaskWritable(semester.getJxrwbh());
+ }
+
+ private void assertAllFillable(List rows) {
+ for (XYDRWB row : rows) {
+ assertFillable(row);
+ }
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingAllocationServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingAllocationServiceImpl.java
new file mode 100644
index 00000000..980232fa
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingAllocationServiceImpl.java
@@ -0,0 +1,655 @@
+package com.roomroot.jwgl.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.roomroot.common.exception.ServiceException;
+import com.roomroot.jwgl.dto.allocation.AllocationGroupRequest;
+import com.roomroot.jwgl.dto.allocation.AllocationSaveItem;
+import com.roomroot.jwgl.entity.JQB;
+import com.roomroot.jwgl.entity.KB;
+import com.roomroot.jwgl.entity.XQXLB;
+import com.roomroot.jwgl.entity.XYDB;
+import com.roomroot.jwgl.entity.XYDNDXQJBXXB;
+import com.roomroot.jwgl.entity.XYDRWB;
+import com.roomroot.jwgl.mapper.JQBMapper;
+import com.roomroot.jwgl.mapper.KBMapper;
+import com.roomroot.jwgl.mapper.SemesterCalendarMapper;
+import com.roomroot.jwgl.mapper.StudentTeamTaskMapper;
+import com.roomroot.jwgl.mapper.XYDBMapper;
+import com.roomroot.jwgl.service.TeachingAllocationService;
+import com.roomroot.jwgl.service.TeachingTaskWriteGuard;
+import com.roomroot.jwgl.mapper.ClassSemesterMapper;
+import com.roomroot.jwgl.utils.ExcelExportUtil;
+import com.roomroot.jwgl.utils.SemesterCodeUtil;
+import com.roomroot.jwgl.vo.allocation.AllocationTaskVO;
+import com.roomroot.jwgl.vo.allocation.AllocationViewVO;
+import com.roomroot.jwgl.vo.allocation.AllocationWeekVO;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
+import static com.roomroot.jwgl.utils.BasicManagementConstants.NOT_FOUND;
+
+/**
+ * 教学配当 Service 实现。
+ *
+ * 口径约定(详见各方法注释):
+ *
+ * - 「是否连排课」不新增列,用已有 配档按周 pdaz:0=连排(单科独进)、1=按周(非连排);
+ * 周课时 ≥ 20 默认 0,否则默认 1。
+ * - 「每周可排正课」= 该周 可排课=1 且 正课=1 的格数 × 2 学时;
+ * 数据源优先级 班历 > 校历 > 默认(15 格 = 30 学时)。
+ * - 铺学时只是粗约束建议:非连排各自成组按起始周+优选序数独立铺;
+ * 连排课合成一组串行铺且优先级低于非连排;不写实施课程表。
+ * - 「保存整个编组」限定 同科目+同配档编组+同学期代号(6 位),防止跨学期串改。
+ *
+ */
+@Service
+public class TeachingAllocationServiceImpl implements TeachingAllocationService {
+
+ /** 双节次一格按 2 学时计 */
+ private static final int HOURS_PER_CELL = 2;
+ /** 班历/校历都没有时的默认每周可排正课:周一~五 1-2/3-4/5-6 共 15 格 */
+ private static final int DEFAULT_WEEKLY_MAIN_CELLS = 15;
+ /** 周课时达到该值默认按连排(配档按周=0) */
+ private static final int CONTINUOUS_ZKS_THRESHOLD = 20;
+ /** 默认每周学时 */
+ private static final int DEFAULT_WEEKLY_HOURS = DEFAULT_WEEKLY_MAIN_CELLS * HOURS_PER_CELL;
+
+ @Resource
+ private StudentTeamTaskMapper xydrwbMapper;
+
+ @Resource
+ private ClassSemesterMapper classSemesterMapper;
+
+ @Resource
+ private XYDBMapper xydbMapper;
+
+ @Resource
+ private JQBMapper jqbMapper;
+
+ @Resource
+ private SemesterCalendarMapper semesterCalendarMapper;
+
+ @Resource
+ private KBMapper kbMapper;
+
+ @Resource
+ private TeachingTaskWriteGuard teachingTaskWriteGuard;
+
+ // ==================== 视图 ====================
+
+ @Override
+ public AllocationViewVO view(String xydxqbh) {
+ XYDNDXQJBXXB semester = requireSemester(xydxqbh);
+ XYDB clazz = xydbMapper.selectById(semester.getXydbh());
+
+ AllocationViewVO vo = new AllocationViewVO();
+ vo.setXydxqbh(xydxqbh);
+ vo.setXydbh(semester.getXydbh());
+ vo.setXydmc(clazz != null ? clazz.getXydmc() : semester.getXydbh());
+ vo.setNdCode(SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc()));
+ vo.setKxrq(semester.getKxrq());
+ vo.setJsrq(semester.getJsrq());
+ vo.setFrozen(isFrozen(xydxqbh));
+
+ List weeks = buildWeeks(semester);
+ vo.setWeeks(weeks);
+ vo.setTotalWeeks(weeks.size());
+
+ List tasks = listTasks(xydxqbh);
+ Map voMap = new LinkedHashMap<>();
+ for (XYDRWB task : tasks) {
+ voMap.put(task.getBh(), toVO(task));
+ }
+ allocate(weeks, tasks, voMap);
+ vo.setTasks(new ArrayList<>(voMap.values()));
+ return vo;
+ }
+
+ private boolean isFrozen(String xydxqbh) {
+ try {
+ teachingTaskWriteGuard.assertCourseTaskWritable(xydxqbh);
+ return false;
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ private List listTasks(String xydxqbh) {
+ List tasks = xydrwbMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getXydxqbh, xydxqbh)
+ .eq(XYDRWB::getDelFlag, 0));
+ for (XYDRWB task : tasks) {
+ // 连排缺省:周课时 >= 20 默认连排(配档按周=0),否则按周
+ if (task.getPdaz() == null) {
+ task.setPdaz(defaultPdaz(task.getZks()));
+ }
+ if (task.getPdbz() == null) {
+ task.setPdbz(0);
+ }
+ }
+ tasks.sort(Comparator
+ .comparing((XYDRWB t) -> t.getPdxh() == null ? Integer.MAX_VALUE : t.getPdxh())
+ .thenComparing(XYDRWB::getBh));
+ return tasks;
+ }
+
+ private int defaultPdaz(Integer zks) {
+ return zks != null && zks >= CONTINUOUS_ZKS_THRESHOLD ? 0 : 1;
+ }
+
+ private AllocationTaskVO toVO(XYDRWB task) {
+ AllocationTaskVO vo = new AllocationTaskVO();
+ vo.setBh(task.getBh());
+ vo.setKbh(task.getKbh());
+ vo.setKcmc(resolveCourseName(task));
+ vo.setKlx(task.getKlx());
+ vo.setXs(task.getXs());
+ vo.setZks(task.getZks());
+ vo.setPdxh(task.getPdxh());
+ vo.setPdqsz(task.getPdqsz());
+ vo.setPdaz(task.getPdaz());
+ vo.setPdzzksj(task.getPdzzksj());
+ vo.setPdbz(task.getPdbz());
+ vo.setJysdh(task.getJysdh());
+ return vo;
+ }
+
+ private String resolveCourseName(XYDRWB task) {
+ if (task.getJc() != null && !task.getJc().isEmpty()) {
+ return task.getJc();
+ }
+ if (task.getKbh() == null) {
+ return "";
+ }
+ KB kb = kbMapper.selectById(task.getKbh());
+ return kb != null && kb.getKmc() != null ? kb.getKmc() : task.getKbh();
+ }
+
+ // ==================== 周轴与每周可排正课 ====================
+
+ /**
+ * 学期起止按周切分(周一为周界,覆盖 [开学日期, 结束日期]),并统计每周可排正课。
+ */
+ private List buildWeeks(XYDNDXQJBXXB semester) {
+ LocalDate kxrq = semester.getKxrq();
+ LocalDate jsrq = semester.getJsrq();
+ if (kxrq == null || jsrq == null) {
+ throw new ServiceException("班次学期缺少开学/结束日期,无法生成配当周轴", BAD_REQUEST);
+ }
+ boolean hasClassCalendar = jqbMapper.selectCount(new LambdaQueryWrapper()
+ .eq(JQB::getXydxqbh, semester.getBh())) > 0;
+ Integer schoolNd = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+
+ List weeks = new ArrayList<>();
+ LocalDate weekStart = kxrq.with(DayOfWeek.MONDAY);
+ int weekNo = 1;
+ while (!weekStart.isAfter(jsrq)) {
+ LocalDate weekEnd = weekStart.plusDays(6);
+ AllocationWeekVO week = new AllocationWeekVO();
+ week.setWeekNo(weekNo);
+ week.setStartDate(weekStart);
+ week.setEndDate(weekEnd.isAfter(jsrq) ? jsrq : weekEnd);
+
+ int hours;
+ String source;
+ if (hasClassCalendar) {
+ hours = countClassMainHours(semester.getBh(), weekStart, weekEnd) * HOURS_PER_CELL;
+ source = "class";
+ if (hours <= 0) {
+ // 班历未覆盖该周时按默认容量兜底,避免连排计算被卡死
+ hours = DEFAULT_WEEKLY_HOURS;
+ source = "default";
+ }
+ } else {
+ int cells = countSchoolMainCells(schoolNd, weekStart, weekEnd);
+ if (cells > 0) {
+ hours = cells * HOURS_PER_CELL;
+ source = "school";
+ } else {
+ hours = DEFAULT_WEEKLY_HOURS;
+ source = "default";
+ }
+ }
+ week.setAvailableHours(hours);
+ week.setSource(source);
+ weeks.add(week);
+ weekNo++;
+ weekStart = weekStart.plusDays(7);
+ }
+ return weeks;
+ }
+
+ /** 班历:该周 可排课=1 且 正课=1 的格数 */
+ private int countClassMainHours(String xydxqbh, LocalDate start, LocalDate end) {
+ List rows = jqbMapper.selectList(new LambdaQueryWrapper()
+ .eq(JQB::getXydxqbh, xydxqbh)
+ .ge(JQB::getJqsj, toIntDay(start))
+ .le(JQB::getJqsj, toIntDay(end)));
+ return (int) rows.stream()
+ .filter(r -> Integer.valueOf(1).equals(r.getKpk()) && Integer.valueOf(1).equals(r.getZk()))
+ .count();
+ }
+
+ /** 校历:该周 可排课=1 且 正课=1 的格数 */
+ private int countSchoolMainCells(Integer schoolNd, LocalDate start, LocalDate end) {
+ if (schoolNd == null) {
+ return 0;
+ }
+ List rows = semesterCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(XQXLB::getNd, schoolNd)
+ .ge(XQXLB::getJqsj, start.toString())
+ .le(XQXLB::getJqsj, end.toString()));
+ return (int) rows.stream()
+ .filter(r -> Boolean.TRUE.equals(r.getKpk()) && Boolean.TRUE.equals(r.getZk()))
+ .count();
+ }
+
+ private static Integer toIntDay(LocalDate day) {
+ return Integer.valueOf(day.toString().replace("-", ""));
+ }
+
+ // ==================== 铺学时 ====================
+
+ /**
+ * 铺学时(粗约束建议,不落库):
+ * 非连排(pdaz=1)各自成组,从配档起始周起每周铺周课时,按优选序数排序依次铺;
+ * 连排(pdaz=0)合成一组串行铺(单科独进,每周占满剩余容量),优先级低于非连排。
+ */
+ private void allocate(List weeks, List tasks, Map voMap) {
+ int totalWeeks = weeks.size();
+ int[] capacity = new int[Math.max(totalWeeks, 1)];
+ for (int i = 0; i < totalWeeks; i++) {
+ capacity[i] = weeks.get(i).getAvailableHours() == null ? 0 : weeks.get(i).getAvailableHours();
+ }
+ Map continuousUsed = new HashMap<>();
+
+ List nonContinuous = new ArrayList<>();
+ List continuous = new ArrayList<>();
+ for (XYDRWB task : tasks) {
+ (Integer.valueOf(0).equals(task.getPdaz()) ? continuous : nonContinuous).add(task);
+ }
+ nonContinuous.sort(Comparator
+ .comparing((XYDRWB t) -> t.getPdqsz() == null ? 1 : t.getPdqsz())
+ .thenComparing(t -> t.getPdxh() == null ? Integer.MAX_VALUE : t.getPdxh())
+ .thenComparing(XYDRWB::getBh));
+ continuous.sort(Comparator
+ .comparing((XYDRWB t) -> t.getPdxh() == null ? Integer.MAX_VALUE : t.getPdxh())
+ .thenComparing(t -> t.getPdqsz() == null ? 1 : t.getPdqsz())
+ .thenComparing(XYDRWB::getBh));
+
+ for (XYDRWB task : nonContinuous) {
+ fillByWeek(voMap.get(task.getBh()), task, capacity, totalWeeks, null);
+ }
+ int cursor = 1;
+ for (XYDRWB task : continuous) {
+ cursor = fillContinuous(voMap.get(task.getBh()), task, capacity, totalWeeks, continuousUsed, cursor);
+ }
+ }
+
+ /** 非连排:各自独立铺,不与其它任务抢容量(粗约束) */
+ private void fillByWeek(AllocationTaskVO vo, XYDRWB task, int[] capacity, int totalWeeks, Map used) {
+ if (vo == null) {
+ return;
+ }
+ int zks = task.getZks() != null && task.getZks() > 0 ? task.getZks() : 2;
+ int remaining = task.getXs() != null ? task.getXs() : 0;
+ int week = task.getPdqsz() != null && task.getPdqsz() > 0 ? task.getPdqsz() : 1;
+ while (remaining > 0 && week <= totalWeeks) {
+ int hours = Math.min(remaining, zks);
+ vo.getDistribution().add(new AllocationTaskVO.WeekHours(week, hours));
+ if (used != null) {
+ used.merge(week, hours, Integer::sum);
+ }
+ remaining -= hours;
+ week++;
+ }
+ finish(vo, remaining, totalWeeks);
+ }
+
+ /** 连排:串行铺,每周占满剩余容量(单科独进),组内不重叠 */
+ private int fillContinuous(AllocationTaskVO vo, XYDRWB task, int[] capacity, int totalWeeks,
+ Map used, int cursor) {
+ if (vo == null) {
+ return cursor;
+ }
+ int remaining = task.getXs() != null ? task.getXs() : 0;
+ int week = Math.max(cursor, task.getPdqsz() != null && task.getPdqsz() > 0 ? task.getPdqsz() : 1);
+ while (remaining > 0 && week <= totalWeeks) {
+ int left = capacity[week - 1] - used.getOrDefault(week, 0);
+ if (left <= 0) {
+ week++;
+ continue;
+ }
+ int hours = Math.min(remaining, left);
+ vo.getDistribution().add(new AllocationTaskVO.WeekHours(week, hours));
+ used.merge(week, hours, Integer::sum);
+ remaining -= hours;
+ week++;
+ }
+ finish(vo, remaining, totalWeeks);
+ return Math.max(cursor, vo.getEndWeek() == null ? cursor : vo.getEndWeek());
+ }
+
+ private void finish(AllocationTaskVO vo, int overflow, int totalWeeks) {
+ List dist = vo.getDistribution();
+ if (!dist.isEmpty()) {
+ vo.setStartWeek(dist.get(0).getWeek());
+ vo.setEndWeek(dist.get(dist.size() - 1).getWeek());
+ }
+ if (overflow > 0) {
+ vo.setOverflowHours(overflow);
+ }
+ }
+
+ // ==================== 保存 / 排序 / 编组 ====================
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public int save(List items) {
+ if (items == null || items.isEmpty()) {
+ throw new ServiceException("请先选择要保存的配当行", BAD_REQUEST);
+ }
+ int count = 0;
+ for (AllocationSaveItem item : items) {
+ count += saveOne(item);
+ }
+ return count;
+ }
+
+ private int saveOne(AllocationSaveItem item) {
+ if (item == null || item.getBh() == null || item.getBh().isEmpty()) {
+ throw new ServiceException("保存项缺少任务编号", BAD_REQUEST);
+ }
+ validate(item);
+ XYDRWB task = xydrwbMapper.selectById(item.getBh());
+ if (task == null || Integer.valueOf(1).equals(task.getDelFlag())) {
+ throw new ServiceException("课程任务不存在:" + item.getBh(), NOT_FOUND);
+ }
+ teachingTaskWriteGuard.assertCourseTaskWritable(task.getXydxqbh());
+
+ List targets;
+ if (Boolean.TRUE.equals(item.getSaveGroup())) {
+ Integer pdbz = task.getPdbz() == null ? 0 : task.getPdbz();
+ // 同科目 + 同配档编组 + 同学期代号:同开同结;限定学期代号防止跨学期串改
+ targets = xydrwbMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getKbh, task.getKbh())
+ .eq(XYDRWB::getPdbz, pdbz)
+ .eq(XYDRWB::getNd, task.getNd())
+ .eq(XYDRWB::getDelFlag, 0));
+ } else {
+ targets = List.of(task);
+ }
+ LocalDateTime now = LocalDateTime.now();
+ for (XYDRWB t : targets) {
+ if (item.getPdxh() != null) {
+ t.setPdxh(item.getPdxh());
+ }
+ if (item.getPdqsz() != null) {
+ t.setPdqsz(item.getPdqsz());
+ }
+ if (item.getPdaz() != null) {
+ t.setPdaz(item.getPdaz());
+ }
+ if (item.getPdzzksj() != null) {
+ t.setPdzzksj(item.getPdzzksj());
+ }
+ if (item.getPdbz() != null) {
+ t.setPdbz(item.getPdbz());
+ }
+ t.setBdsj(now);
+ xydrwbMapper.updateById(t);
+ }
+ return targets.size();
+ }
+
+ private void validate(AllocationSaveItem item) {
+ if (item.getPdxh() != null && item.getPdxh() < 1) {
+ throw new ServiceException("配档序号必须 ≥ 1", BAD_REQUEST);
+ }
+ if (item.getPdqsz() != null && item.getPdqsz() < 1) {
+ throw new ServiceException("配档起始周必须 ≥ 1", BAD_REQUEST);
+ }
+ if (item.getPdaz() != null && item.getPdaz() != 0 && item.getPdaz() != 1) {
+ throw new ServiceException("配档按周只能为 0(连排)或 1(按周)", BAD_REQUEST);
+ }
+ if (item.getPdzzksj() != null && item.getPdzzksj() != 0 && item.getPdzzksj() != 1) {
+ throw new ServiceException("配档占正课时间只能为 0 或 1", BAD_REQUEST);
+ }
+ if (item.getPdbz() != null && item.getPdbz() < 0) {
+ throw new ServiceException("配档编组必须 ≥ 0", BAD_REQUEST);
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public List move(String xydxqbh, String bh, String direction) {
+ teachingTaskWriteGuard.assertCourseTaskWritable(xydxqbh);
+ boolean up = "up".equalsIgnoreCase(direction);
+ if (!up && !"down".equalsIgnoreCase(direction)) {
+ throw new ServiceException("方向只支持 up / down", BAD_REQUEST);
+ }
+ List tasks = listTasks(xydxqbh);
+ if (tasks.isEmpty()) {
+ throw new ServiceException("该班次学期没有课程任务", NOT_FOUND);
+ }
+ // 归一化优选序数 1..n(与展示顺序一致)
+ LocalDateTime now = LocalDateTime.now();
+ int idx = -1;
+ for (int i = 0; i < tasks.size(); i++) {
+ XYDRWB t = tasks.get(i);
+ if (!Objects.equals(t.getPdxh(), i + 1)) {
+ t.setPdxh(i + 1);
+ t.setBdsj(now);
+ xydrwbMapper.updateById(t);
+ }
+ if (t.getBh().equals(bh)) {
+ idx = i;
+ }
+ }
+ if (idx < 0) {
+ throw new ServiceException("课程任务不属于该班次学期:" + bh, NOT_FOUND);
+ }
+ int other = up ? idx - 1 : idx + 1;
+ if (other < 0) {
+ throw new ServiceException("已经是最前面了", BAD_REQUEST);
+ }
+ if (other >= tasks.size()) {
+ throw new ServiceException("已经是最后面了", BAD_REQUEST);
+ }
+ XYDRWB a = tasks.get(idx);
+ XYDRWB b = tasks.get(other);
+ Integer tmp = a.getPdxh();
+ a.setPdxh(b.getPdxh());
+ b.setPdxh(tmp);
+ a.setBdsj(now);
+ b.setBdsj(now);
+ xydrwbMapper.updateById(a);
+ xydrwbMapper.updateById(b);
+ // 返回顺序必须反映交换后的新顺序(供前端直接刷新),而不是归一化时的旧顺序
+ java.util.Collections.swap(tasks, idx, other);
+
+ return tasks.stream().map(XYDRWB::getBh).toList();
+ }
+
+ @Override
+ public List> groups(String kbh) {
+ if (kbh == null || kbh.isEmpty()) {
+ throw new ServiceException("请指定课编号", BAD_REQUEST);
+ }
+ List tasks = xydrwbMapper.selectList(new LambdaQueryWrapper()
+ .eq(XYDRWB::getKbh, kbh)
+ .eq(XYDRWB::getDelFlag, 0));
+ tasks.sort(Comparator
+ .comparing((XYDRWB t) -> t.getNd() == null ? 0 : t.getNd())
+ .thenComparing(t -> t.getPdbz() == null ? 0 : t.getPdbz())
+ .thenComparing(XYDRWB::getBh));
+ List> rows = new ArrayList<>();
+ for (XYDRWB task : tasks) {
+ Map row = new LinkedHashMap<>();
+ row.put("bh", task.getBh());
+ row.put("kbh", task.getKbh());
+ row.put("kcmc", resolveCourseName(task));
+ row.put("nd", task.getNd());
+ XYDB clazz = task.getXydbh() == null ? null : xydbMapper.selectById(task.getXydbh());
+ row.put("xydbh", task.getXydbh());
+ row.put("xydmc", clazz != null ? clazz.getXydmc() : task.getXydbh());
+ row.put("xs", task.getXs());
+ row.put("zks", task.getZks());
+ row.put("pdxh", task.getPdxh());
+ row.put("pdqsz", task.getPdqsz());
+ row.put("pdaz", task.getPdaz() == null ? defaultPdaz(task.getZks()) : task.getPdaz());
+ row.put("pdbz", task.getPdbz() == null ? 0 : task.getPdbz());
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public int setGroup(AllocationGroupRequest request) {
+ if (request == null || request.getBhList() == null || request.getBhList().isEmpty()) {
+ throw new ServiceException("请先选择要改编组的任务", BAD_REQUEST);
+ }
+ if (request.getPdbz() == null || request.getPdbz() < 0) {
+ throw new ServiceException("配档编组必须 ≥ 0", BAD_REQUEST);
+ }
+ int count = 0;
+ LocalDateTime now = LocalDateTime.now();
+ for (String bh : request.getBhList()) {
+ XYDRWB task = xydrwbMapper.selectById(bh);
+ if (task == null || Integer.valueOf(1).equals(task.getDelFlag())) {
+ continue;
+ }
+ teachingTaskWriteGuard.assertCourseTaskWritable(task.getXydxqbh());
+ if (!Objects.equals(task.getPdbz(), request.getPdbz())) {
+ task.setPdbz(request.getPdbz());
+ task.setBdsj(now);
+ xydrwbMapper.updateById(task);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ // ==================== 导出 ====================
+
+ /** 导出行(固定列,便于 ExcelExportUtil 取值) */
+ public static class AllocationExportRow {
+ private final String xydmc;
+ private final Integer ndCode;
+ private final String kbh;
+ private final String kcmc;
+ private final String klx;
+ private final Integer xs;
+ private final Integer zks;
+ private final Integer pdxh;
+ private final Integer pdqsz;
+ private final String pdazText;
+ private final Integer pdzzksj;
+ private final Integer pdbz;
+ private final Integer startWeek;
+ private final Integer endWeek;
+ private final Integer overflowHours;
+
+ AllocationExportRow(String xydmc, Integer ndCode, XYDRWB task, AllocationTaskVO vo) {
+ this.xydmc = xydmc;
+ this.ndCode = ndCode;
+ this.kbh = task.getKbh();
+ this.kcmc = vo.getKcmc();
+ this.klx = task.getKlx();
+ this.xs = task.getXs();
+ this.zks = task.getZks();
+ this.pdxh = task.getPdxh();
+ this.pdqsz = task.getPdqsz();
+ this.pdazText = Objects.equals(task.getPdaz(), 0) ? "连排" : "按周";
+ this.pdzzksj = task.getPdzzksj();
+ this.pdbz = task.getPdbz() == null ? 0 : task.getPdbz();
+ this.startWeek = vo.getStartWeek();
+ this.endWeek = vo.getEndWeek();
+ this.overflowHours = vo.getOverflowHours();
+ }
+
+ public String getXydmc() { return xydmc; }
+ public Integer getNdCode() { return ndCode; }
+ public String getKbh() { return kbh; }
+ public String getKcmc() { return kcmc; }
+ public String getKlx() { return klx; }
+ public Integer getXs() { return xs; }
+ public Integer getZks() { return zks; }
+ public Integer getPdxh() { return pdxh; }
+ public Integer getPdqsz() { return pdqsz; }
+ public String getPdazText() { return pdazText; }
+ public Integer getPdzzksj() { return pdzzksj; }
+ public Integer getPdbz() { return pdbz; }
+ public Integer getStartWeek() { return startWeek; }
+ public Integer getEndWeek() { return endWeek; }
+ public Integer getOverflowHours() { return overflowHours; }
+ }
+
+ @Override
+ public void exportExcel(List xydxqbhList, HttpServletResponse response) throws Exception {
+ if (xydxqbhList == null || xydxqbhList.isEmpty()) {
+ throw new ServiceException("请先选择要导出的班次学期", BAD_REQUEST);
+ }
+ List rows = new ArrayList<>();
+ for (String xydxqbh : xydxqbhList) {
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
+ if (semester == null) {
+ continue;
+ }
+ XYDB clazz = xydbMapper.selectById(semester.getXydbh());
+ String xydmc = clazz != null ? clazz.getXydmc() : semester.getXydbh();
+ Integer ndCode = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
+ AllocationViewVO view = view(xydxqbh);
+ Map voMap = new HashMap<>();
+ view.getTasks().forEach(t -> voMap.put(t.getBh(), t));
+ for (XYDRWB task : listTasks(xydxqbh)) {
+ rows.add(new AllocationExportRow(xydmc, ndCode, task, voMap.get(task.getBh())));
+ }
+ }
+ if (rows.isEmpty()) {
+ throw new ServiceException("所选班次学期没有课程任务可导出", NOT_FOUND);
+ }
+ String[] headers = {"班次名称", "学期代号", "课编号", "课程", "课类型", "学时", "周课时",
+ "配档序号", "配档起始周", "连排/按周", "占正课时间", "配档编组", "计算起始周", "计算结束周", "溢出学时"};
+ byte[] bytes = ExcelExportUtil.export("教学配当", headers, rows,
+ AllocationExportRow::getXydmc, AllocationExportRow::getNdCode, AllocationExportRow::getKbh,
+ AllocationExportRow::getKcmc, AllocationExportRow::getKlx, AllocationExportRow::getXs,
+ AllocationExportRow::getZks, AllocationExportRow::getPdxh, AllocationExportRow::getPdqsz,
+ AllocationExportRow::getPdazText, AllocationExportRow::getPdzzksj, AllocationExportRow::getPdbz,
+ AllocationExportRow::getStartWeek, AllocationExportRow::getEndWeek, AllocationExportRow::getOverflowHours);
+ response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ response.setCharacterEncoding("utf-8");
+ String fileName = java.net.URLEncoder.encode("教学配当.xlsx", java.nio.charset.StandardCharsets.UTF_8);
+ response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
+ response.getOutputStream().write(bytes);
+ response.getOutputStream().flush();
+ }
+
+ // ==================== 公共 ====================
+
+ private XYDNDXQJBXXB requireSemester(String xydxqbh) {
+ if (xydxqbh == null || xydxqbh.isEmpty()) {
+ throw new ServiceException("请指定班次学期", BAD_REQUEST);
+ }
+ XYDNDXQJBXXB semester = classSemesterMapper.selectById(xydxqbh);
+ if (semester == null) {
+ throw new ServiceException("班次学期不存在:" + xydxqbh, NOT_FOUND);
+ }
+ return semester;
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingTaskManageServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingTaskManageServiceImpl.java
index 035dbef6..ad146775 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingTaskManageServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TeachingTaskManageServiceImpl.java
@@ -379,7 +379,7 @@ public class TeachingTaskManageServiceImpl implements TeachingTaskManageService
continue;
}
try {
- studentTeamTaskService.autoGenerateRequiredCourses(squad.getXydbh(), squad.getXqdc());
+ studentTeamTaskService.autoGenerateRequiredCourses(squad.getBh());
} catch (Exception ignored) {
// 课程任务已存在或计划缺失时,仍继续生成教研室任务书
}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java
index 5e772e27..ff9a1296 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/impl/TimetableConflictServiceImpl.java
@@ -2,11 +2,26 @@ package com.roomroot.jwgl.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.roomroot.jwgl.entity.JQB;
+import com.roomroot.jwgl.entity.JXCDL;
+import com.roomroot.jwgl.entity.SSKC;
+import com.roomroot.jwgl.entity.SSKCB;
+import com.roomroot.jwgl.entity.SSKCXYD;
import com.roomroot.jwgl.entity.TimetableConflictResult;
+import com.roomroot.jwgl.entity.XYDB;
+import com.roomroot.jwgl.entity.XQXLB;
+import com.roomroot.jwgl.mapper.ClassRoomCalendarMapper;
+import com.roomroot.jwgl.mapper.JQBMapper;
+import com.roomroot.jwgl.mapper.KBMapper;
import com.roomroot.jwgl.mapper.SSKCBFZJYMapper;
import com.roomroot.jwgl.mapper.SSKCBJSMapper;
+import com.roomroot.jwgl.mapper.SSKCBMapper;
import com.roomroot.jwgl.mapper.SSKCBXYDMapper;
+import com.roomroot.jwgl.mapper.SSKCMapper;
+import com.roomroot.jwgl.mapper.SSKCXYDMapper;
+import com.roomroot.jwgl.mapper.SemesterCalendarMapper;
import com.roomroot.jwgl.mapper.TimetableConflictResultMapper;
+import com.roomroot.jwgl.mapper.XYDBMapper;
import com.roomroot.jwgl.service.TimetableConflictService;
import com.roomroot.jwgl.unit.PageResult;
import com.roomroot.jwgl.unit.conflict.TimetableConflictDimension;
@@ -17,6 +32,7 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -47,6 +63,10 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
@Resource
private SSKCBXYDMapper sskcbxydMapper;
+ /** 实施_课程学员队(注意与 SSKCBXYDMapper=实施_课程表_学员队 是两张表) */
+ @Resource
+ private com.roomroot.jwgl.mapper.SSKCXYDMapper sskcxydMapper;
+
@Resource
private SSKCBFZJYMapper sskcbfzjyMapper;
@@ -59,6 +79,28 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
@Resource
private TimetableConflictResultMapper conflictResultMapper;
+ // 阶段 5.1:EVENT_CONFLICT / 场地历不可用 需要读取的表
+ @Resource
+ private SemesterCalendarMapper semesterCalendarMapper;
+
+ @Resource
+ private JQBMapper jqbMapper;
+
+ @Resource
+ private ClassRoomCalendarMapper classRoomCalendarMapper;
+
+ @Resource
+ private SSKCBMapper sskcbMapper;
+
+ @Resource
+ private SSKCMapper sskcMapper;
+
+ @Resource
+ private XYDBMapper xydbMapper;
+
+ @Resource
+ private KBMapper kbMapper;
+
// =====================================================================
// 内存缓存(不需要持久化到数据库,临时保存每次检查结果)
// Key 格式:"<年度>_<维度编码>" 例:"2026_TEACHER_CONFLICT"
@@ -210,9 +252,19 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
case TEAM_CONFLICT -> sskcbxydMapper.selectTeamConflictDetails(nd);
case ELECTIVE_REQUIRED_CONFLICT -> sskcbxydMapper.selectElectiveRequiredConflictDetails(nd);
case TEACHER_CONFLICT -> sskcbfzjyMapper.selectTeacherConflictDetails(nd);
- case CLASSROOM_CONFLICT -> sskcbjsMapper.selectClassroomConflictDetails(nd);
- case GUARANTEE_CONFLICT, EVENT_CONFLICT -> Collections.emptyList();
+ case CLASSROOM_CONFLICT -> {
+ // 阶段 5.1:教室重复占用 + 场地历不可用
+ List dup = sskcbjsMapper.selectClassroomConflictDetails(nd);
+ dup.addAll(checkClassroomCalendarConflicts(nd));
+ yield dup;
+ }
+ case EVENT_CONFLICT -> checkEventConflicts(nd);
+ case GUARANTEE_CONFLICT -> Collections.emptyList();
};
+ // 本检查器的维度全部为硬冲突(双占 / 三类历不可排);软提示由排课窗实时计算
+ if (details != null) {
+ details.forEach(d -> d.setLevel("hard"));
+ }
} catch (Exception e) {
log.error("[课表冲突检查][维度:{}][年度:{}] 执行SQL失败: {}",
dimension.getCode(), nd, e.getMessage(), e);
@@ -435,4 +487,195 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
return Collections.emptyList();
}
}
+
+ // =====================================================================
+ // 阶段 5.1:EVENT_CONFLICT(校历/班历不可排课格)与场地历不可用
+ // =====================================================================
+
+ /**
+ * EVENT_CONFLICT:读取 学期校历表、假期表 的不可排课格,与已排课次求交。
+ * 校历不可排课格上有已排课次 → 硬冲突;班历对某学员队不可排课格上有该队已排课次 → 硬冲突。
+ */
+ private List checkEventConflicts(Integer nd) {
+ List result = new ArrayList<>();
+ // 已排课次(未删除、有日期)
+ List lessons = sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getNd, nd)
+ .eq(SSKCB::getSczt, 0)
+ .isNotNull(SSKCB::getRq));
+ if (lessons.isEmpty()) {
+ return result;
+ }
+ Map courseNames = loadCourseNames(nd);
+
+ // ---- 校历:可排课=0 的格 ----
+ Map> byGrid = lessons.stream()
+ .filter(l -> l.getRq() != null && l.getJc() != null)
+ .collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
+ List schoolUnavailable = semesterCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(XQXLB::getNd, nd)
+ .eq(XQXLB::getKpk, false));
+ for (XQXLB grid : schoolUnavailable) {
+ for (Integer jc : parsePeriods(grid.getCourseClass())) {
+ List hits = byGrid.get(grid.getJqsj() + "#" + jc);
+ if (hits != null && !hits.isEmpty()) {
+ result.add(buildEventDetail("SCHOOL|" + grid.getJqsj() + "#" + jc, "校历不可排课格",
+ grid.getJqsj(), jc, hits, courseNames,
+ "校历标记该日期节次不可排课(" + safeName(grid.getJqmc()) + "),但已有课次安排"));
+ }
+ }
+ }
+
+ // ---- 班历:对某学员队可排课=0 的格 ----
+ List classUnavailable = jqbMapper.selectList(new LambdaQueryWrapper()
+ .eq(JQB::getNd, nd)
+ .eq(JQB::getKpk, 0));
+ if (!classUnavailable.isEmpty()) {
+ Map lessonByBh = lessons.stream()
+ .collect(Collectors.toMap(SSKCB::getBh, l -> l, (a, b) -> a));
+ List links = sskcxydMapper.selectList(new LambdaQueryWrapper()
+ .in(SSKCXYD::getSskcbh, lessonByBh.keySet()));
+ Map teamNames = xydbMapper.selectList(new LambdaQueryWrapper<>()).stream()
+ .collect(Collectors.toMap(XYDB::getXydbh, XYDB::getXydmc, (a, b) -> a));
+ Map> teamsOfLesson = new HashMap<>();
+ for (SSKCXYD link : links) {
+ teamsOfLesson.computeIfAbsent(link.getSskcbh(), k -> new ArrayList<>()).add(link.getXydbh());
+ }
+ for (JQB grid : classUnavailable) {
+ if (grid.getJqsj() == null || grid.getCourseClass() == null) continue;
+ String dateStr = intDateToString(grid.getJqsj());
+ for (Integer jc : parsePeriods(grid.getCourseClass())) {
+ for (Map.Entry> e : teamsOfLesson.entrySet()) {
+ if (!e.getValue().contains(grid.getXydbh())) continue;
+ SSKCB lesson = lessonByBh.get(e.getKey());
+ if (lesson == null || lesson.getRq() == null || lesson.getJc() == null) continue;
+ if (!dateStr.equals(lesson.getRq().toLocalDate().toString()) || lesson.getJc() != jc) continue;
+ TimetableConflictDetailVO d = buildEventDetail(
+ "CLASS|" + grid.getXydbh() + "|" + dateStr + "#" + jc, "班历不可排课格",
+ dateStr, jc, List.of(lesson), courseNames,
+ "班历标记该日期节次对该学员队不可排课,但已有课次安排");
+ d.setXydNames(teamNames.getOrDefault(grid.getXydbh(), grid.getXydbh()));
+ result.add(d);
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ /** CLASSROOM_CONFLICT 追加:教学场地历 标记不可排课(可排课=0)但该教室该格已有课次 */
+ private List checkClassroomCalendarConflicts(Integer nd) {
+ List result = new ArrayList<>();
+ List lessons = sskcbMapper.selectList(new LambdaQueryWrapper()
+ .eq(SSKCB::getNd, nd)
+ .eq(SSKCB::getSczt, 0)
+ .isNotNull(SSKCB::getRq));
+ if (lessons.isEmpty()) {
+ return result;
+ }
+ Map lessonByBh = lessons.stream()
+ .collect(Collectors.toMap(SSKCB::getBh, l -> l, (a, b) -> a));
+ List unavailable = classRoomCalendarMapper.selectList(new LambdaQueryWrapper()
+ .eq(JXCDL::getKpk, false)
+ .eq(JXCDL::getDelFlag, 0)
+ .isNotNull(JXCDL::getRq));
+ if (unavailable.isEmpty()) {
+ return result;
+ }
+ Map> roomsOfLesson = new HashMap<>();
+ for (var link : sskcbjsMapper.selectList(new LambdaQueryWrapper()
+ .in(com.roomroot.jwgl.entity.SSKCBJS::getSskcbbh, lessonByBh.keySet()))) {
+ roomsOfLesson.computeIfAbsent(link.getSskcbbh(), k -> new ArrayList<>()).add(link.getJsbh());
+ }
+ Map courseNames = loadCourseNames(nd);
+ Map roomNames = new HashMap<>();
+ for (JXCDL row : unavailable) {
+ if (row.getRq() == null || row.getJsbh() == null) continue;
+ String dateStr = row.getRq().toLocalDate().toString();
+ roomNames.put(row.getJsbh(), safeName(row.getMc()));
+ for (Map.Entry> e : roomsOfLesson.entrySet()) {
+ if (!e.getValue().contains(row.getJsbh())) continue;
+ SSKCB lesson = lessonByBh.get(e.getKey());
+ if (lesson.getRq() == null || lesson.getJc() == null) continue;
+ if (!dateStr.equals(lesson.getRq().toLocalDate().toString()) || lesson.getJc() != row.getJc()) continue;
+ TimetableConflictDetailVO d = buildEventDetail(
+ "ROOMCAL|" + row.getJsbh() + "|" + dateStr + "#" + row.getJc(), "场地历不可用",
+ dateStr, row.getJc(), List.of(lesson), courseNames,
+ "教学场地历标记该教室该格不可排课,但已有课次安排");
+ d.setClassroomNames(safeName(row.getMc()));
+ result.add(d);
+ }
+ }
+ return result;
+ }
+
+ /** 构建 不可排课格 类冲突明细 */
+ private TimetableConflictDetailVO buildEventDetail(String resourceId, String resourceName,
+ String dateStr, Integer jc, List lessons,
+ Map courseNames, String reason) {
+ String courses = lessons.stream()
+ .map(l -> courseNames.getOrDefault(l.getSskcbh(), l.getSskcbh()))
+ .distinct()
+ .collect(Collectors.joining(";"));
+ return TimetableConflictDetailVO.builder()
+ .dimensionCode("EVENT_CONFLICT")
+ .dimensionTitle("日历冲突")
+ .resourceId(resourceId)
+ .resourceName(resourceName)
+ .rq(LocalDate.parse(dateStr).atStartOfDay())
+ .jc(jc)
+ .occupiedLessonCount(lessons.size())
+ .courseNames(courses)
+ .conflictSskcbBhs(lessons.stream().map(SSKCB::getBh).collect(Collectors.joining(",")))
+ .reason(reason)
+ .level("hard")
+ .build();
+ }
+
+ /** 实施_课程编号 → 课程名称(经 实施_课程.科目编号 → 课表.课名称) */
+ private Map loadCourseNames(Integer nd) {
+ Map names = new HashMap<>();
+ List courses = sskcMapper.selectList(new LambdaQueryWrapper().eq(SSKC::getNd, nd));
+ if (courses.isEmpty()) return names;
+ Set kbhs = courses.stream().map(SSKC::getKmbh).collect(Collectors.toSet());
+ Map kbhNames = kbMapper.selectBatchIds(kbhs).stream()
+ .collect(Collectors.toMap(com.roomroot.jwgl.entity.KB::getKbh, k -> safeName(k.getKmc()), (a, b) -> a));
+ for (SSKC c : courses) {
+ names.put(c.getBh(), kbhNames.getOrDefault(c.getKmbh(), c.getKmbh()));
+ }
+ return names;
+ }
+
+ /** 解析历表「节次」标签为节次号集合:"1-2"→[1,2],"3"→[3],无法解析返回空 */
+ private List parsePeriods(String label) {
+ List out = new ArrayList<>();
+ if (label == null) return out;
+ for (String token : label.split("[,,]")) {
+ String t = token.trim();
+ if (t.contains("-")) {
+ String[] parts = t.split("-");
+ try {
+ int a = Integer.parseInt(parts[0].trim());
+ int b = Integer.parseInt(parts[1].trim());
+ for (int i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i);
+ } catch (Exception ignored) { }
+ } else {
+ try {
+ out.add(Integer.parseInt(t));
+ } catch (Exception ignored) { }
+ }
+ }
+ return out;
+ }
+
+ private static String intDateToString(Integer d) {
+ if (d == null) return null;
+ String s = String.valueOf(d);
+ if (s.length() == 8) return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
+ return s;
+ }
+
+ private static String safeName(String s) {
+ return s == null || s.isEmpty() ? "-" : s;
+ }
}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/SemesterCodeUtil.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/SemesterCodeUtil.java
index 98ced885..037a2c23 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/SemesterCodeUtil.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/utils/SemesterCodeUtil.java
@@ -1,18 +1,21 @@
package com.roomroot.jwgl.utils;
+import java.time.LocalDate;
+
/**
* 学期代号工具。
*
* 系统内学期统一用 6 位学期代号表示,形如 {@code 202601}:前 4 位为年份,后 2 位为学期第次。
*
- *
但「学员队年度学期基本信息表」的 {@code 年度} 列在历史上出现过两种口径:
+ *
「年度」列的口径约定(按表区分):
*
- * - 4 位年份 + 学期第次,例如 年度=2026、学期第次=1 → 2026 * 100 + 1 = 202601
- * - 直接存 6 位学期代号,例如 年度=202503
+ * - 课程/任务/实施类表(学员队任务表、实施_课程、实施_课程表、课程表):存 6 位学期代号;
+ * - 学员队年度学期基本信息表(班次学期):存 4 位年份 + 学期第次列。
*
+ * 班次学期表的历史数据里「年度」出现过直接存 6 位代号的情况(如 202503),
* 把 6 位值再套 {@code nd * 100 + xqdc} 会溢出(202503 * 100 + 1 = 20250301),
* 所以各处都需要先判别口径。本工具把两种口径统一解析为 6 位学期代号,
- * 同时提供「写库时统一为 4 位年份」的规范化方法,避免新增数据继续产生双口径。
+ * 同时提供「写库时统一为 4 位年份」的规范化方法。
*/
public final class SemesterCodeUtil {
@@ -22,6 +25,30 @@ public final class SemesterCodeUtil {
private SemesterCodeUtil() {
}
+ /**
+ * 按日期推断「学年内学期类别」:2-5 月春季(1)、6-8 月夏季(2)、9 月-次年 1 月秋季(3)。
+ *
+ * 学期第次在全系统的契约是 1/2/3(见 {@code ClassSemesterServiceImpl.assertXqdc} 与
+ * 前端批量建班接口「xqdc 传 春季学期/夏季学期/秋季学期」),它与「专业总第几学期」
+ * (人培/专业教学计划表的口径,1..学期数)是两套不同的编号,不能混用。
+ *
+ * @param date 用于判断学年内位置的日期(优先开学日期,其次入学日期)
+ * @return 1 春季 / 2 夏季 / 3 秋季;日期为空时返回 1
+ */
+ public static Integer periodOf(LocalDate date) {
+ if (date == null) {
+ return 1;
+ }
+ int month = date.getMonthValue();
+ if (month >= 2 && month <= 5) {
+ return 1;
+ }
+ if (month >= 6 && month <= 8) {
+ return 2;
+ }
+ return 3;
+ }
+
/**
* 解析为 6 位学期代号,兼容 4 位年份与 6 位学期代号两种历史口径。
*
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationTaskVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationTaskVO.java
new file mode 100644
index 00000000..a07d58ff
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationTaskVO.java
@@ -0,0 +1,77 @@
+package com.roomroot.jwgl.vo.allocation;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 教学配当 - 单条课程任务的配当行(含铺学时结果)。
+ *
+ * 铺学时结果只是「粗约束建议」,不写实施课程表(SSKCB),见方案阶段 3「明确不做」。
+ */
+@Data
+public class AllocationTaskVO {
+
+ /** 任务编号 */
+ private String bh;
+
+ /** 课编号 */
+ private String kbh;
+
+ /** 课程名(简称优先,缺省取课名称) */
+ private String kcmc;
+
+ /** 课类型 */
+ private String klx;
+
+ /** 学时 */
+ private Integer xs;
+
+ /** 周课时 */
+ private Integer zks;
+
+ /** 配档序号(优选序数) */
+ private Integer pdxh;
+
+ /** 配档起始周 */
+ private Integer pdqsz;
+
+ /** 配档按周:0=连排(单科独进) 1=按周(非连排) */
+ private Integer pdaz;
+
+ /** 配档占正课时间 */
+ private Integer pdzzksj;
+
+ /** 配档编组 */
+ private Integer pdbz;
+
+ /** 教研室代号 */
+ private String jysdh;
+
+ /** 计算起始周(铺学时结果) */
+ private Integer startWeek;
+
+ /** 计算结束周(铺学时结果) */
+ private Integer endWeek;
+
+ /** 溢出学时(超出学期周数没铺完的部分) */
+ private Integer overflowHours;
+
+ /** 每周铺学时(weekNo -> 学时),仅含 >0 的周 */
+ private List distribution = new ArrayList<>();
+
+ @Data
+ public static class WeekHours {
+ private Integer week;
+ private Integer hours;
+
+ public WeekHours() {
+ }
+
+ public WeekHours(Integer week, Integer hours) {
+ this.week = week;
+ this.hours = hours;
+ }
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationViewVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationViewVO.java
new file mode 100644
index 00000000..97875d81
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationViewVO.java
@@ -0,0 +1,44 @@
+package com.roomroot.jwgl.vo.allocation;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 教学配当编辑器 - 打开视图。
+ */
+@Data
+public class AllocationViewVO {
+
+ /** 班次学期编号 */
+ private String xydxqbh;
+
+ /** 学员队编号 */
+ private String xydbh;
+
+ /** 班次(学员队)名称 */
+ private String xydmc;
+
+ /** 学期代号(6 位) */
+ private Integer ndCode;
+
+ /** 学期起 */
+ private LocalDate kxrq;
+
+ /** 学期止 */
+ private LocalDate jsrq;
+
+ /** 总周数 */
+ private Integer totalWeeks;
+
+ /** 是否冻结(已发布/已结束的教学任务,只读) */
+ private Boolean frozen;
+
+ /** 周视图 */
+ private List weeks = new ArrayList<>();
+
+ /** 任务配当行(已铺学时) */
+ private List tasks = new ArrayList<>();
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationWeekVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationWeekVO.java
new file mode 100644
index 00000000..f95d0d32
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/allocation/AllocationWeekVO.java
@@ -0,0 +1,31 @@
+package com.roomroot.jwgl.vo.allocation;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+
+/**
+ * 教学配当 - 周视图行。
+ *
+ * 「每周可排正课」口径:该周内 可排课=1 且 正课=1 的时间格数 × 2 学时(双节次一格按 2 学时计)。
+ * 数据源优先级:班历(假期表)> 校历(学期校历表)> 默认值(周一~五 1-2/3-4/5-6 共 15 格 = 30 学时)。
+ * 班历已继承校历并允许人工补正课,故直接用班历即等价于「校历正课 − 不可排课 + 班历补正课」。
+ */
+@Data
+public class AllocationWeekVO {
+
+ /** 周次(从 1 起) */
+ private Integer weekNo;
+
+ /** 周起(周一) */
+ private LocalDate startDate;
+
+ /** 周止(周日) */
+ private LocalDate endDate;
+
+ /** 该周可排正课学时 */
+ private Integer availableHours;
+
+ /** 容量来源:class-班历 / school-校历 / default-默认 */
+ private String source;
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/elective/SemesterInitVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/elective/SemesterInitVO.java
index 0ef13e49..5f1d8fa2 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/elective/SemesterInitVO.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/elective/SemesterInitVO.java
@@ -34,7 +34,8 @@ public class SemesterInitVO {
private Boolean halfYear;
/**
- * 默认/推断的学期第次
+ * 默认/推断的学期第次(学年内学期类别:1 春季 / 2 夏季 / 3 秋季;
+ * 不是「专业总第几学期」,人培/专业教学计划表的学期第次才是专业总学期口径)
*/
private Integer xqdc;
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java
new file mode 100644
index 00000000..d0d431f1
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/scheduling/SchedulingViewVO.java
@@ -0,0 +1,98 @@
+package com.roomroot.jwgl.vo.scheduling;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 实施排课窗口视图(阶段 5.3):班次头 + 课程列表 + 周次×星期×节次 格子。
+ */
+@Data
+public class SchedulingViewVO {
+
+ private String xydxqbh;
+
+ private String xydmc;
+
+ /** 班次专用教室编号 */
+ private String zyjsbh;
+
+ /** 6 位学期代号 */
+ private Integer ndCode;
+
+ private LocalDate kxrq;
+
+ private LocalDate jsrq;
+
+ private Integer totalWeeks;
+
+ /** 课程列表(已排满 full=true,前端标绿) */
+ private List courses = new ArrayList<>();
+
+ /** 时间区:按周切分,格子叠加校历/班历不可排与已排课次 */
+ private List weeks = new ArrayList<>();
+
+ @Data
+ public static class Course {
+ private String sskcbh;
+ private String kbh;
+ private String kcmc;
+ private String klx;
+ private Integer xs;
+ private Integer zks;
+ private String jybh;
+ private String jyxm;
+ private String jsbh;
+ private String jsmc;
+ /** 已排学时(每节 2 学时 + 节次调节) */
+ private Integer scheduledHours;
+ /** 是否已排满(scheduledHours >= xs) */
+ private Boolean full;
+ /** 当前用户是否可编辑该课程 */
+ private Boolean editable;
+ }
+
+ @Data
+ public static class Week {
+ private Integer weekNo;
+ private LocalDate startDate;
+ private LocalDate endDate;
+ private List days = new ArrayList<>();
+ }
+
+ @Data
+ public static class Day {
+ private LocalDate date;
+ private Integer weekday;
+ /** 该日不可排原因(校历整日/班历整日,节次级见格子) */
+ private String unavailableReason;
+ private List cells = new ArrayList<>();
+ }
+
+ @Data
+ public static class Cell {
+ private Integer jc;
+ /** 硬冲突:不可排课 */
+ private Boolean unavailable;
+ /** 硬冲突原因(不可排课来源:校历/班历等) */
+ private String reason;
+ /** 软提示(非正课 / 配当周次不符) */
+ private String warning;
+ private List lessons = new ArrayList<>();
+ }
+
+ @Data
+ public static class Lesson {
+ private String bh;
+ private String sskcbh;
+ private String kcmc;
+ private String jxnr;
+ private String jxff;
+ private Integer jcdj;
+ private String jyxm;
+ private String jsmc;
+ private String ycxx;
+ }
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/taskbook/TaskBookRowVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/taskbook/TaskBookRowVO.java
new file mode 100644
index 00000000..26ecd5f5
--- /dev/null
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/taskbook/TaskBookRowVO.java
@@ -0,0 +1,79 @@
+package com.roomroot.jwgl.vo.taskbook;
+
+import lombok.Data;
+
+/**
+ * 教研室任务书填报列表行(阶段 4)。
+ *
+ * 一行 = 一条课程任务(学员队任务表)。列表排序:课程 → 责任教员 → 课次;
+ * 返回 合班分组号(bz2,0=未合班)供前端同色连显。
+ */
+@Data
+public class TaskBookRowVO {
+
+ /** 课程任务编号(学员队任务表.编号) */
+ private String bh;
+
+ /** 学员队学期编号 */
+ private String xydxqbh;
+
+ /** 课编号(科目) */
+ private String kbh;
+
+ /** 课程名称(简称优先,缺省取课表课名称) */
+ private String kcmc;
+
+ /** 课类型 */
+ private String klx;
+
+ /** 学时 */
+ private Integer xs;
+
+ /** 周课时 */
+ private Integer zks;
+
+ /** 成绩分制 */
+ private String cjfz;
+
+ /** 学员队编号 */
+ private String xydbh;
+
+ /** 学员队名称 */
+ private String xydmc;
+
+ /** 责任教员编号 */
+ private String jybh;
+
+ /** 责任教员姓名 */
+ private String jyxm;
+
+ /** 场地(教室编号) */
+ private String jsbh;
+
+ /** 场地名称 */
+ private String jsmc;
+
+ /** 课次序号 */
+ private Integer kcxh;
+
+ /** 合班分组号(0=未合班) */
+ private Integer bz2;
+
+ /** 教研室代号 */
+ private String jysdh;
+
+ /** 教研室名称 */
+ private String jysmc;
+
+ /** 教研室计划教员编号 */
+ private String jysjhjybh;
+
+ /** 教研室计划教员姓名 */
+ private String jysjhjyxm;
+
+ /** 排课建议(教研室计划备注) */
+ private String jysjhbz;
+
+ /** 是否只读(教学任务未发布或已结束) */
+ private Boolean frozen;
+}
diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/timetableconflict/TimetableConflictDetailVO.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/timetableconflict/TimetableConflictDetailVO.java
index 716fe4c4..82cd94fd 100644
--- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/timetableconflict/TimetableConflictDetailVO.java
+++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/vo/timetableconflict/TimetableConflictDetailVO.java
@@ -132,4 +132,10 @@ public class TimetableConflictDetailVO {
* 统一从学员队表的「所属单位」列取值(WM_CONCAT 去重)。
*/
private String responsibleDept;
+
+ /**
+ * 冲突等级(阶段 5.1):hard-硬冲突(双占 / 三类历不可排,深红);soft-软提示(橙)。
+ * 本检查器的维度全部为硬冲突;软提示(配当周次不符、非正课)由排课窗实时计算。
+ */
+ private String level;
}
diff --git a/frontend/src/api/studentRecords/classCalendar.js b/frontend/src/api/studentRecords/classCalendar.js
index f77179de..ac99989a 100644
--- a/frontend/src/api/studentRecords/classCalendar.js
+++ b/frontend/src/api/studentRecords/classCalendar.js
@@ -95,12 +95,14 @@ export function batchPresetSplit(bhList) {
})
}
-// 自动生成必修课程(按学员队+学期第次从专业教学计划生成班历记录)
-export function autoGenerateRequiredCourses(xydbh, xqdc) {
+// 自动生成必修课程(按学员队学期编号 xydxqbh 定位唯一的目标班次学期)
+// 不再传 (学员队编号, 学期第次):同一学员队在多个年度会有学期第次相同的班次学期,
+// 只传这两个参数无法区分学年,会把课程任务写进别的年度。
+export function autoGenerateRequiredCourses(xydxqbh) {
return request({
url: '/studentTeamTask/autoGenerateRequiredCourses',
method: 'post',
- params: { xydbh: xydbh, xqdc: xqdc }
+ params: { xydxqbh: xydxqbh }
})
}
diff --git a/frontend/src/api/teachBusiness/allocation.js b/frontend/src/api/teachBusiness/allocation.js
new file mode 100644
index 00000000..8a35256d
--- /dev/null
+++ b/frontend/src/api/teachBusiness/allocation.js
@@ -0,0 +1,59 @@
+import request from '@/utils/request'
+
+// 教学配当(阶段 3)
+// 配当只做排课粗约束(周学时分布、同开同结),不直接写实施课程表。
+
+// 配当编辑器视图:周轴(每周可排正课)+ 任务行(含铺学时建议)
+export function allocationView(xydxqbh) {
+ return request({
+ url: '/teachingAllocation/view',
+ method: 'get',
+ params: { xydxqbh }
+ })
+}
+
+// 保存配当:单条(列表 1 个元素)或多选批量;元素 saveGroup=true 时整编组同开同结
+export function allocationSave(items) {
+ return request({
+ url: '/teachingAllocation/save',
+ method: 'post',
+ data: items
+ })
+}
+
+// 优选序数上移/下移(direction: up/down),返回归一化后的任务编号顺序
+export function allocationMove(xydxqbh, bh, direction) {
+ return request({
+ url: '/teachingAllocation/move',
+ method: 'post',
+ params: { xydxqbh, bh, direction }
+ })
+}
+
+// 编组管理面板:同科目全部开课班次
+export function allocationGroups(kbh) {
+ return request({
+ url: '/teachingAllocation/groups',
+ method: 'get',
+ params: { kbh }
+ })
+}
+
+// 编组管理:把所选任务统一改编组号
+export function allocationSetGroup(data) {
+ return request({
+ url: '/teachingAllocation/setGroup',
+ method: 'post',
+ data
+ })
+}
+
+// 导出配当 Excel(xydxqbh 逗号分隔,支持批量)
+export function allocationExport(xydxqbhList) {
+ return request({
+ url: '/teachingAllocation/export',
+ method: 'get',
+ params: { xydxqbh: xydxqbhList.join(',') },
+ responseType: 'blob'
+ })
+}
diff --git a/frontend/src/api/teachBusiness/taskBook.js b/frontend/src/api/teachBusiness/taskBook.js
new file mode 100644
index 00000000..efb45fc4
--- /dev/null
+++ b/frontend/src/api/teachBusiness/taskBook.js
@@ -0,0 +1,69 @@
+import request from '@/utils/request'
+
+/**
+ * 教研室任务书填报(阶段 4)
+ * 接口依据:/taskBookFill/*(前端 baseURL 为 /api,此处不重复 /api 前缀)
+ */
+
+/** 填报列表 GET /taskBookFill/list?jxrwbh= */
+export function taskBookList(jxrwbh) {
+ return request({
+ url: '/taskBookFill/list',
+ method: 'get',
+ params: { jxrwbh }
+ })
+}
+
+/** 手动合班 POST /taskBookFill/merge body: { bhList } */
+export function taskBookMerge(bhList) {
+ return request({
+ url: '/taskBookFill/merge',
+ method: 'post',
+ data: { bhList }
+ })
+}
+
+/** 按预设合班 POST /taskBookFill/mergeByPreset body: { bhList } */
+export function taskBookMergeByPreset(bhList) {
+ return request({
+ url: '/taskBookFill/mergeByPreset',
+ method: 'post',
+ data: { bhList }
+ })
+}
+
+/** 拆班 POST /taskBookFill/split body: { bhList } */
+export function taskBookSplit(bhList) {
+ return request({
+ url: '/taskBookFill/split',
+ method: 'post',
+ data: { bhList }
+ })
+}
+
+/** 责任教员指定 POST /taskBookFill/setTeacher body: { bh, mode, jybh } */
+export function taskBookSetTeacher(data) {
+ return request({
+ url: '/taskBookFill/setTeacher',
+ method: 'post',
+ data
+ })
+}
+
+/** 场地指定 POST /taskBookFill/setRoom body: { bh, jsbh, useSpecial } */
+export function taskBookSetRoom(data) {
+ return request({
+ url: '/taskBookFill/setRoom',
+ method: 'post',
+ data
+ })
+}
+
+/** 填报字段 POST /taskBookFill/fill body: { bh, jysjhjybh, jsbh, jysjhbz } */
+export function taskBookFill(data) {
+ return request({
+ url: '/taskBookFill/fill',
+ method: 'post',
+ data
+ })
+}
diff --git a/frontend/src/views/teachBusiness/calendar/components/SchoolCalendarEditor.vue b/frontend/src/views/teachBusiness/calendar/components/SchoolCalendarEditor.vue
index 7ed548da..27e8293d 100644
--- a/frontend/src/views/teachBusiness/calendar/components/SchoolCalendarEditor.vue
+++ b/frontend/src/views/teachBusiness/calendar/components/SchoolCalendarEditor.vue
@@ -442,6 +442,10 @@ export default {
cellClass(wIdx, col) {
const classes = []
if (this.selectedKeys.includes(this.cellKey(wIdx, col.colIndex))) classes.push('is-selected')
+ // 班历:班次日期范围外的格子不可排,给出明确视觉提示
+ if (this.mode === 'class' && this.isOutOfRange(wIdx, col.dayIndex)) {
+ classes.push('is-out-of-range')
+ }
const ev = this.getEvent(wIdx, col.colIndex)
// 可排课的全部白色;不可排课且事件名非空才标红
if (ev && ev.name && !ev.schedulable) {
@@ -449,6 +453,30 @@ export default {
}
return classes
},
+ // 时间格对应的实际日期
+ cellDate(wIdx, dayIndex) {
+ if (!this.calendarStart) return null
+ const d = new Date(this.calendarStart)
+ d.setDate(this.calendarStart.getDate() + wIdx * 7 + dayIndex)
+ return d
+ },
+ // 班历只有 [开学日期, 结束日期] 内的格子会落库,范围外的格子无法保存
+ isOutOfRange(wIdx, dayIndex) {
+ if (!this.startDate || !this.endDate) return false
+ const d = this.cellDate(wIdx, dayIndex)
+ if (!d) return false
+ const at = v => new Date(v.getFullYear(), v.getMonth(), v.getDate()).getTime()
+ return at(d) < at(this.startDate) || at(d) > at(this.endDate)
+ },
+ // 返回所选时间格里落在班次日期范围外的格子数
+ countOutOfRangeSelected() {
+ let n = 0
+ this.selectedKeys.forEach(key => {
+ const pos = this.parseCellKey(key)
+ if (pos && this.isOutOfRange(pos.wIdx, pos.dayIndex)) n++
+ })
+ return n
+ },
/* ---------- 点击 / 拖拽多选 ---------- */
onGridMouseDown(e) {
const cell = e.target.closest('.sce-cell')
@@ -548,6 +576,14 @@ export default {
this.$message.warning('请输入事件名')
return
}
+ // 班历只在班次日期范围内落库,范围外的格子直接提示,避免落成含糊的“保存失败”
+ if (this.mode === 'class') {
+ const outside = this.countOutOfRangeSelected()
+ if (outside > 0) {
+ this.$message.warning(`所选 ${outside} 个时间格在班次日期范围外,不可排课`)
+ return
+ }
+ }
const keys = [...this.selectedKeys]
keys.forEach(key => {
const prev = this.events[key]
@@ -876,6 +912,14 @@ export default {
// 可排课白色,不可排课且有事件标红
&.no-schedule { background: #fde2e2; }
+ // 班历:班次日期范围外的格子不可排
+ &.is-out-of-range {
+ background: #f4f4f5;
+ cursor: not-allowed;
+
+ .sce-date-num { color: #dcdfe6; }
+ }
+
&.is-selected {
outline: 2px solid var(--edu-green-primary, #00875a);
outline-offset: -2px;
diff --git a/frontend/src/views/teachOffice/shiftSemester/ClassCalendarDialog.vue b/frontend/src/views/teachOffice/shiftSemester/ClassCalendarDialog.vue
index 19d071b3..4f7a4ebd 100644
--- a/frontend/src/views/teachOffice/shiftSemester/ClassCalendarDialog.vue
+++ b/frontend/src/views/teachOffice/shiftSemester/ClassCalendarDialog.vue
@@ -116,7 +116,7 @@
-
+
@@ -165,7 +165,7 @@
学时学分
-
+
@@ -233,7 +233,7 @@
教研室计划
-
+
@@ -418,7 +418,11 @@ export default {
editMode: 'add',
editForm: this.createEmptyEditForm(),
editRules: {
- jc: [{ required: true, message: '请输入课程简称', trigger: 'blur' }]
+ jc: [{ required: true, message: '请输入课程简称', trigger: 'blur' }],
+ // 以下三列在库中「非空且无默认值」,留空会写入无意义值,这里前置拦截
+ xs: [{ required: true, message: '请输入学时', trigger: 'blur' }],
+ klx: [{ required: true, message: '请输入课类型', trigger: 'blur' }],
+ jysdh: [{ required: true, message: '请输入教研室代号', trigger: 'blur' }]
},
// ==================== 复制到其它班次 ====================
@@ -427,6 +431,7 @@ export default {
copyNote: '',
clipboardCount: 0,
planMap: {},
+ planByKbh: {},
libraryVisible: false,
libraryKeyword: '',
libraryRows: [],
@@ -532,19 +537,31 @@ export default {
]).then(([taskRes, planRes]) => {
this.records = (taskRes && taskRes.data) || []
const plans = (planRes && planRes.data) || []
- const map = {}
+ // 同一课编号可能出现在人培的多个学期第次里,按「课编号@学期第次」精确配对,
+ // 再退化为只按课编号配对,避免拿别的学期的人培行来判「人培差异」。
+ const exact = {}
+ const byKbh = {}
plans.forEach(item => {
- if (item.kbh) map[item.kbh] = item
+ if (!item.kbh) return
+ if (item.xqdc !== undefined && item.xqdc !== null && item.xqdc !== '') {
+ exact[item.kbh + '@' + item.xqdc] = item
+ }
+ if (!byKbh[item.kbh]) byKbh[item.kbh] = item
})
- this.planMap = map
+ this.planMap = exact
+ this.planByKbh = byKbh
this.loading = false
}).catch(() => {
this.records = []
this.loading = false
})
},
+ findPlan(row) {
+ if (!row || !row.kbh) return null
+ return this.planMap[row.kbh + '@' + row.xqdc] || this.planByKbh[row.kbh] || null
+ },
isPlanMismatch(row) {
- const plan = row && row.kbh ? this.planMap[row.kbh] : null
+ const plan = this.findPlan(row)
if (!plan) return false
return String(row.xs || '') !== String(plan.xs || '')
|| String(row.klx || '') !== String(plan.klx || '')
@@ -674,7 +691,7 @@ export default {
/* ---------- 自动生成必修课程 ---------- */
handleAutoGenerate() {
const s = this.semester || {}
- if (!s.xydbh || !s.xqdc) {
+ if (!s.bh || !s.xydbh || !s.xqdc) {
this.$message.warning('当前班次学期缺少学员队编号或学期第次,无法自动生成')
return
}
@@ -683,7 +700,9 @@ export default {
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
- autoGenerateRequiredCourses(s.xydbh, s.xqdc).then(res => {
+ // 传班次学期编号:同一学员队在多个年度可能有学期第次相同的班次学期,
+ // 只用 (学员队编号, 学期第次) 会写进别的学年。
+ autoGenerateRequiredCourses(s.bh).then(res => {
const count = (res && res.data) || 0
this.$message.success(`已自动生成 ${count} 门必修课程`)
this.loadData()
diff --git a/frontend/src/views/teachOffice/shiftSemester/TeachingAllocationDialog.vue b/frontend/src/views/teachOffice/shiftSemester/TeachingAllocationDialog.vue
new file mode 100644
index 00000000..86625102
--- /dev/null
+++ b/frontend/src/views/teachOffice/shiftSemester/TeachingAllocationDialog.vue
@@ -0,0 +1,452 @@
+
+ $emit('update:visible', val)"
+ >
+
+
+
+
+ 学期:{{ view.kxrq }} ~ {{ view.jsrq }}
+ 共 {{ view.totalWeeks }} 周
+
+ 学期代号:
+ {{ view.ndCode }}
+
+
+ 教学任务已发布/已结束,配当只读
+
+
+
+
+
+
+
+
+ | 周次 |
+
+ W{{ w.weekNo }}
+ {{ shortDate(w.startDate) }}
+ |
+
+
+ | 可排正课 |
+
+ {{ w.availableHours }}h
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ handleCheckAll(val)" />
+ |
+ 序 |
+ 课程 |
+ 学时 |
+ 周课时 |
+ 序数 |
+ 起周 |
+ 连排/按周 |
+ 编组 |
+ 操作 |
+
+ {{ w.weekNo }}
+ |
+
+
+
+
+ |
+
+ |
+ {{ row.pdxh || '-' }} |
+ {{ row.kcmc }} |
+ {{ row.xs }} |
+ {{ row.zks }} |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+ 保存
+ 整组
+ ↑
+ ↓
+ 编组
+ |
+
+ {{ cellHours(row, w.weekNo) || '' }}
+ |
+
+
+ |
+ 该班次学期暂无课程任务,请先在「班次教学任务」中生成/添加课程。
+ |
+
+
+
+
+
+
+
+
+ 同科目全部开课班次;勾选后统一改编组号(同组同开同结)。
+ groupSelection = val">
+
+
+
+
+
+
+
+ {{ row.pdaz === 0 ? '连排' : '按周' }}
+
+
+
+
+ 目标编组号:
+
+ 应用
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/teachOffice/shiftSemester/index.vue b/frontend/src/views/teachOffice/shiftSemester/index.vue
index 190d81d9..134bd0f5 100644
--- a/frontend/src/views/teachOffice/shiftSemester/index.vue
+++ b/frontend/src/views/teachOffice/shiftSemester/index.vue
@@ -63,6 +63,7 @@
编辑班历
编辑班次教学任务
+ 教学配当
@@ -170,6 +171,12 @@
@update:visible="val => eventCalendarVisible = val"
/>
+ allocationVisible = val"
+ />
+
-
+
详情
编辑
+ 任务书填报
关闭
+
+
+
@@ -207,9 +216,11 @@ import {
endPublishTeachingTask
} from '@/api/teachBusiness/teachingTask'
import { listAllSemester } from '@/api/teachBusiness/semester'
+import TaskBookFillDialog from './TaskBookFillDialog.vue'
export default {
name: 'TaskPlan',
+ components: { TaskBookFillDialog },
data() {
return {
// ==================== 1. 查询条件 ====================
@@ -252,7 +263,11 @@ export default {
// ==================== 4. 详情 ====================
detailVisible: false,
detailLoading: false,
- detailData: {}
+ detailData: {},
+
+ // ==================== 5. 任务书填报(阶段 4) ====================
+ taskBookVisible: false,
+ taskBookRow: {}
}
},
created() {
@@ -441,6 +456,11 @@ export default {
isTaskPublished(row) {
return row && (row.zt === '发布' || row.zt === '已发布')
},
+ /* ---------- 任务书填报(阶段 4) ---------- */
+ handleTaskBook(row) {
+ this.taskBookRow = row || {}
+ this.taskBookVisible = true
+ },
isTaskEnded(row) {
return row && row.zt === '已结束'
},
diff --git a/frontend/vue.config.js b/frontend/vue.config.js
index c1ec8752..d0df932b 100644
--- a/frontend/vue.config.js
+++ b/frontend/vue.config.js
@@ -9,7 +9,7 @@ const CompressionPlugin = require('compression-webpack-plugin')
const name = process.env.VUE_APP_TITLE || '教学管理信息系统' // 网页标题
-const baseUrl = 'http://10.1.1.193:8080' // 后端接口
+const baseUrl = 'http://127.0.0.1:8080' // 后端接口
const port = 80 // 固定开发服务器端口,避免多实例端口混乱
|