学期新增的时候校验校历功能点添加,否则会有大量的数据重复生成

This commit is contained in:
2026-09-14 17:24:56 +08:00
parent 52a04dbf60
commit 5187435285
9 changed files with 352 additions and 87 deletions
@@ -10,12 +10,13 @@ import java.util.List;
public interface SemesterCalendarService {
/**
* 新增学期校历
* 预生成学期校历(幂等:已存在的「年度 + 日期 + 节次」不会被重复插入,也不会覆盖用户已手工标记的格子)
*
* @param startDate 学期起始日期
* @param endDate 学期结束日期
* @param endDate 学期结束日期
* @param nd 6 位学期代号(如 202601)。为空时按日期在「学期」表反查
*/
void add(String startDate,String endDate);
void add(String startDate, String endDate, Integer nd);
/**
* 删除学期校历
@@ -11,6 +11,7 @@ import com.roomroot.jwgl.mapper.JQBMapper;
import com.roomroot.jwgl.mapper.SemesterCalendarMapper;
import com.roomroot.jwgl.service.ClassEventCalendarService;
import com.roomroot.jwgl.service.TeachingTaskWriteGuard;
import com.roomroot.jwgl.utils.SemesterCodeUtil;
import com.roomroot.jwgl.utils.UuidUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -22,7 +23,9 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
@@ -141,20 +144,63 @@ public class ClassEventCalendarServiceImpl implements ClassEventCalendarService
generateEmptyGrid(semester, start, end);
return;
}
for (XQXLB school : schoolRows) {
LocalDate day = parseDay(school.getJqsj());
if (day.isBefore(start) || day.isAfter(end)) {
continue;
}
insertCell(semester, day, school.getCourseClass(), school.getJqmc(),
// 校历表同一 (日期, 节次) 可能存在多条重复记录,先归并再落库,
// 否则班历会被按校历的重复倍数整体放大。
for (XQXLB school : mergeByDayAndPeriod(schoolRows, start, end)) {
insertCell(semester, parseDay(school.getJqsj()), school.getCourseClass(), school.getJqmc(),
flag(school.getJc()), flag(school.getKpk()), flag(school.getBzxs()),
flag(school.getZdpk()), flag(school.getZk()), school.getBz());
}
}
/**
* 按 (日期, 节次) 归并校历行,同一格只保留一条。
* <p>同一格存在多条时按「正课 &gt; 可排课 &gt; 带假期名称」的优先级取一条,
* 避免校历中的重复行被逐条继承导致班历成倍膨胀。
*/
private List<XQXLB> mergeByDayAndPeriod(List<XQXLB> rows, LocalDate start, LocalDate end) {
Map<String, XQXLB> merged = new LinkedHashMap<>();
for (XQXLB row : rows) {
String courseClass = row.getCourseClass();
if (courseClass == null || courseClass.isEmpty()) {
continue;
}
LocalDate day = parseDay(row.getJqsj());
if (day.isBefore(start) || day.isAfter(end)) {
continue;
}
String key = row.getJqsj() + "|" + courseClass;
XQXLB kept = merged.get(key);
if (kept == null || preferredOver(row, kept)) {
merged.put(key, row);
}
}
return new ArrayList<>(merged.values());
}
/**
* 判断 candidate 是否比 current 更值得保留(正课 &gt; 可排课 &gt; 带假期名称)。
*/
private boolean preferredOver(XQXLB candidate, XQXLB current) {
boolean candidateMain = Boolean.TRUE.equals(candidate.getZk());
boolean currentMain = Boolean.TRUE.equals(current.getZk());
if (candidateMain != currentMain) {
return candidateMain;
}
boolean candidateKpk = Boolean.TRUE.equals(candidate.getKpk());
boolean currentKpk = Boolean.TRUE.equals(current.getKpk());
if (candidateKpk != currentKpk) {
return candidateKpk;
}
boolean candidateNamed = candidate.getJqmc() != null && !candidate.getJqmc().isEmpty();
boolean currentNamed = current.getJqmc() != null && !current.getJqmc().isEmpty();
return candidateNamed && !currentNamed;
}
private List<XQXLB> loadSchoolRows(XYDNDXQJBXXB semester, LocalDate start, LocalDate end) {
Integer schoolNd = semester.getNd() == null || semester.getXqdc() == null
? null : semester.getNd() * 100 + semester.getXqdc();
// 「年度 + 学期第次」统一解析为 6 位学期代号。
// 历史数据里 年度 可能直接存的 6 位学期代号,两种口径都要兼容(详见 SemesterCodeUtil)。
Integer schoolNd = SemesterCodeUtil.resolve(semester.getNd(), semester.getXqdc());
List<XQXLB> rows = querySchool(schoolNd, start, end);
if (!rows.isEmpty() || semester.getNd() == null) {
return rows;
@@ -16,6 +16,7 @@ import com.roomroot.jwgl.service.ClassSemesterService;
import com.roomroot.jwgl.service.ElectiveService;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -349,6 +350,8 @@ public class ClassSemesterServiceImpl implements ClassSemesterService {
if (rows.size() < 2) {
throw new ServiceException("请至少选择两个班次进行预设合班", BAD_REQUEST);
}
// 编波是学期级操作:所选班次必须同属一个学期,否则合班结果会跨学期串味
assertSameSemester(rows);
Set<Integer> waves = new HashSet<>();
for (XYDNDXQJBXXB row : rows) {
if (row.getYsbbh() != null && row.getYsbbh() > 0) {
@@ -383,19 +386,22 @@ public class ClassSemesterServiceImpl implements ClassSemesterService {
}
private boolean insertClassSemester(XYDB clazz, int nd, int xqdc, LocalDate kxrq, LocalDate jsrq) {
// 统一落库为「4 位年份 + 学期第次」,避免同一列再次出现 4 位/6 位两套口径
Integer year = SemesterCodeUtil.toYear(nd);
Integer period = SemesterCodeUtil.toPeriod(nd, xqdc);
XYDNDXQJBXXB existing = classSemesterMapper.selectByBcAndNdAndXqdc(
clazz.getXydbh(), String.valueOf(nd), String.valueOf(xqdc));
clazz.getXydbh(), String.valueOf(year), String.valueOf(period));
if (existing != null) {
return false;
}
XYDNDXQJBXXB row = new XYDNDXQJBXXB();
row.setBh(UuidUtil.getUUID());
row.setXydbh(clazz.getXydbh());
row.setNd(nd);
row.setXqdc(xqdc);
row.setNd(year);
row.setXqdc(period);
row.setKxrq(kxrq);
row.setJsrq(jsrq);
row.setZyjsbh(resolveRoom(clazz, xqdc));
row.setZyjsbh(resolveRoom(clazz, period));
row.setXhbs(0);
row.setYsbbh(0);
row.setJclb(clazz.getJclb() != null && !clazz.getJclb().isEmpty() ? clazz.getJclb() : "双节次");
@@ -425,7 +431,8 @@ public class ClassSemesterServiceImpl implements ClassSemesterService {
}
private XQ findSchoolSemester(Integer nd, Integer xqdc) {
String key = String.valueOf(nd * 100 + xqdc);
// 「年度 + 学期第次」统一解析为 6 位学期代号,兼容历史数据里 年度 直接存 6 位的情况
String key = String.valueOf(SemesterCodeUtil.resolve(nd, xqdc));
XQ school = semesterMapper.selectById(key);
if (school != null) {
return school;
@@ -466,6 +473,24 @@ public class ClassSemesterServiceImpl implements ClassSemesterService {
}
}
/**
* 校验所选班次同属一个学期。
*
* <p>编波(预设合班)是学期级操作。跨学期合班会让同一个「预设编班号」同时落在多个学期上,
* 后续班历生成与课时配当无法判断该把合班结果归到哪个学期。
*/
private void assertSameSemester(List<XYDNDXQJBXXB> rows) {
Set<String> semesters = new HashSet<>();
for (XYDNDXQJBXXB row : rows) {
Integer code = SemesterCodeUtil.resolve(row.getNd(), row.getXqdc());
semesters.add(code == null ? "未知学期" : String.valueOf(code));
if (semesters.size() > 1) {
throw new ServiceException("预设合班要求所选班次属于同一学期,当前选中项跨了 "
+ semesters.size() + " 个学期,请先按学期筛选后再操作", BAD_REQUEST);
}
}
}
private int parseXqdc(String xqdc) {
if (xqdc == null || xqdc.isEmpty()) {
throw new ServiceException("请选择学期第次", BAD_REQUEST);
@@ -1,9 +1,10 @@
package com.roomroot.jwgl.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.roomroot.jwgl.entity.XQ;
import com.roomroot.jwgl.entity.XQXLB;
import com.roomroot.jwgl.mapper.SemesterCalendarMapper;
import com.roomroot.jwgl.mapper.XQMapper;
import com.roomroot.jwgl.service.SemesterCalendarService;
import com.roomroot.jwgl.utils.DateCourseUtil;
import com.roomroot.jwgl.utils.UuidUtil;
@@ -17,9 +18,12 @@ import jakarta.annotation.Resource;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 学期校历表服务实现类
@@ -27,50 +31,112 @@ import java.util.Map;
@Service
public class SemesterCalendarServiceImpl implements SemesterCalendarService {
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* 正课时段:与班历空网格 {@code generateEmptyGrid} 的规则保持一致
* (周一至周五的 1-2 / 3-4 / 5-6 节)。
*/
private static final List<String> MAIN_PERIODS = Arrays.asList("1-2", "3-4", "5-6");
@Resource
private SemesterCalendarMapper xqxlbMapper;
@Resource
private XQMapper xqMapper;
@Override
public void add(String startDate,String endDateb) {
Map<String, Object> dateWeekAndCourseMap = DateCourseUtil.getDateWeekAndCourseMap(startDate, endDateb);
public void add(String startDate, String endDate, Integer nd) {
Map<String, Object> dateWeekAndCourseMap = DateCourseUtil.getDateWeekAndCourseMap(startDate, endDate);
if (dateWeekAndCourseMap.isEmpty()) {
return;
}
// 学期定义:用于把日期解析成 6 位学期代号,与前端查询、班历继承口径保持一致
List<XQ> semesters = xqMapper.selectList(new LambdaQueryWrapper<XQ>().orderByAsc(XQ::getKxrq));
// 每个年度已存在的「日期|节次」键,按年度懒加载,避免逐格查库
Map<Integer, Set<String>> existingCache = new HashMap<>();
for (Map.Entry<String, Object> entry : dateWeekAndCourseMap.entrySet()) {
String dateStr = entry.getKey();
Page<XQXLB> page = new Page<>(0, 10);
XQXLB query = new XQXLB();
//将日期分为年度和月日
LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
int nd = date.getYear();
String md = String.format("%02d%02d", date.getMonthValue(), date.getDayOfMonth());
query.setJqsj(md);
query.setNd(nd);
Page<XQXLB> result = xqxlbMapper.selectPageList(page, query);
if(result.getTotal() > 0){
LocalDate date = LocalDate.parse(dateStr, DAY);
Integer year = resolveNd(nd, date, semesters);
Set<String> existing = existingCache.computeIfAbsent(
year, key -> loadExistingKeys(key, startDate, endDate));
Map<String, Object> map = (Map<String, Object>) entry.getValue();
Object course = map.get("course");
if (!(course instanceof List<?>)) {
continue;
}
Map<String,Object> map = (Map<String, Object>) entry.getValue();
Object course = map.get("course");
if (course instanceof ArrayList<?>) {
ArrayList<String> list = (ArrayList<String>) course;
for (int i = 0; i < list.size(); i++) {
String courseClass = list.get(i);
XQXLB xqxlb = new XQXLB();
//编号,用时间戳
String bh = UuidUtil.getUUID();
xqxlb.setBh(bh);
//假期时间
xqxlb.setJqsj(dateStr);
//年度
xqxlb.setNd(Integer.valueOf(nd));
//节次
xqxlb.setCourseClass(courseClass);
xqxlb.setKpk(true);
xqxlb.setBzxs(true);
xqxlb.setZdpk(true);
xqxlbMapper.insert(xqxlb);
for (Object item : (List<?>) course) {
String courseClass = String.valueOf(item);
// 幂等:同一 (年度, 日期, 节次) 已存在则跳过。
// 既不重复插入,也不覆盖用户已手工标记的假期/正课等内容。
if (!existing.add(dateStr + "|" + courseClass)) {
continue;
}
XQXLB xqxlb = new XQXLB();
xqxlb.setBh(UuidUtil.getUUID());
// 假期时间:完整日期 yyyy-MM-dd(判重键与落库值必须同格式)
xqxlb.setJqsj(dateStr);
// 年度:6 位学期代号
xqxlb.setNd(year);
// 节次
xqxlb.setCourseClass(courseClass);
xqxlb.setKpk(true);
xqxlb.setBzxs(true);
xqxlb.setZdpk(true);
// 正课:周一至周五 1-6 节,与班历空网格规则一致
xqxlb.setZk(isMainCourse(date, courseClass));
xqxlbMapper.insert(xqxlb);
}
}
}
/**
* 解析 6 位学期代号(如 202601)。
* <p>优先级:合法的 6 位入参 &gt; 按日期在「学期」表反查 &gt; 兜底用 4 位年份。
* 4 位年份(如 2026)属历史错误口径,一律走反查纠正,避免同一批日期被贴上两套年度标签。
*/
private Integer resolveNd(Integer nd, LocalDate date, List<XQ> semesters) {
if (nd != null && nd >= 100000) {
return nd;
}
for (XQ xq : semesters) {
if (xq.getNd() == null || xq.getKxrq() == null || xq.getJsrq() == null) {
continue;
}
LocalDate kxrq = xq.getKxrq().toLocalDate();
LocalDate jsrq = xq.getJsrq().toLocalDate();
if (!date.isBefore(kxrq) && !date.isAfter(jsrq)) {
try {
return Integer.valueOf(xq.getNd().trim());
} catch (NumberFormatException ignore) {
// 学期代号非数字,继续匹配下一条
}
}
}
return date.getYear();
}
/**
* 读取该年度在 [startDate, endDate] 区间内已存在的「日期|节次」键集合。
*/
private Set<String> loadExistingKeys(Integer nd, String startDate, String endDate) {
Set<String> keys = new HashSet<>();
List<XQXLB> rows = xqxlbMapper.selectList(new LambdaQueryWrapper<XQXLB>()
.eq(XQXLB::getNd, nd)
.ge(XQXLB::getJqsj, startDate)
.le(XQXLB::getJqsj, endDate));
for (XQXLB row : rows) {
keys.add(row.getJqsj() + "|" + row.getCourseClass());
}
return keys;
}
/**
* 正课判定:周一至周五的 1-2 / 3-4 / 5-6 节。
*/
private boolean isMainCourse(LocalDate date, String courseClass) {
return date.getDayOfWeek().getValue() <= 5 && MAIN_PERIODS.contains(courseClass);
}
@Override
@@ -124,9 +190,14 @@ public class SemesterCalendarServiceImpl implements SemesterCalendarService {
// 假期时间
String jqsj = getCellValue(row, 1);
xqxlb.setJqsj(jqsj);
Page<XQXLB> page = new Page<>(0, 10);
Page<XQXLB> result = xqxlbMapper.selectPageList(page, xqxlb);
if(result.getTotal() > 0){
// 节次:判重键的一部分,必须在查重前解析
String courseClass = getCellValue(row, 8);
xqxlb.setCourseClass(courseClass);
// 按 (年度, 假期时间, 节次) 精确判重。
// 原实现只按「年度 + 日期模糊匹配」,会导致同一天的其余节次被误判为已存在而整行跳过。
if (xqxlb.getNd() != null && jqsj != null && !jqsj.isEmpty() && hasSameCell(xqxlb.getNd(), jqsj, courseClass)) {
continue;
}
@@ -152,9 +223,6 @@ public class SemesterCalendarServiceImpl implements SemesterCalendarService {
String zdpk = getCellValue(row, 7);
xqxlb.setZdpk("是".equals(zdpk));
// 节次
xqxlb.setCourseClass(getCellValue(row, 8));
xqxlbMapper.insert(xqxlb);
count++;
}
@@ -164,6 +232,22 @@ public class SemesterCalendarServiceImpl implements SemesterCalendarService {
}
}
/**
* 判断同一 (年度, 假期时间, 节次) 是否已存在。
*/
private boolean hasSameCell(Integer nd, String jqsj, String courseClass) {
LambdaQueryWrapper<XQXLB> wrapper = new LambdaQueryWrapper<XQXLB>()
.eq(XQXLB::getNd, nd)
.eq(XQXLB::getJqsj, jqsj);
if (courseClass == null || courseClass.isEmpty()) {
wrapper.isNull(XQXLB::getCourseClass);
} else {
wrapper.eq(XQXLB::getCourseClass, courseClass);
}
Long exist = xqxlbMapper.selectCount(wrapper);
return exist != null && exist > 0;
}
/**
* 获取单元格字符串值
*/
@@ -197,4 +281,4 @@ public class SemesterCalendarServiceImpl implements SemesterCalendarService {
}
}
}
}
@@ -63,7 +63,23 @@ public class SemesterServiceImpl extends ServiceImpl<SemesterMapper, XQ> impleme
LocalDateTime jsrq = xq.getJsrq();
String startDate = localDateTimeToDateStr(kxrq);
String endDate = localDateTimeToDateStr(jsrq);
semesterCalendarService.add(startDate, endDate);
// 直接把本学期 6 位代号传给校历生成,避免与前端查询口径不一致导致重复落库
semesterCalendarService.add(startDate, endDate, parseSemesterNd(xq.getNd()));
}
}
/**
* 把「学期」表的年度主键(6 位学期代号,如 202601)转为 Integer。
* 非数字时返回 null,由 {@code SemesterCalendarService.add} 按日期反查兜底。
*/
private static Integer parseSemesterNd(String nd) {
if (nd == null) {
return null;
}
try {
return Integer.valueOf(nd.trim());
} catch (NumberFormatException ignore) {
return null;
}
}
@@ -142,14 +158,36 @@ public class SemesterServiceImpl extends ServiceImpl<SemesterMapper, XQ> impleme
}
}
/**
* 校验学期时间范围不与其它学期重叠。
*
* <p>只拦截「本次修改新引入」的重叠:若某条重叠在修改前就已存在
* (库中 202501/202502、202601/202602 两对历史数据本身就互相重叠),
* 则放行本次保存。否则这些记录连改个名称、设为「当前学期」都会被挡住,
* 脏数据反而永远修不掉。
*/
private void assertNoOverlap(XQ xq) {
Long overlap = semesterMapper.selectCount(new LambdaQueryWrapper<XQ>()
List<XQ> overlaps = semesterMapper.selectList(new LambdaQueryWrapper<XQ>()
.ne(XQ::getNd, xq.getNd())
.lt(XQ::getKxrq, xq.getJsrq())
.gt(XQ::getJsrq, xq.getKxrq()));
if (overlap != null && overlap > 0) {
if (overlaps.isEmpty()) {
return;
}
XQ existing = semesterMapper.selectById(xq.getNd());
if (existing == null || existing.getKxrq() == null || existing.getJsrq() == null) {
throw new ServiceException("学期时间范围与已有学期重叠", CONFLICT);
}
for (XQ other : overlaps) {
if (other.getKxrq() == null || other.getJsrq() == null) {
continue;
}
boolean preExisting = other.getKxrq().isBefore(existing.getJsrq())
&& other.getJsrq().isAfter(existing.getKxrq());
if (!preExisting) {
throw new ServiceException("学期时间范围与已有学期重叠", CONFLICT);
}
}
}
@Override
@@ -0,0 +1,76 @@
package com.roomroot.jwgl.utils;
/**
* 学期代号工具。
*
* <p>系统内学期统一用 6 位学期代号表示,形如 {@code 202601}:前 4 位为年份,后 2 位为学期第次。
*
* <p>但「学员队年度学期基本信息表」的 {@code 年度} 列在历史上出现过两种口径:
* <ul>
* <li>4 位年份 + 学期第次,例如 年度=2026、学期第次=1 → 2026 * 100 + 1 = 202601</li>
* <li>直接存 6 位学期代号,例如 年度=202503</li>
* </ul>
* 把 6 位值再套 {@code nd * 100 + xqdc} 会溢出(202503 * 100 + 1 = 20250301),
* 所以各处都需要先判别口径。本工具把两种口径统一解析为 6 位学期代号,
* 同时提供「写库时统一为 4 位年份」的规范化方法,避免新增数据继续产生双口径。
*/
public final class SemesterCodeUtil {
/** 6 位学期代号的下界(4 位年份最大 9999)。 */
private static final int SEMESTER_CODE_LOWER_BOUND = 100000;
private SemesterCodeUtil() {
}
/**
* 解析为 6 位学期代号,兼容 4 位年份与 6 位学期代号两种历史口径。
*
* @param nd 年度:4 位年份(如 2026)或 6 位学期代号(如 202503)
* @param xqdc 学期第次(1/2/3)。仅当 nd 为 4 位年份时参与计算
* @return 6 位学期代号;nd 为空时返回 null
*/
public static Integer resolve(Integer nd, Integer xqdc) {
if (nd == null) {
return null;
}
if (isSemesterCode(nd)) {
return nd;
}
if (xqdc == null) {
return nd;
}
return nd * 100 + xqdc;
}
/**
* 判断给定年度是否已经是 6 位学期代号。
*/
public static boolean isSemesterCode(Integer nd) {
return nd != null && nd >= SEMESTER_CODE_LOWER_BOUND;
}
/**
* 取 4 位年份部分。传入 6 位学期代号时拆分,传入 4 位年份时原样返回。
*
* @return 4 位年份;nd 为空时返回 null
*/
public static Integer toYear(Integer nd) {
if (nd == null) {
return null;
}
return isSemesterCode(nd) ? nd / 100 : nd;
}
/**
* 取学期第次。传入 6 位学期代号时取末两位,传入 4 位年份时返回传入的兜底值。
*
* @param xqdcFallback 4 位年份口径下的学期第次兜底值
* @return 学期第次;nd 为空时返回 null
*/
public static Integer toPeriod(Integer nd, Integer xqdcFallback) {
if (nd == null) {
return null;
}
return isSemesterCode(nd) ? nd % 100 : xqdcFallback;
}
}