通过AI比对原操作文档,生成系统功能完善方案,根据方案进行增补,完善从学期新增到最终的排课功能

This commit is contained in:
2026-09-15 14:42:43 +08:00
parent 5187435285
commit e8a58b19fe
54 changed files with 4577 additions and 134 deletions
@@ -0,0 +1,18 @@
package com.roomroot.jwgl.dto.allocation;
import lombok.Data;
import java.util.List;
/**
* 教学配当 - 编组管理改编组号请求。
*/
@Data
public class AllocationGroupRequest {
/** 任务编号列表(必填) */
private List<String> bhList;
/** 目标配档编组号(必填,&gt;=0 */
private Integer pdbz;
}
@@ -0,0 +1,34 @@
package com.roomroot.jwgl.dto.allocation;
import lombok.Data;
/**
* 教学配当 - 单条保存项。
*
* <p>字段为 null 表示不修改。{@code saveGroup=true} 时按「同科目 + 同配档编组 + 同学期代号」
* 把同组各班次任务一起改(同开同结),限定在同一学期代号内防止跨学期串改。</p>
*/
@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;
}
@@ -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<Cell> cells;
@Data
public static class Cell {
/** 日期 yyyy-MM-dd */
private String rq;
/** 节次 */
private Integer jc;
}
}
@@ -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<SchedulingArrangeRequest.Cell> cells;
}
@@ -0,0 +1,15 @@
package com.roomroot.jwgl.dto.taskbook;
import lombok.Data;
import java.util.List;
/**
* 任务书批量操作请求(合班 / 按预设合班 / 拆班)。
*/
@Data
public class TaskBookBhListRequest {
/** 课程任务编号列表 */
private List<String> bhList;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
/**
* 实施_课程学员队
*
* <p>阶段 5 修复:原实体缺少中文列注解,LambdaQueryWrapper 会生成错误的英文列名
* (如 SSKCBH),导致 isScheduled 等查询运行时报 Invalid column。</p>
*/
@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;
}
@@ -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 "科目编号", "教员编号", "年度",
@@ -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<JYL> {
}
@@ -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<JXRW> {
*/
List<TeachingOfficeBookCourseVO> selectOfficeBookCourses(@Param("jxrwbh") String jxrwbh,
@Param("jysdh") String jysdh);
/**
* 教研室任务书填报列表(阶段 4):某教学任务下全部课程任务行,
* 排序 课程 → 责任教员 → 课次,含合班分组号(编组)与教员/场地/教研室联查。
*/
List<TaskBookRowVO> selectTaskBookRows(@Param("jxrwbh") String jxrwbh);
}
@@ -228,4 +228,41 @@
ORDER BY d."学员队名称", k."课名称"
</select>
<select id="selectTaskBookRows" resultType="com.roomroot.jwgl.vo.taskbook.TaskBookRowVO">
SELECT
r."编号" AS bh,
r."学员队学期编号" AS xydxqbh,
r."课编号" AS kbh,
NVL(NULLIF(r."简称", ''), k."课名称") AS kcmc,
r."课类型" AS klx,
r."学时" AS xs,
r."周课时" AS zks,
r."成绩分制" AS cjfz,
r."学员队编号" AS xydbh,
d."学员队名称" AS xydmc,
r."教员编号" AS jybh,
jy."教员姓名" AS jyxm,
r."教室编号" AS jsbh,
js."教室名称" AS jsmc,
r."课次序号" AS kcxh,
r."编组" AS bz2,
r."教研室代号" AS jysdh,
jys."教研室名称" AS jysmc,
r."教研室计划教员编号" AS jysjhjybh,
jy2."教员姓名" AS jysjhjyxm,
r."教研室计划备注" AS jysjhbz
FROM "学员队任务表" r
INNER JOIN "学员队年度学期基本信息表" s ON s."编号" = r."学员队学期编号"
LEFT JOIN "课表" k ON k."课编号" = r."课编号"
LEFT JOIN "学员队表" d ON d."学员队编号" = r."学员队编号"
LEFT JOIN "教员表" jy ON jy."教员编号" = r."教员编号"
LEFT JOIN "教员表" jy2 ON jy2."教员编号" = r."教研室计划教员编号"
LEFT JOIN "教室表" js ON js."教室编号" = r."教室编号"
LEFT JOIN "教研室表" jys ON jys."教研室代号" = r."教研室代号"
WHERE s."教学任务编号" = #{jxrwbh}
AND NVL(r."DEL_FLAG", 0) = 0
AND NVL(s."DEL_FLAG", 0) = 0
ORDER BY r."课编号", r."教员编号" NULLS LAST, r."课次序号" NULLS LAST, r."编号"
</select>
</mapper>
@@ -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<JYL> pageList(PageQuery query, JYL condition);
List<JYL> list(JYL condition);
List<JYL> listByJybh(String jybh);
List<JYL> listByRq(String rq);
}
@@ -0,0 +1,25 @@
package com.roomroot.jwgl.service;
/**
* 运行课表批量发布/撤回(阶段 5.2)。
*
* <p>发布规则:班次下全部课程 责任教员、默认场地均已指定,否则整批失败并列出缺项;
* 只写 实施_课程 和 实施_课程学员队,不插 实施_课程表。
* 停用 convertToLessons 作为发布路径(不生成空壳课次)。</p>
*/
public interface RunningCoursePublishService {
/**
* 批量发布某班次学期的全部课程任务为运行课程。
*
* @return 发布的 实施_课程 条数
*/
int publish(String xydxqbh);
/**
* 撤回:删除该班次已发布的 实施_课程 及其课次、教室、教员关联。
*
* @return 删除的 实施_课程 条数
*/
int withdraw(String xydxqbh);
}
@@ -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)。
*
* <p>组合查询 + 写操作:安排所选节次 / 删除所选节次 / 删除课程全部节次 / 彻底删除运行课程。
* 硬冲突格忽略(双占、三类历不可排);软提示(配当周次不符、非正课)随返回值给出。</p>
*/
public interface SchedulingWindowService {
/** 排课窗组合视图:班次头 + 课程列表(排满标绿)+ 周次×星期×节次格子 */
SchedulingViewVO view(String xydxqbh);
/**
* 安排所选节次。硬冲突格忽略并列出,软提示格照常安排并给出提示。
*
* @return {arranged: 安排格数, ignored: [{rq,jc,reason}], warnings: [{rq,jc,reason}]}
*/
Map<String, Object> arrange(SchedulingArrangeRequest request);
/** 删除所选节次:仅删除该课程在这些格上的课次 */
int deleteCells(SchedulingCellOpRequest request);
/** 删除课程全部节次(课程仍留在列表) */
int clearCourse(String sskcbh);
/** 彻底删除运行课程:课次与列表项一起删,编制侧任务保留 */
int deleteCourse(String sskcbh);
/** 排课日志查询:按课程(可叠加操作类型) */
List<Map<String, Object>> logs(String sskcbh, String czlx);
}
@@ -92,17 +92,19 @@ public interface StudentTeamTaskService {
/**
* 自动生成必修课程
* <p>
* 根据学员队编号从学员队表获取专业代号
* 再根据专业代号学期第次查专业教学计划表
* 为当前班次自动生成必修课程,插入到学员队任务表。
* 按「学员队学期编号」定位唯一的目标班次学期,由该班次学期反查学员队编号与学期第次
* 再专业代号 + 学期第次查专业教学计划表补齐必修课程。
* 已存在的课程(按课编号判断)不会重复添加。
* </p>
* <p>
* 不再用「学员队编号 + 学期第次」定位:同一学员队在多个年度会有学期第次相同的班次学期
* (例如 2025 第3学期 与 2026 第3学期),按 (学员队, 学期第次) 查找会写错学年。
* </p>
*
* @param xydbh 学员队编号
* @param xqdc 学期第次
* @param xydxqbh 学员队学期编号(班次学期主键)
* @return 新增数量
*/
int autoGenerateRequiredCourses(String xydbh, Integer xqdc);
int autoGenerateRequiredCourses(String xydxqbh);
/**
* 批量复制课程
@@ -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)。
*
* <p>把已生成的课程任务确认成「可以排课的运行对象」:合班、责任教员、默认场地。</p>
*
* <p>口径约定:
* <ul>
* <li>合班分组号用已有列 学员队任务表.编组(bz2),0=未合班;不复用 预设编班号 和 课次序号。</li>
* <li>发布门禁:仅「已发布且未结束」的教学任务可填报;结束后只读(TeachingTaskWriteGuard)。</li>
* <li>责任教员 / 场地修改时,同合班组(同编组,非 0)的行同步。</li>
* <li>已发布到运行课表的行须先撤回才能拆班(运行课表属阶段 5,当前先拦截有排课数据的行)。</li>
* </ul></p>
*/
public interface TaskBookFillService {
/**
* 任务书填报列表:某教学任务下全部课程任务行(排序 课程 → 责任教员 → 课次)。
*/
List<TaskBookRowVO> list(String jxrwbh);
/**
* 手动合班:所选行写入同一编组(新组号 = 现有最大编组 + 1)。
* 校验 科目、学时、课类型、成绩分制 相同,任一不同则拒绝并说明。
*
* @return 编组号
*/
Integer merge(TaskBookBhListRequest request);
/**
* 按预设合班:所选行按课程科目分组,组内按 班次学期.预设编班号 写入同一编组;
* 没有预设编班号的行各成一组(不与别人强并)。
*
* @return 每组合班结果:groupNo / kbh / ysbbh / 行数
*/
List<Map<String, Object>> mergeByPreset(TaskBookBhListRequest request);
/**
* 拆班:所选行编组置 0。
*
* @return 实际拆开行数
*/
int split(TaskBookBhListRequest request);
/**
* 责任教员指定(unit=责任单位教员 / academy=全院教员 / plan=应用教研室计划教员)。
* 该行编组非 0 时,同合班组其它行同步 jybh。
*
* @return 实际更新行数
*/
int setTeacher(TaskBookTeacherRequest request);
/**
* 场地指定:其它教室(useSpecial=falsejsbh 必传)或 班次专用教室(useSpecial=true)。
* 该行编组非 0 时,同合班组其它行同步 jsbh。
*
* @return 实际更新行数
*/
int setRoom(TaskBookRoomRequest request);
/**
* 填报字段:计划教员(jysjhjybh)、场地(jsbh,同合班同步)、排课建议(jysjhbz)。
*
* @return 实际更新行数
*/
int fill(TaskBookFillRequest request);
}
@@ -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)。
*
* <p>给出「哪一周上多少学时、谁和谁同开同结」,作为排课的粗约束;
* 不直接写实施课程表(SSKCB)日期节次。</p>
*/
public interface TeachingAllocationService {
/**
* 打开某班次学期的配当编辑器视图:周轴(每周可排正课)+ 任务行(含铺学时结果)。
*/
AllocationViewVO view(String xydxqbh);
/**
* 保存配档字段:单条(列表 1 个元素)或多选批量。
* 元素 saveGroup=true 时按「同科目+同配档编组+同学期代号」整组一起改(同开同结)。
*
* @return 实际更新的任务行数
*/
int save(List<AllocationSaveItem> items);
/**
* 优选序数上移/下移。先把该班次学期的配档序号按当前展示顺序归一化为 1..n,
* 再交换相邻两条。direction: up / down。
*
* @return 归一化后的任务编号顺序(供前端刷新)
*/
List<String> move(String xydxqbh, String bh, String direction);
/**
* 编组管理面板:同科目全部开课班次(跨班次学期)。
*/
List<Map<String, Object>> groups(String kbh);
/**
* 编组管理:把所选任务统一改编组号。
*
* @return 实际更新行数
*/
int setGroup(AllocationGroupRequest request);
/**
* 导出配当 Excel(批量:每行一个任务,含班次名称与学期代号列)。
*/
void exportExcel(List<String> xydxqbhList, HttpServletResponse response) throws Exception;
}
@@ -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) {
@@ -371,27 +371,17 @@ public class CourseRunningImportServiceImpl implements CourseRunningImportServic
// 步骤2:遍历课程列表,逐个处理导入
for (SSKC course : courseList) {
try {
// 步骤3:将课程转换为课次列表(根据总学时计算课次数)
List<SSKCB> 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());
}
}
@@ -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);
@@ -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));
}
/**
* 根据班次入学日期智能推断指定日期所在的学期第次(每半年为一个学期)
* <p>
* 半年以内的班次返回 1;半年以上的班次按 入学日期 到 指定日期 经过的月份
* 每 6 个月为 1 个学期递增,且不超过人才培养方案总学期数。
* </p>
*/
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);
}
/**
* 获取某班次指定学期第次的上一学期专用教室(用于第二以上学期的默认预设)
*
@@ -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<JYL> 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<JYL> 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<JYL> list(JYL condition) {
return jylMapper.selectList(buildWrapper(condition));
}
@Override
public List<JYL> listByJybh(String jybh) {
return jylMapper.selectList(new LambdaQueryWrapper<JYL>()
.eq(JYL::getJybh, jybh)
.eq(JYL::getDelFlag, 0)
.orderByAsc(JYL::getRq)
.orderByAsc(JYL::getJc));
}
@Override
public List<JYL> listByRq(String rq) {
return jylMapper.selectList(new LambdaQueryWrapper<JYL>()
.apply("DATE(rq) = {0}", rq)
.eq(JYL::getDelFlag, 0));
}
private LambdaQueryWrapper<JYL> buildWrapper(JYL condition) {
LambdaQueryWrapper<JYL> 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;
}
}
@@ -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)。
*
* <p>发布 = 班次学期的课程任务(学员队任务表)按 合班组(编组 bz2)聚合写成
* 实施_课程 + 实施_课程学员队;不生成 实施_课程表 空壳课次。
* 课次由阶段 5.3 排课窗逐格安排。</p>
*/
@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<XYDRWB> tasks = studentTeamTaskMapper.selectList(new LambdaQueryWrapper<XYDRWB>()
.eq(XYDRWB::getXydxqbh, semester.getBh())
.eq(XYDRWB::getDelFlag, 0));
if (tasks.isEmpty()) {
throw new ServiceException("该班次学期没有课程任务可发布", NOT_FOUND);
}
// 幂等:已有发布记录则拒绝
List<SSKCXYD> published = sskcxydMapper.selectList(new LambdaQueryWrapper<SSKCXYD>()
.eq(SSKCXYD::getXydbh, semester.getXydbh())
.eq(SSKCXYD::getNd, ndCode));
if (!published.isEmpty()) {
throw new ServiceException("该班次学期已发布 " + published.size() + " 条运行课程,请先撤回再重新发布", BAD_REQUEST);
}
// 整批校验:责任教员、默认场地非空,缺项全部列出
List<String> missing = new ArrayList<>();
for (XYDRWB task : tasks) {
List<String> 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<String, List<XYDRWB>> 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<XYDRWB> 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<SSKCXYD> links = sskcxydMapper.selectList(new LambdaQueryWrapper<SSKCXYD>()
.eq(SSKCXYD::getXydbh, semester.getXydbh())
.eq(SSKCXYD::getNd, ndCode));
if (links.isEmpty()) {
throw new ServiceException("该班次学期没有已发布的运行课程", NOT_FOUND);
}
List<String> sskcBhs = links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
int removed = 0;
for (String sskcbh : sskcBhs) {
// 删除该课程的课次及其教室/教员/学员队关联
List<SSKCB> lessons = sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.eq(SSKCB::getSskcbh, sskcbh));
for (SSKCB lesson : lessons) {
sskcbxydMapper.delete(new LambdaQueryWrapper<SSKCBXYD>()
.eq(SSKCBXYD::getSskcbbh, lesson.getBh()));
sskcbjsMapper.delete(new LambdaQueryWrapper<SSKCBJS>()
.eq(SSKCBJS::getSskcbbh, lesson.getBh()));
sskcbfzjyMapper.delete(new LambdaQueryWrapper<SSKCBFZJY>()
.eq(SSKCBFZJY::getSskcbbh, lesson.getBh()));
}
sskcbMapper.delete(new LambdaQueryWrapper<SSKCB>()
.eq(SSKCB::getSskcbh, sskcbh));
// TODO(业务确认):课次已有调课/请假记录时应先确认,当前直接随撤回删除
// 删除本班次的关联行
sskcxydMapper.delete(new LambdaQueryWrapper<SSKCXYD>()
.eq(SSKCXYD::getSskcbh, sskcbh)
.eq(SSKCXYD::getXydbh, semester.getXydbh())
.eq(SSKCXYD::getNd, ndCode));
// 合班课程可能仍被其它班次引用:仅当不再有任何关联时删除 实施_课程 本体
Long remain = sskcxydMapper.selectCount(new LambdaQueryWrapper<SSKCXYD>()
.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() : "-";
}
}
@@ -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)。
*
* <p>冲突口径:硬冲突(格忽略)= 教员/教室/班次双占、校历/班历/场地历/教员历不可排;
* 软提示(照常安排)= 非正课、配当周次不符。</p>
*/
@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<SSKCXYD> links = ctx.links;
List<String> sskcIds = links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
Map<String, SSKC> courses = sskcIds.isEmpty() ? Map.of()
: sskcMapper.selectBatchIds(sskcIds).stream().collect(Collectors.toMap(SSKC::getBh, c -> c, (a, b) -> a));
List<SSKCB> lessons = ctx.lessons;
Map<String, List<SSKCB>> 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<SSKCB> 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<String> schoolHard = new HashSet<>(); // 校历不可排 date#jc
Set<String> schoolSoft = new HashSet<>(); // 非正课
List<XQXLB> schoolRows = semesterCalendarMapper.selectList(new LambdaQueryWrapper<XQXLB>()
.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<String> classHard = new HashSet<>();
Set<String> classSoft = new HashSet<>();
List<JQB> classRows = jqbMapper.selectList(new LambdaQueryWrapper<JQB>()
.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<String, List<SSKCB>> lessonsByGrid = ctx.lessons.stream()
.filter(l -> l.getRq() != null && l.getJc() != null)
.collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
Map<String, String> courseNames = new HashMap<>();
Map<String, String> 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<String, Object> 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<SSKCB> semesterLessons = ctx.lessons;
Map<String, List<SSKCB>> byGrid = semesterLessons.stream()
.filter(l -> l.getRq() != null && l.getJc() != null)
.collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
// 课次 → 学员队 / 教室 / 教员(无占用课次时跳过,避免空 IN())
Map<String, List<String>> teamsOfLesson = new HashMap<>();
Map<String, List<String>> roomsOfLesson = new HashMap<>();
Map<String, List<String>> teachersOfLesson = new HashMap<>();
List<String> occupantIds = byGrid.values().stream().flatMap(List::stream)
.map(SSKCB::getBh).distinct().toList();
if (!occupantIds.isEmpty()) {
for (SSKCBXYD l : sskcbxydMapper.selectList(new LambdaQueryWrapper<SSKCBXYD>()
.in(SSKCBXYD::getSskcbbh, occupantIds))) {
teamsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getXydbh());
}
for (SSKCBJS l : sskcbjsMapper.selectList(new LambdaQueryWrapper<SSKCBJS>()
.in(SSKCBJS::getSskcbbh, occupantIds))) {
roomsOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getJsbh());
}
for (SSKCBFZJY l : sskcbfzjyMapper.selectList(new LambdaQueryWrapper<SSKCBFZJY>()
.in(SSKCBFZJY::getSskcbbh, occupantIds))) {
teachersOfLesson.computeIfAbsent(l.getSskcbbh(), k -> new ArrayList<>()).add(l.getFzjybh());
}
}
// 场地历 / 教员历 不可排
Map<String, String> roomBlocked = new HashMap<>();
for (JXCDL row : classRoomCalendarMapper.selectList(new LambdaQueryWrapper<JXCDL>()
.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<String, String> teacherBlocked = new HashMap<>();
for (JYL row : jylMapper.selectList(new LambdaQueryWrapper<JYL>()
.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<String> schoolHard = calendarHardKeys(ctx, true);
Set<String> classHard = calendarHardKeys(ctx, false);
// 配当周次(软提示):该课程配当分布覆盖的周
Set<Integer> allocatedWeeks = allocatedWeeks(ctx, course);
LocalDateTime now = LocalDateTime.now();
int arranged = 0;
List<Map<String, Object>> ignored = new ArrayList<>();
List<Map<String, Object>> warnings = new ArrayList<>();
for (SchedulingArrangeRequest.Cell cell : request.getCells()) {
if (cell.getRq() == null || cell.getJc() == null) continue;
String key = cell.getRq() + "#" + cell.getJc();
List<SSKCB> 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<String, Object> 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<String> 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<SSKCB>()
.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<SSKCB> lessons = sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.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<SSKCB>()
.eq(SSKCB::getSskcbh, course.getBh()))) {
deleteLessonChildren(lesson.getBh());
sskcbMapper.deleteById(lesson.getBh());
}
sskcxydMapper.delete(new LambdaQueryWrapper<SSKCXYD>()
.eq(SSKCXYD::getSskcbh, course.getBh()));
sskcMapper.deleteById(course.getBh());
logOperation(course, "删除", "彻底删除运行课程", null);
return 1;
}
@Override
public List<Map<String, Object>> logs(String sskcbh, String czlx) {
LambdaQueryWrapper<KCJXYX_CZRZ> 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<String, Object> m = new HashMap<String, Object>();
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<SSKCXYD> links;
List<SSKCB> 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<SSKCXYD>()
.eq(SSKCXYD::getXydbh, semester.getXydbh())
.eq(SSKCXYD::getNd, ctx.ndCode));
List<String> ids = ctx.links.stream().map(SSKCXYD::getSskcbh).distinct().toList();
ctx.lessons = ids.isEmpty() ? new ArrayList<>() : sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.in(SSKCB::getSskcbh, ids)
.eq(SSKCB::getSczt, 0));
return ctx;
}
private Ctx loadCtxForCourse(SSKC course) {
// 通过任一关联学员队反查班次学期
List<SSKCXYD> links = sskcxydMapper.selectList(new LambdaQueryWrapper<SSKCXYD>()
.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<SSKCXYD> teamLinks = sskcxydMapper.selectList(new LambdaQueryWrapper<SSKCXYD>()
.eq(SSKCXYD::getXydbh, ctx.semester.getXydbh())
.eq(SSKCXYD::getNd, ctx.ndCode));
ctx.links = teamLinks;
List<String> ids = teamLinks.stream().map(SSKCXYD::getSskcbh).distinct().toList();
ctx.lessons = ids.isEmpty() ? new ArrayList<>() : sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.in(SSKCB::getSskcbh, ids)
.eq(SSKCB::getSczt, 0));
return ctx;
}
private Ctx loadCtxByTeam(String xydbh, Integer ndCode) {
List<XYDNDXQJBXXB> semesters = classSemesterMapper.selectList(new LambdaQueryWrapper<XYDNDXQJBXXB>()
.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<SSKCBXYD>().eq(SSKCBXYD::getSskcbbh, sskcbbh));
sskcbjsMapper.delete(new LambdaQueryWrapper<SSKCBJS>().eq(SSKCBJS::getSskcbbh, sskcbbh));
sskcbfzjyMapper.delete(new LambdaQueryWrapper<SSKCBFZJY>().eq(SSKCBFZJY::getSskcbbh, sskcbbh));
}
/** 校历/班历不可排格键集合 */
private Set<String> calendarHardKeys(Ctx ctx, boolean school) {
Set<String> keys = new HashSet<>();
if (school) {
for (XQXLB row : semesterCalendarMapper.selectList(new LambdaQueryWrapper<XQXLB>()
.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<JQB>()
.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<Integer> 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<String, Object> ignoredCell(SchedulingArrangeRequest.Cell cell, String reason) {
Map<String, Object> m = new HashMap<>();
m.put("rq", cell.getRq());
m.put("jc", cell.getJc());
m.put("reason", reason);
return m;
}
private Map<String, Object> warnCell(SchedulingArrangeRequest.Cell cell, String reason) {
Map<String, Object> 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<Integer> parsePeriods(String label) {
List<Integer> 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;
}
}
@@ -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<ZYJXJHB> planWrapper = new LambdaQueryWrapper<>();
planWrapper.eq(ZYJXJHB::getZydh, zydh)
.eq(ZYJXJHB::getXqdc, xqdc)
.eq(ZYJXJHB::getTy, 0);
List<ZYJXJHB> planList = zyjxjhbMapper.selectList(planWrapper);
if (CollectionUtils.isEmpty(planList)) {
return 0;
}
// 3. 根据学员队编号和学期第次查询学员队期表,获取年度和学员队学期编号
LambdaQueryWrapper<XYDNDXQJBXXB> 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<ZYJXJHB> planList = planMatch.matched();
if (planList.isEmpty()) {
return 0;
}
// 4. 查询当前班次已存在的课程编号集合
LambdaQueryWrapper<XYDRWB> existWrapper = new LambdaQueryWrapper<>();
existWrapper.eq(XYDRWB::getXydxqbh, xydxqbh)
.eq(XYDRWB::getDelFlag, 0);
List<XYDRWB> existList = xydrwbMapper.selectList(existWrapper);
List<String> existKbhs = existList.stream()
.map(XYDRWB::getKbh)
.filter(kbh -> kbh != null)
.collect(Collectors.toList());
Set<String> 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<String> 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<ZYJXJHB> matched, String problem) {
static PlanMatch ok(List<ZYJXJHB> 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)。
*
* <p>口径说明(经业务确认保留等值匹配):人培的「学期第次」是专业总第几学期
* (1..学期数,学期数 = 学年制 × 2,见专业表);而班次学期的「学期第次」是
* 学年内学期类别(1 春季 / 2 夏季 / 3 秋季,见 ClassSemesterServiceImpl.assertXqdc)。
* 两者口径不同,因此匹配不到时必须把原因讲清楚,不能静默返回 0。</p>
*/
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<ZYJXJHB> allEnabled = zyjxjhbMapper.selectList(new LambdaQueryWrapper<ZYJXJHB>()
.eq(ZYJXJHB::getZydh, zydh)
.eq(ZYJXJHB::getTy, 0));
if (allEnabled.isEmpty()) {
return PlanMatch.fail("专业 " + zydh + " 尚无启用状态的人才培养方案数据");
}
List<ZYJXJHB> 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<PlanCourseVO> 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));
}
/**
* 补齐学员队任务表里「非空且无库默认值」的列。
* <p>
* 这些列漏填时插入会直接违反非空约束:计划课次序号、序号标识、编组、
* 配当同班异课选修编组、配档启用自定义学时、学时、课类型、教研室代号。
* 其余非空列在库里有默认值,交给库处理。
*/
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<XYDRWB> rows = xydrwbMapper.selectList(new LambdaQueryWrapper<XYDRWB>()
.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) {
@@ -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<TaskBookRowVO> 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<TaskBookRowVO> 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<XYDRWB> 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<XYDRWB> 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<XYDRWB> rows) {
XYDRWB first = rows.get(0);
for (XYDRWB row : rows) {
List<String> 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<XYDRWB>()
.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<Map<String, Object>> mergeByPreset(TaskBookBhListRequest request) {
List<XYDRWB> rows = loadRows(request);
assertAllFillable(rows);
assertSameXq(rows);
// 行按 课程科目(kbh) → 预设编班号(ysbbh,空则各班次学期独立) 分组
Map<String, List<XYDRWB>> 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<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, List<XYDRWB>> entry : groups.entrySet()) {
List<XYDRWB> group = entry.getValue();
for (XYDRWB row : group) {
row.setBz2(groupNo);
row.setBdsj(now);
taskMapper.updateById(row);
}
Map<String, Object> 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<XYDRWB> 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<XYDRWB> targets;
if (row.getBz2() != null && row.getBz2() != 0) {
targets = taskMapper.selectList(new LambdaQueryWrapper<XYDRWB>()
.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<XYDRWB> loadRows(TaskBookBhListRequest request) {
if (request == null || request.getBhList() == null || request.getBhList().isEmpty()) {
throw new ServiceException("请先选择课程任务行", BAD_REQUEST);
}
List<XYDRWB> 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<XYDRWB> rows) {
for (XYDRWB row : rows) {
assertFillable(row);
}
}
}
@@ -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 实现。
*
* <p>口径约定(详见各方法注释):
* <ul>
* <li>「是否连排课」不新增列,用已有 配档按周 pdaz:0=连排(单科独进)、1=按周(非连排);
* 周课时 ≥ 20 默认 0,否则默认 1。</li>
* <li>「每周可排正课」= 该周 可排课=1 且 正课=1 的格数 × 2 学时;
* 数据源优先级 班历 &gt; 校历 &gt; 默认(15 格 = 30 学时)。</li>
* <li>铺学时只是粗约束建议:非连排各自成组按起始周+优选序数独立铺;
* 连排课合成一组串行铺且优先级低于非连排;不写实施课程表。</li>
* <li>「保存整个编组」限定 同科目+同配档编组+同学期代号(6 位),防止跨学期串改。</li>
* </ul></p>
*/
@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<AllocationWeekVO> weeks = buildWeeks(semester);
vo.setWeeks(weeks);
vo.setTotalWeeks(weeks.size());
List<XYDRWB> tasks = listTasks(xydxqbh);
Map<String, AllocationTaskVO> 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<XYDRWB> listTasks(String xydxqbh) {
List<XYDRWB> tasks = xydrwbMapper.selectList(new LambdaQueryWrapper<XYDRWB>()
.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<AllocationWeekVO> 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<JQB>()
.eq(JQB::getXydxqbh, semester.getBh())) > 0;
Integer schoolNd = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
List<AllocationWeekVO> 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<JQB> rows = jqbMapper.selectList(new LambdaQueryWrapper<JQB>()
.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<XQXLB> rows = semesterCalendarMapper.selectList(new LambdaQueryWrapper<XQXLB>()
.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<AllocationWeekVO> weeks, List<XYDRWB> tasks, Map<String, AllocationTaskVO> 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<Integer, Integer> continuousUsed = new HashMap<>();
List<XYDRWB> nonContinuous = new ArrayList<>();
List<XYDRWB> 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<Integer, Integer> 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<Integer, Integer> 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<AllocationTaskVO.WeekHours> 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<AllocationSaveItem> 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<XYDRWB> targets;
if (Boolean.TRUE.equals(item.getSaveGroup())) {
Integer pdbz = task.getPdbz() == null ? 0 : task.getPdbz();
// 同科目 + 同配档编组 + 同学期代号:同开同结;限定学期代号防止跨学期串改
targets = xydrwbMapper.selectList(new LambdaQueryWrapper<XYDRWB>()
.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<String> 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<XYDRWB> 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<Map<String, Object>> groups(String kbh) {
if (kbh == null || kbh.isEmpty()) {
throw new ServiceException("请指定课编号", BAD_REQUEST);
}
List<XYDRWB> tasks = xydrwbMapper.selectList(new LambdaQueryWrapper<XYDRWB>()
.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<Map<String, Object>> rows = new ArrayList<>();
for (XYDRWB task : tasks) {
Map<String, Object> 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<String> xydxqbhList, HttpServletResponse response) throws Exception {
if (xydxqbhList == null || xydxqbhList.isEmpty()) {
throw new ServiceException("请先选择要导出的班次学期", BAD_REQUEST);
}
List<AllocationExportRow> 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<String, AllocationTaskVO> 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;
}
}
@@ -379,7 +379,7 @@ public class TeachingTaskManageServiceImpl implements TeachingTaskManageService
continue;
}
try {
studentTeamTaskService.autoGenerateRequiredCourses(squad.getXydbh(), squad.getXqdc());
studentTeamTaskService.autoGenerateRequiredCourses(squad.getBh());
} catch (Exception ignored) {
// 课程任务已存在或计划缺失时,仍继续生成教研室任务书
}
@@ -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.1EVENT_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<TimetableConflictDetailVO> 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.1EVENT_CONFLICT(校历/班历不可排课格)与场地历不可用
// =====================================================================
/**
* EVENT_CONFLICT:读取 学期校历表、假期表 的不可排课格,与已排课次求交。
* 校历不可排课格上有已排课次 → 硬冲突;班历对某学员队不可排课格上有该队已排课次 → 硬冲突。
*/
private List<TimetableConflictDetailVO> checkEventConflicts(Integer nd) {
List<TimetableConflictDetailVO> result = new ArrayList<>();
// 已排课次(未删除、有日期)
List<SSKCB> lessons = sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.eq(SSKCB::getNd, nd)
.eq(SSKCB::getSczt, 0)
.isNotNull(SSKCB::getRq));
if (lessons.isEmpty()) {
return result;
}
Map<String, String> courseNames = loadCourseNames(nd);
// ---- 校历:可排课=0 的格 ----
Map<String, List<SSKCB>> byGrid = lessons.stream()
.filter(l -> l.getRq() != null && l.getJc() != null)
.collect(Collectors.groupingBy(l -> l.getRq().toLocalDate().toString() + "#" + l.getJc()));
List<XQXLB> schoolUnavailable = semesterCalendarMapper.selectList(new LambdaQueryWrapper<XQXLB>()
.eq(XQXLB::getNd, nd)
.eq(XQXLB::getKpk, false));
for (XQXLB grid : schoolUnavailable) {
for (Integer jc : parsePeriods(grid.getCourseClass())) {
List<SSKCB> 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<JQB> classUnavailable = jqbMapper.selectList(new LambdaQueryWrapper<JQB>()
.eq(JQB::getNd, nd)
.eq(JQB::getKpk, 0));
if (!classUnavailable.isEmpty()) {
Map<String, SSKCB> lessonByBh = lessons.stream()
.collect(Collectors.toMap(SSKCB::getBh, l -> l, (a, b) -> a));
List<SSKCXYD> links = sskcxydMapper.selectList(new LambdaQueryWrapper<SSKCXYD>()
.in(SSKCXYD::getSskcbh, lessonByBh.keySet()));
Map<String, String> teamNames = xydbMapper.selectList(new LambdaQueryWrapper<>()).stream()
.collect(Collectors.toMap(XYDB::getXydbh, XYDB::getXydmc, (a, b) -> a));
Map<String, List<String>> 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<String, List<String>> 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<TimetableConflictDetailVO> checkClassroomCalendarConflicts(Integer nd) {
List<TimetableConflictDetailVO> result = new ArrayList<>();
List<SSKCB> lessons = sskcbMapper.selectList(new LambdaQueryWrapper<SSKCB>()
.eq(SSKCB::getNd, nd)
.eq(SSKCB::getSczt, 0)
.isNotNull(SSKCB::getRq));
if (lessons.isEmpty()) {
return result;
}
Map<String, SSKCB> lessonByBh = lessons.stream()
.collect(Collectors.toMap(SSKCB::getBh, l -> l, (a, b) -> a));
List<JXCDL> unavailable = classRoomCalendarMapper.selectList(new LambdaQueryWrapper<JXCDL>()
.eq(JXCDL::getKpk, false)
.eq(JXCDL::getDelFlag, 0)
.isNotNull(JXCDL::getRq));
if (unavailable.isEmpty()) {
return result;
}
Map<String, List<String>> roomsOfLesson = new HashMap<>();
for (var link : sskcbjsMapper.selectList(new LambdaQueryWrapper<com.roomroot.jwgl.entity.SSKCBJS>()
.in(com.roomroot.jwgl.entity.SSKCBJS::getSskcbbh, lessonByBh.keySet()))) {
roomsOfLesson.computeIfAbsent(link.getSskcbbh(), k -> new ArrayList<>()).add(link.getJsbh());
}
Map<String, String> courseNames = loadCourseNames(nd);
Map<String, String> 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<String, List<String>> 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<SSKCB> lessons,
Map<String, String> 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<String, String> loadCourseNames(Integer nd) {
Map<String, String> names = new HashMap<>();
List<SSKC> courses = sskcMapper.selectList(new LambdaQueryWrapper<SSKC>().eq(SSKC::getNd, nd));
if (courses.isEmpty()) return names;
Set<String> kbhs = courses.stream().map(SSKC::getKmbh).collect(Collectors.toSet());
Map<String, String> 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<Integer> parsePeriods(String label) {
List<Integer> 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;
}
}
@@ -1,18 +1,21 @@
package com.roomroot.jwgl.utils;
import java.time.LocalDate;
/**
* 学期代号工具。
*
* <p>系统内学期统一用 6 位学期代号表示,形如 {@code 202601}:前 4 位为年份,后 2 位为学期第次。
*
* <p>但「学员队年度学期基本信息表」的 {@code 年度} 列在历史上出现过两种口径
* <p>「年度」列的口径约定(按表区分)
* <ul>
* <li>4 位年份 + 学期第次,例如 年度=2026、学期第次=1 → 2026 * 100 + 1 = 202601</li>
* <li>直接存 6 位学期代号,例如 年度=202503</li>
* <li>课程/任务/实施类表(学员队任务表、实施_课程、实施_课程表、课程表):存 <b>6 位学期代号</b></li>
* <li>学员队年度学期基本信息表(班次学期):存 <b>4 位年份</b> + 学期第次列。</li>
* </ul>
* 班次学期表的历史数据里「年度」出现过直接存 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)。
*
* <p>学期第次在全系统的契约是 1/2/3(见 {@code ClassSemesterServiceImpl.assertXqdc} 与
* 前端批量建班接口「xqdc 传 春季学期/夏季学期/秋季学期」),它与「专业总第几学期」
* (人培/专业教学计划表的口径,1..学期数)是两套不同的编号,不能混用。</p>
*
* @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 位学期代号两种历史口径。
*
@@ -0,0 +1,77 @@
package com.roomroot.jwgl.vo.allocation;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 教学配当 - 单条课程任务的配当行(含铺学时结果)。
*
* <p>铺学时结果只是「粗约束建议」,不写实施课程表(SSKCB),见方案阶段 3「明确不做」。</p>
*/
@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 -> 学时),仅含 &gt;0 的周 */
private List<WeekHours> 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;
}
}
}
@@ -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<AllocationWeekVO> weeks = new ArrayList<>();
/** 任务配当行(已铺学时) */
private List<AllocationTaskVO> tasks = new ArrayList<>();
}
@@ -0,0 +1,31 @@
package com.roomroot.jwgl.vo.allocation;
import lombok.Data;
import java.time.LocalDate;
/**
* 教学配当 - 周视图行。
*
* <p>「每周可排正课」口径:该周内 可排课=1 且 正课=1 的时间格数 × 2 学时(双节次一格按 2 学时计)。
* 数据源优先级:班历(假期表)&gt; 校历(学期校历表)&gt; 默认值(周一~五 1-2/3-4/5-6 共 15 格 = 30 学时)。
* 班历已继承校历并允许人工补正课,故直接用班历即等价于「校历正课 − 不可排课 + 班历补正课」。</p>
*/
@Data
public class AllocationWeekVO {
/** 周次(从 1 起) */
private Integer weekNo;
/** 周起(周一) */
private LocalDate startDate;
/** 周止(周日) */
private LocalDate endDate;
/** 该周可排正课学时 */
private Integer availableHours;
/** 容量来源:class-班历 / school-校历 / default-默认 */
private String source;
}
@@ -34,7 +34,8 @@ public class SemesterInitVO {
private Boolean halfYear;
/**
* 默认/推断的学期第次
* 默认/推断的学期第次(学年内学期类别:1 春季 / 2 夏季 / 3 秋季;
* 不是「专业总第几学期」,人培/专业教学计划表的学期第次才是专业总学期口径)
*/
private Integer xqdc;
@@ -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<Course> courses = new ArrayList<>();
/** 时间区:按周切分,格子叠加校历/班历不可排与已排课次 */
private List<Week> 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<Day> days = new ArrayList<>();
}
@Data
public static class Day {
private LocalDate date;
private Integer weekday;
/** 该日不可排原因(校历整日/班历整日,节次级见格子) */
private String unavailableReason;
private List<Cell> cells = new ArrayList<>();
}
@Data
public static class Cell {
private Integer jc;
/** 硬冲突:不可排课 */
private Boolean unavailable;
/** 硬冲突原因(不可排课来源:校历/班历等) */
private String reason;
/** 软提示(非正课 / 配当周次不符) */
private String warning;
private List<Lesson> 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;
}
}
@@ -0,0 +1,79 @@
package com.roomroot.jwgl.vo.taskbook;
import lombok.Data;
/**
* 教研室任务书填报列表行(阶段 4)。
*
* <p>一行 = 一条课程任务(学员队任务表)。列表排序:课程 → 责任教员 → 课次;
* 返回 合班分组号(bz2,0=未合班)供前端同色连显。</p>
*/
@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;
}
@@ -132,4 +132,10 @@ public class TimetableConflictDetailVO {
* 统一从学员队表的「所属单位」列取值(WM_CONCAT 去重)。
*/
private String responsibleDept;
/**
* 冲突等级(阶段 5.1):hard-硬冲突(双占 / 三类历不可排,深红);soft-软提示(橙)。
* 本检查器的维度全部为硬冲突;软提示(配当周次不符、非正课)由排课窗实时计算。
*/
private String level;
}