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> arrange(@RequestBody SchedulingArrangeRequest request) { + return Result.success(schedulingWindowService.arrange(request)); + } + + /** + * 删除所选节次:仅删除该课程在这些格上的课次;已提交实施计划的课次拒绝。 + */ + @PostMapping("/deleteCells") + public Result deleteCells(@RequestBody SchedulingCellOpRequest request) { + return Result.success(schedulingWindowService.deleteCells(request)); + } + + /** + * 删除课程全部节次(课程仍留在列表)。 + */ + @PostMapping("/clearCourse") + public Result clearCourse(@RequestParam("sskcbh") String sskcbh) { + return Result.success(schedulingWindowService.clearCourse(sskcbh)); + } + + /** + * 彻底删除运行课程:课次与列表项一起删,编制侧任务保留(教员不可用)。 + */ + @PostMapping("/deleteCourse") + public Result deleteCourse(@RequestParam("sskcbh") String sskcbh) { + return Result.success(schedulingWindowService.deleteCourse(sskcbh)); + } + + /** + * 排课日志查询:按课程(可叠加操作类型)。 + */ + @GetMapping("/logs") + public Result>> logs(@RequestParam("sskcbh") String sskcbh, + @RequestParam(value = "czlx", required = false) String czlx) { + return Result.success(schedulingWindowService.logs(sskcbh, czlx)); + } +} diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/StudentTeamTaskController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/StudentTeamTaskController.java index 8094fc33..ca1db6d5 100644 --- a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/StudentTeamTaskController.java +++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/StudentTeamTaskController.java @@ -124,21 +124,19 @@ public class StudentTeamTaskController { /** * 自动生成必修课程 *

- * 根据学员队编号从学员队表获取专业代号, - * 再根据专业代号和学期第次查询专业教学计划表, - * 为当前班次自动生成必修课程,插入到学员队任务表。 + * 按学员队学期编号定位目标班次学期,由它反查学员队编号、学期第次与年度, + * 再按专业代号 + 学期第次查专业教学计划表补齐必修课程。 * 已存在的课程(按课编号判断)不会重复添加。 *

* - * @param xydbh 学员队编号 - * @param xqdc 学期第次 + * @param xydxqbh 学员队学期编号 * @return 新增数量 */ @PostMapping("/autoGenerateRequiredCourses") - public Result autoGenerateRequiredCourses( - @RequestParam("xydbh") String xydbh, - @RequestParam("xqdc") Integer xqdc) { - int count = studentTeamTaskService.autoGenerateRequiredCourses(xydbh, xqdc); + public Result autoGenerateRequiredCourses(@RequestParam("xydxqbh") String xydxqbh) { + // 必须按「班次学期编号」定位:同一学员队在多个年度可能有学期第次相同的班次学期, + // 只传 (学员队编号, 学期第次) 无法区分学年,会把课程任务写进别的年度。 + int count = studentTeamTaskService.autoGenerateRequiredCourses(xydxqbh); return Result.success(count); } diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TaskBookFillController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TaskBookFillController.java new file mode 100644 index 00000000..129906d1 --- /dev/null +++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TaskBookFillController.java @@ -0,0 +1,89 @@ +package com.roomroot.web.controller.jwgl; + +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.service.TaskBookFillService; +import com.roomroot.jwgl.unit.Result; +import com.roomroot.jwgl.vo.taskbook.TaskBookRowVO; +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(阶段 4)。 + * + *

把已生成的课程任务确认成「可以排课的运行对象」:合班、责任教员、默认场地。

+ */ +@RestController +@RequestMapping("/taskBookFill") +public class TaskBookFillController { + + @Autowired + private TaskBookFillService taskBookFillService; + + /** + * 填报列表:某教学任务下全部课程任务行(排序 课程→责任教员→课次,含合班分组号)。 + */ + @GetMapping("/list") + public Result> list(@RequestParam("jxrwbh") String jxrwbh) { + return Result.success(taskBookFillService.list(jxrwbh)); + } + + /** + * 手动合班:所选行写入同一编组;科目/学时/课类型/成绩分制不同则拒绝并说明。 + */ + @PostMapping("/merge") + public Result merge(@RequestBody TaskBookBhListRequest request) { + return Result.success(taskBookFillService.merge(request)); + } + + /** + * 按预设合班:按课程科目分组,组内按预设编班号写入同一编组。 + */ + @PostMapping("/mergeByPreset") + public Result>> mergeByPreset(@RequestBody TaskBookBhListRequest request) { + return Result.success(taskBookFillService.mergeByPreset(request)); + } + + /** + * 拆班:所选行编组置 0。 + */ + @PostMapping("/split") + public Result split(@RequestBody TaskBookBhListRequest request) { + return Result.success(taskBookFillService.split(request)); + } + + /** + * 责任教员指定:mode = unit(责任单位教员)/ academy(全院教员)/ plan(应用教研室计划教员)。 + * 同合班组其它行同步。 + */ + @PostMapping("/setTeacher") + public Result setTeacher(@RequestBody TaskBookTeacherRequest request) { + return Result.success(taskBookFillService.setTeacher(request)); + } + + /** + * 场地指定:useSpecial=true 使用班次专用教室;否则按 jsbh 指定其它教室。同合班组同步。 + */ + @PostMapping("/setRoom") + public Result setRoom(@RequestBody TaskBookRoomRequest request) { + return Result.success(taskBookFillService.setRoom(request)); + } + + /** + * 填报字段:计划教员 / 场地(同合班同步)/ 排课建议。 + */ + @PostMapping("/fill") + public Result fill(@RequestBody TaskBookFillRequest request) { + return Result.success(taskBookFillService.fill(request)); + } +} diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeacherCalendarController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeacherCalendarController.java new file mode 100644 index 00000000..3c98e90a --- /dev/null +++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeacherCalendarController.java @@ -0,0 +1,73 @@ +package com.roomroot.web.controller.jwgl; + +import com.roomroot.jwgl.entity.JYL; +import com.roomroot.jwgl.service.JYLService; +import com.roomroot.jwgl.unit.PageQuery; +import com.roomroot.jwgl.unit.PageResult; +import com.roomroot.jwgl.unit.Result; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 教员历控制器(阶段 5.1)。排课冲突同时读取场地历与教员历。 + */ +@RestController +@RequestMapping("/teacherCalendar/jyl") +public class TeacherCalendarController { + + @Resource + private JYLService jylService; + + /** 新增教员历 */ + @PostMapping("/add") + public Result add(@RequestBody JYL jyl) { + jylService.add(jyl); + return Result.success(); + } + + /** 删除教员历(软删) */ + @PostMapping("/delete") + public Result delete(@RequestParam("bh") String bh) { + jylService.delete(bh); + return Result.success(); + } + + /** 更新教员历 */ + @PostMapping("/update") + public Result update(@RequestBody JYL jyl) { + jylService.update(jyl); + return Result.success(); + } + + /** 根据编号查询 */ + @GetMapping("/get") + public Result getById(@RequestParam("bh") String bh) { + return Result.success(jylService.getById(bh)); + } + + /** 分页查询 */ + @GetMapping("/list") + public Result> list(PageQuery query, JYL jyl) { + return Result.success(jylService.pageList(query, jyl)); + } + + /** 查询全部 */ + @GetMapping("/all") + public Result> all(JYL jyl) { + return Result.success(jylService.list(jyl)); + } + + /** 按教员编号查询 */ + @GetMapping("/listByJybh") + public Result> listByJybh(@RequestParam("jybh") String jybh) { + return Result.success(jylService.listByJybh(jybh)); + } + + /** 按日期查询 */ + @GetMapping("/listByRq") + public Result> listByRq(@RequestParam("rq") String rq) { + return Result.success(jylService.listByRq(rq)); + } +} diff --git a/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeachingAllocationController.java b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeachingAllocationController.java new file mode 100644 index 00000000..1742d957 --- /dev/null +++ b/backend/roomroot-admin/src/main/java/com/roomroot/web/controller/jwgl/TeachingAllocationController.java @@ -0,0 +1,82 @@ +package com.roomroot.web.controller.jwgl; + +import com.roomroot.jwgl.unit.Result; +import com.roomroot.jwgl.dto.allocation.AllocationGroupRequest; +import com.roomroot.jwgl.dto.allocation.AllocationSaveItem; +import com.roomroot.jwgl.service.TeachingAllocationService; +import com.roomroot.jwgl.vo.allocation.AllocationViewVO; +import jakarta.servlet.http.HttpServletResponse; +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.Arrays; +import java.util.List; +import java.util.Map; + +/** + * 教学配当 Controller(阶段 3)。 + * + *

配当只做排课粗约束(周学时分布、同开同结),不直接写实施课程表。

+ */ +@RestController +@RequestMapping("/teachingAllocation") +public class TeachingAllocationController { + + @Autowired + private TeachingAllocationService teachingAllocationService; + + /** + * 配当编辑器视图:周轴(每周可排正课)+ 任务行(含铺学时建议)。 + */ + @GetMapping("/view") + public Result view(@RequestParam("xydxqbh") String xydxqbh) { + return Result.success(teachingAllocationService.view(xydxqbh)); + } + + /** + * 保存配当:单条(列表 1 个元素)或多选批量;元素 saveGroup=true 时整编组同开同结。 + */ + @PostMapping("/save") + public Result save(@RequestBody List items) { + return Result.success(teachingAllocationService.save(items)); + } + + /** + * 优选序数上移/下移(direction: up/down),返回归一化后的任务编号顺序。 + */ + @PostMapping("/move") + public Result> move(@RequestParam("xydxqbh") String xydxqbh, + @RequestParam("bh") String bh, + @RequestParam("direction") String direction) { + return Result.success(teachingAllocationService.move(xydxqbh, bh, direction)); + } + + /** + * 编组管理面板:同科目全部开课班次。 + */ + @GetMapping("/groups") + public Result>> groups(@RequestParam("kbh") String kbh) { + return Result.success(teachingAllocationService.groups(kbh)); + } + + /** + * 编组管理:把所选任务统一改编组号。 + */ + @PostMapping("/setGroup") + public Result setGroup(@RequestBody AllocationGroupRequest request) { + return Result.success(teachingAllocationService.setGroup(request)); + } + + /** + * 导出配当 Excel(支持批量:xydxqbh 逗号分隔)。 + */ + @GetMapping("/export") + public void export(@RequestParam("xydxqbh") String xydxqbh, HttpServletResponse response) throws Exception { + teachingAllocationService.exportExcel(Arrays.asList(xydxqbh.split(",")), response); + } +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationGroupRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationGroupRequest.java new file mode 100644 index 00000000..1aa7f0e8 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationGroupRequest.java @@ -0,0 +1,18 @@ +package com.roomroot.jwgl.dto.allocation; + +import lombok.Data; + +import java.util.List; + +/** + * 教学配当 - 编组管理改编组号请求。 + */ +@Data +public class AllocationGroupRequest { + + /** 任务编号列表(必填) */ + private List bhList; + + /** 目标配档编组号(必填,>=0) */ + private Integer pdbz; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationSaveItem.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationSaveItem.java new file mode 100644 index 00000000..cab2b222 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/allocation/AllocationSaveItem.java @@ -0,0 +1,34 @@ +package com.roomroot.jwgl.dto.allocation; + +import lombok.Data; + +/** + * 教学配当 - 单条保存项。 + * + *

字段为 null 表示不修改。{@code saveGroup=true} 时按「同科目 + 同配档编组 + 同学期代号」 + * 把同组各班次任务一起改(同开同结),限定在同一学期代号内防止跨学期串改。

+ */ +@Data +public class AllocationSaveItem { + + /** 任务编号(必填) */ + private String bh; + + /** 配档序号(优选序数) */ + private Integer pdxh; + + /** 配档起始周 */ + private Integer pdqsz; + + /** 配档按周:0=连排 1=按周 */ + private Integer pdaz; + + /** 配档占正课时间 */ + private Integer pdzzksj; + + /** 配档编组 */ + private Integer pdbz; + + /** 是否保存整个编组 */ + private Boolean saveGroup; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingArrangeRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingArrangeRequest.java new file mode 100644 index 00000000..09e5e0ff --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingArrangeRequest.java @@ -0,0 +1,55 @@ +package com.roomroot.jwgl.dto.scheduling; + +import lombok.Data; + +import java.util.List; + +/** + * 安排所选节次请求(阶段 5.3)。 + */ +@Data +public class SchedulingArrangeRequest { + + private String xydxqbh; + + /** 实施_课程编号 */ + private String sskcbh; + + /** 教学内容(课题) */ + private String jxnr; + + /** 教学要点 */ + private String jxyd; + + /** 教学方法,默认 理论讲授 */ + private String jxff; + + /** 节次调节:0 默认(一节 2 学时)/ -1 / +1 */ + private Integer jcdj; + + /** 教学保障备注 */ + private String jxbzbz; + + /** 用车信息 */ + private String ycxx; + + /** 班组模式:合班全部 / 分组等 */ + private String bzms; + + /** 场地(教室编号),缺省用课程的默认场地 */ + private String jsbh; + + /** 主讲教员,缺省用课程责任教员 */ + private String jybh; + + /** 所选格子 */ + private List cells; + + @Data + public static class Cell { + /** 日期 yyyy-MM-dd */ + private String rq; + /** 节次 */ + private Integer jc; + } +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingCellOpRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingCellOpRequest.java new file mode 100644 index 00000000..bbf1eb3e --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/scheduling/SchedulingCellOpRequest.java @@ -0,0 +1,17 @@ +package com.roomroot.jwgl.dto.scheduling; + +import lombok.Data; + +import java.util.List; + +/** + * 删除所选节次请求(阶段 5.3)。仅删除该课程在这些格上的课次。 + */ +@Data +public class SchedulingCellOpRequest { + + /** 实施_课程编号 */ + private String sskcbh; + + private List cells; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookBhListRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookBhListRequest.java new file mode 100644 index 00000000..c6929553 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookBhListRequest.java @@ -0,0 +1,15 @@ +package com.roomroot.jwgl.dto.taskbook; + +import lombok.Data; + +import java.util.List; + +/** + * 任务书批量操作请求(合班 / 按预设合班 / 拆班)。 + */ +@Data +public class TaskBookBhListRequest { + + /** 课程任务编号列表 */ + private List bhList; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookFillRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookFillRequest.java new file mode 100644 index 00000000..813499a3 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookFillRequest.java @@ -0,0 +1,22 @@ +package com.roomroot.jwgl.dto.taskbook; + +import lombok.Data; + +/** + * 教研室填报字段请求(阶段 4:计划教员 / 场地 / 排课建议)。 + */ +@Data +public class TaskBookFillRequest { + + /** 课程任务编号 */ + private String bh; + + /** 教研室计划教员编号 */ + private String jysjhjybh; + + /** 场地(教室编号;非空时同合班行同步) */ + private String jsbh; + + /** 排课建议(教研室计划备注) */ + private String jysjhbz; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookRoomRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookRoomRequest.java new file mode 100644 index 00000000..e95c555b --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookRoomRequest.java @@ -0,0 +1,19 @@ +package com.roomroot.jwgl.dto.taskbook; + +import lombok.Data; + +/** + * 场地指定请求(阶段 4)。 + */ +@Data +public class TaskBookRoomRequest { + + /** 课程任务编号 */ + private String bh; + + /** 教室编号(useSpecial=false 时必传) */ + private String jsbh; + + /** true=使用班次专用教室(忽略 jsbh,取班次学期.专用教室编号) */ + private Boolean useSpecial; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookTeacherRequest.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookTeacherRequest.java new file mode 100644 index 00000000..6e62934e --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/dto/taskbook/TaskBookTeacherRequest.java @@ -0,0 +1,24 @@ +package com.roomroot.jwgl.dto.taskbook; + +import lombok.Data; + +/** + * 责任教员指定请求(阶段 4:三种指定方式)。 + */ +@Data +public class TaskBookTeacherRequest { + + /** 课程任务编号 */ + private String bh; + + /** + * 指定方式: + * unit — 责任单位教员(教员必须属于该行教研室 jysdh); + * academy — 全院教员(任意在职教员); + * plan — 应用教研室计划教员(jybh = 该行 jysjhjybh)。 + */ + private String mode; + + /** 责任教员编号(mode=unit / academy 时必传) */ + private String jybh; +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/entity/SSKCXYD.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/entity/SSKCXYD.java index 9c420a43..33671408 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/entity/SSKCXYD.java +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/entity/SSKCXYD.java @@ -1,5 +1,6 @@ package com.roomroot.jwgl.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.roomroot.jwgl.unit.BaseEntity; import lombok.Data; @@ -7,6 +8,9 @@ import lombok.EqualsAndHashCode; /** * 实施_课程学员队 + * + *

阶段 5 修复:原实体缺少中文列注解,LambdaQueryWrapper 会生成错误的英文列名 + * (如 SSKCBH),导致 isScheduled 等查询运行时报 Invalid column。

*/ @Data @EqualsAndHashCode(callSuper = true) @@ -16,46 +20,55 @@ public class SSKCXYD extends BaseEntity { /** * 编号 */ + @TableField("编号") private String bh; /** * 实施_课程编号 */ + @TableField("实施_课程编号") private String sskcbh; /** * 学员队编号 */ + @TableField("学员队编号") private String xydbh; /** * 简称 */ + @TableField("简称") private String jc; /** * 人数 */ + @TableField("人数") private Integer rs; /** * 年度 */ + @TableField("年度") private Integer nd; /** * 学期第次 */ + @TableField("学期第次") private Integer xqdc; /** * 学分 */ + @TableField("学分") private Float xf; /** * 学员_课程板块测评编号 */ + @TableField("学员_课程板块测评编号") private String xykcbkcpbh; } diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/ElectiveCourseMapper.xml b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/ElectiveCourseMapper.xml index 86bacaec..982f110b 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/ElectiveCourseMapper.xml +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/ElectiveCourseMapper.xml @@ -90,7 +90,8 @@ FROM "学员队年度学期基本信息表" GROUP BY "学员队编号", "年度", "学期第次" ) xq ON xq."学员队编号" = t."学员队编号" - AND xq."年度" = t."年度" + AND (CASE WHEN xq."年度" >= 100000 THEN xq."年度" + ELSE xq."年度" * 100 + xq."学期第次" END) = t."年度" AND xq."学期第次" = t."学期第次" LEFT JOIN ( SELECT "科目编号", "教员编号", "年度", diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/JYLMapper.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/JYLMapper.java new file mode 100644 index 00000000..5511f814 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/JYLMapper.java @@ -0,0 +1,12 @@ +package com.roomroot.jwgl.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.roomroot.jwgl.entity.JYL; +import org.apache.ibatis.annotations.Mapper; + +/** + * 教员历 Mapper + */ +@Mapper +public interface JYLMapper extends BaseMapper { +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.java index b7167389..ce3bb40a 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.java +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.java @@ -8,6 +8,7 @@ import com.roomroot.jwgl.vo.jys.TeachingOfficeBookCourseVO; import com.roomroot.jwgl.vo.jys.TeachingOfficeBookVO; import com.roomroot.jwgl.vo.jys.TeachingTaskSquadVO; import com.roomroot.jwgl.vo.jys.TeachingTaskVO; +import com.roomroot.jwgl.vo.taskbook.TaskBookRowVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -54,4 +55,10 @@ public interface TeachingTaskMapper extends BaseMapper { */ List selectOfficeBookCourses(@Param("jxrwbh") String jxrwbh, @Param("jysdh") String jysdh); + + /** + * 教研室任务书填报列表(阶段 4):某教学任务下全部课程任务行, + * 排序 课程 → 责任教员 → 课次,含合班分组号(编组)与教员/场地/教研室联查。 + */ + List selectTaskBookRows(@Param("jxrwbh") String jxrwbh); } diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.xml b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.xml index 760a49c5..04f3f7e2 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.xml +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/mapper/TeachingTaskMapper.xml @@ -228,4 +228,41 @@ ORDER BY d."学员队名称", k."课名称" + + \ No newline at end of file diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/JYLService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/JYLService.java new file mode 100644 index 00000000..809f5a32 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/JYLService.java @@ -0,0 +1,29 @@ +package com.roomroot.jwgl.service; + +import com.roomroot.jwgl.entity.JYL; +import com.roomroot.jwgl.unit.PageQuery; +import com.roomroot.jwgl.unit.PageResult; + +import java.util.List; + +/** + * 教员历 Service(阶段 5.1)。排课冲突除教员双占外,同时读教员历不可排课格。 + */ +public interface JYLService { + + void add(JYL row); + + void delete(String bh); + + void update(JYL row); + + JYL getById(String bh); + + PageResult pageList(PageQuery query, JYL condition); + + List list(JYL condition); + + List listByJybh(String jybh); + + List listByRq(String rq); +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/RunningCoursePublishService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/RunningCoursePublishService.java new file mode 100644 index 00000000..f1ea0cc1 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/RunningCoursePublishService.java @@ -0,0 +1,25 @@ +package com.roomroot.jwgl.service; + +/** + * 运行课表批量发布/撤回(阶段 5.2)。 + * + *

发布规则:班次下全部课程 责任教员、默认场地均已指定,否则整批失败并列出缺项; + * 只写 实施_课程 和 实施_课程学员队,不插 实施_课程表。 + * 停用 convertToLessons 作为发布路径(不生成空壳课次)。

+ */ +public interface RunningCoursePublishService { + + /** + * 批量发布某班次学期的全部课程任务为运行课程。 + * + * @return 发布的 实施_课程 条数 + */ + int publish(String xydxqbh); + + /** + * 撤回:删除该班次已发布的 实施_课程 及其课次、教室、教员关联。 + * + * @return 删除的 实施_课程 条数 + */ + int withdraw(String xydxqbh); +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/SchedulingWindowService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/SchedulingWindowService.java new file mode 100644 index 00000000..d87f06b5 --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/SchedulingWindowService.java @@ -0,0 +1,39 @@ +package com.roomroot.jwgl.service; + +import com.roomroot.jwgl.dto.scheduling.SchedulingArrangeRequest; +import com.roomroot.jwgl.dto.scheduling.SchedulingCellOpRequest; +import com.roomroot.jwgl.vo.scheduling.SchedulingViewVO; + +import java.util.List; +import java.util.Map; + +/** + * 实施排课窗口(阶段 5.3)。 + * + *

组合查询 + 写操作:安排所选节次 / 删除所选节次 / 删除课程全部节次 / 彻底删除运行课程。 + * 硬冲突格忽略(双占、三类历不可排);软提示(配当周次不符、非正课)随返回值给出。

+ */ +public interface SchedulingWindowService { + + /** 排课窗组合视图:班次头 + 课程列表(排满标绿)+ 周次×星期×节次格子 */ + SchedulingViewVO view(String xydxqbh); + + /** + * 安排所选节次。硬冲突格忽略并列出,软提示格照常安排并给出提示。 + * + * @return {arranged: 安排格数, ignored: [{rq,jc,reason}], warnings: [{rq,jc,reason}]} + */ + Map arrange(SchedulingArrangeRequest request); + + /** 删除所选节次:仅删除该课程在这些格上的课次 */ + int deleteCells(SchedulingCellOpRequest request); + + /** 删除课程全部节次(课程仍留在列表) */ + int clearCourse(String sskcbh); + + /** 彻底删除运行课程:课次与列表项一起删,编制侧任务保留 */ + int deleteCourse(String sskcbh); + + /** 排课日志查询:按课程(可叠加操作类型) */ + List> logs(String sskcbh, String czlx); +} diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/StudentTeamTaskService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/StudentTeamTaskService.java index 97349cd1..31bf7fab 100644 --- a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/StudentTeamTaskService.java +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/StudentTeamTaskService.java @@ -92,17 +92,19 @@ public interface StudentTeamTaskService { /** * 自动生成必修课程 *

- * 根据学员队编号从学员队表获取专业代号, - * 再根据专业代号和学期第次查询专业教学计划表, - * 为当前班次自动生成必修课程,插入到学员队任务表。 + * 按「学员队学期编号」定位唯一的目标班次学期,由该班次学期反查学员队编号与学期第次, + * 再按专业代号 + 学期第次查专业教学计划表补齐必修课程。 * 已存在的课程(按课编号判断)不会重复添加。 *

+ *

+ * 不再用「学员队编号 + 学期第次」定位:同一学员队在多个年度会有学期第次相同的班次学期 + * (例如 2025 第3学期 与 2026 第3学期),按 (学员队, 学期第次) 查找会写错学年。 + *

* - * @param xydbh 学员队编号 - * @param xqdc 学期第次 + * @param xydxqbh 学员队学期编号(班次学期主键) * @return 新增数量 */ - int autoGenerateRequiredCourses(String xydbh, Integer xqdc); + int autoGenerateRequiredCourses(String xydxqbh); /** * 批量复制课程 diff --git a/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TaskBookFillService.java b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TaskBookFillService.java new file mode 100644 index 00000000..40ff4c2b --- /dev/null +++ b/backend/roomroot-jwgl/src/main/java/com/roomroot/jwgl/service/TaskBookFillService.java @@ -0,0 +1,77 @@ +package com.roomroot.jwgl.service; + +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.vo.taskbook.TaskBookRowVO; + +import java.util.List; +import java.util.Map; + +/** + * 教研室任务书填报(阶段 4)。 + * + *

把已生成的课程任务确认成「可以排课的运行对象」:合班、责任教员、默认场地。

+ * + *

口径约定: + *

    + *
  • 合班分组号用已有列 学员队任务表.编组(bz2),0=未合班;不复用 预设编班号 和 课次序号。
  • + *
  • 发布门禁:仅「已发布且未结束」的教学任务可填报;结束后只读(TeachingTaskWriteGuard)。
  • + *
  • 责任教员 / 场地修改时,同合班组(同编组,非 0)的行同步。
  • + *
  • 已发布到运行课表的行须先撤回才能拆班(运行课表属阶段 5,当前先拦截有排课数据的行)。
  • + *

+ */ +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 @@ + + + + + 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" /> + + - + @@ -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 // 固定开发服务器端口,避免多实例端口混乱