Commit 33c90a7a61194478f0a6a335e0920b590b25d8e1

Authored by YunaiV
1 parent 8fdc34e9

fix(mes): 修复排班连班与节假日生成异常

yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/cal/plan/MesCalPlanMapper.java
@@ -8,6 +8,9 @@ import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; @@ -8,6 +8,9 @@ import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
8 import com.baomidou.mybatisplus.core.toolkit.Wrappers; 8 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
9 import org.apache.ibatis.annotations.Mapper; 9 import org.apache.ibatis.annotations.Mapper;
10 10
  11 +import java.time.LocalDate;
  12 +import java.util.List;
  13 +
11 /** 14 /**
12 * MES 排班计划 Mapper 15 * MES 排班计划 Mapper
13 * 16 *
@@ -32,6 +35,15 @@ public interface MesCalPlanMapper extends BaseMapperX<MesCalPlanDO> { @@ -32,6 +35,15 @@ public interface MesCalPlanMapper extends BaseMapperX<MesCalPlanDO> {
32 return selectOne(MesCalPlanDO::getCode, code); 35 return selectOne(MesCalPlanDO::getCode, code);
33 } 36 }
34 37
  38 + default List<MesCalPlanDO> selectListByStatusAndDay(Integer status, LocalDate day) {
  39 + return selectList(new LambdaQueryWrapperX<MesCalPlanDO>()
  40 + .eq(MesCalPlanDO::getStatus, status)
  41 + // TODO @AI:dayend 获取下?应该有方法,这样可以 le,更统一点;
  42 + .lt(MesCalPlanDO::getStartDate, day.plusDays(1).atStartOfDay())
  43 + .ge(MesCalPlanDO::getEndDate, day.atStartOfDay())
  44 + .orderByAsc(MesCalPlanDO::getId));
  45 + }
  46 +
35 default void updateWithShiftFieldCleanup(MesCalPlanDO plan, boolean clearShiftMethod, boolean clearShiftCount) { 47 default void updateWithShiftFieldCleanup(MesCalPlanDO plan, boolean clearShiftMethod, boolean clearShiftCount) {
36 update(plan, Wrappers.<MesCalPlanDO>lambdaUpdate() 48 update(plan, Wrappers.<MesCalPlanDO>lambdaUpdate()
37 .eq(MesCalPlanDO::getId, plan.getId()) 49 .eq(MesCalPlanDO::getId, plan.getId())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -140,6 +140,9 @@ public interface ErrorCodeConstants { @@ -140,6 +140,9 @@ public interface ErrorCodeConstants {
140 ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH = new ErrorCode(1_040_201_202, "班次数量不满足轮班方式要求"); 140 ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH = new ErrorCode(1_040_201_202, "班次数量不满足轮班方式要求");
141 ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_COUNT_INVALID = new ErrorCode(1_040_201_203, "按天倒班必须填写合法的倒班天数"); 141 ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_COUNT_INVALID = new ErrorCode(1_040_201_203, "按天倒班必须填写合法的倒班天数");
142 ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_METHOD_INVALID = new ErrorCode(1_040_201_204, "不支持的倒班方式:{}"); 142 ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_METHOD_INVALID = new ErrorCode(1_040_201_204, "不支持的倒班方式:{}");
  143 + ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID = new ErrorCode(1_040_201_205, "班次【{}】的开始或结束时间格式不正确,应为 HH:mm,结束时间可为 24:00");
  144 + ErrorCode CAL_TEAM_SHIFT_GENERATE_REST_INVALID = new ErrorCode(1_040_201_206,
  145 + "班组【{}】{} {}与 {} {}的上班时间重叠或首尾相接,没有休息间隔,请调整班次时间或倒班方式");
143 146
144 // ========== MES 日历排班-排班计划(1-040-202-000) ========== 147 // ========== MES 日历排班-排班计划(1-040-202-000) ==========
145 ErrorCode CAL_PLAN_NOT_EXISTS = new ErrorCode(1_040_202_000, "排班计划不存在"); 148 ErrorCode CAL_PLAN_NOT_EXISTS = new ErrorCode(1_040_202_000, "排班计划不存在");
@@ -152,6 +155,8 @@ public interface ErrorCodeConstants { @@ -152,6 +155,8 @@ public interface ErrorCodeConstants {
152 155
153 // ========== MES 日历排班-假期设置(1-040-203-000) ========== 156 // ========== MES 日历排班-假期设置(1-040-203-000) ==========
154 ErrorCode CAL_HOLIDAY_NOT_EXISTS = new ErrorCode(1_040_203_000, "假期设置不存在"); 157 ErrorCode CAL_HOLIDAY_NOT_EXISTS = new ErrorCode(1_040_203_000, "假期设置不存在");
  158 + ErrorCode CAL_HOLIDAY_PLAN_CONFIRMED = new ErrorCode(1_040_203_001,
  159 + "{} 在已确认的排班计划【{}】范围内,修改是否节假日会与已生成的排班不一致,不允许修改");
155 160
156 // ========== MES 设备管理-设备类型(1-040-300-000) ========== 161 // ========== MES 设备管理-设备类型(1-040-300-000) ==========
157 ErrorCode DV_MACHINERY_TYPE_NOT_EXISTS = new ErrorCode(1_040_300_000, "设备类型不存在"); 162 ErrorCode DV_MACHINERY_TYPE_NOT_EXISTS = new ErrorCode(1_040_300_000, "设备类型不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/cal/holiday/MesCalHolidayServiceImpl.java
1 package cn.iocoder.yudao.module.mes.service.cal.holiday; 1 package cn.iocoder.yudao.module.mes.service.cal.holiday;
2 2
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.StrUtil;
3 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 import cn.iocoder.yudao.module.mes.controller.admin.cal.holiday.vo.MesCalHolidaySaveReqVO; 6 import cn.iocoder.yudao.module.mes.controller.admin.cal.holiday.vo.MesCalHolidaySaveReqVO;
5 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO; 7 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO;
  8 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
6 import cn.iocoder.yudao.module.mes.dal.mysql.cal.holiday.MesCalHolidayMapper; 9 import cn.iocoder.yudao.module.mes.dal.mysql.cal.holiday.MesCalHolidayMapper;
  10 +import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum;
  11 +import cn.iocoder.yudao.module.mes.enums.cal.MesCalPlanStatusEnum;
  12 +import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService;
7 import jakarta.annotation.Resource; 13 import jakarta.annotation.Resource;
  14 +import org.springframework.context.annotation.Lazy;
8 import org.springframework.stereotype.Service; 15 import org.springframework.stereotype.Service;
9 import org.springframework.validation.annotation.Validated; 16 import org.springframework.validation.annotation.Validated;
10 17
11 import java.time.LocalDateTime; 18 import java.time.LocalDateTime;
12 import java.util.List; 19 import java.util.List;
13 20
  21 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  22 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  23 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_HOLIDAY_PLAN_CONFIRMED;
  24 +
14 /** 25 /**
15 * MES 假期设置 Service 实现类 26 * MES 假期设置 Service 实现类
16 * 27 *
@@ -23,10 +34,17 @@ public class MesCalHolidayServiceImpl implements MesCalHolidayService { @@ -23,10 +34,17 @@ public class MesCalHolidayServiceImpl implements MesCalHolidayService {
23 @Resource 34 @Resource
24 private MesCalHolidayMapper holidayMapper; 35 private MesCalHolidayMapper holidayMapper;
25 36
  37 + @Resource
  38 + @Lazy
  39 + private MesCalPlanService planService;
  40 +
26 @Override 41 @Override
27 public Long saveHoliday(MesCalHolidaySaveReqVO saveReqVO) { 42 public Long saveHoliday(MesCalHolidaySaveReqVO saveReqVO) {
28 - // Upsert 逻辑:如果该日期已有记录,则更新 43 + // 1. 校验是否节假日的变化,不影响已确认的排班计划
29 MesCalHolidayDO existing = holidayMapper.selectByDay(saveReqVO.getDay()); 44 MesCalHolidayDO existing = holidayMapper.selectByDay(saveReqVO.getDay());
  45 + validateHolidayChangeForConfirmedPlan(existing, saveReqVO);
  46 +
  47 + // 2.1 Upsert 逻辑:如果该日期已有记录,则更新
30 if (existing != null) { 48 if (existing != null) {
31 MesCalHolidayDO updateObj = BeanUtils.toBean(saveReqVO, MesCalHolidayDO.class); 49 MesCalHolidayDO updateObj = BeanUtils.toBean(saveReqVO, MesCalHolidayDO.class);
32 updateObj.setId(existing.getId()); 50 updateObj.setId(existing.getId());
@@ -34,12 +52,33 @@ public class MesCalHolidayServiceImpl implements MesCalHolidayService { @@ -34,12 +52,33 @@ public class MesCalHolidayServiceImpl implements MesCalHolidayService {
34 return existing.getId(); 52 return existing.getId();
35 } 53 }
36 54
37 - // 插入新记录 55 + // 2.2 插入新记录
38 MesCalHolidayDO holiday = BeanUtils.toBean(saveReqVO, MesCalHolidayDO.class); 56 MesCalHolidayDO holiday = BeanUtils.toBean(saveReqVO, MesCalHolidayDO.class);
39 holidayMapper.insert(holiday); 57 holidayMapper.insert(holiday);
40 return holiday.getId(); 58 return holiday.getId();
41 } 59 }
42 60
  61 + /**
  62 + * 校验是否节假日的变化,不影响已确认的排班计划
  63 + *
  64 + * 排班计划确认时按“节假日停班”生成排班记录(按天倒班时还会顺延后续倒班),确认后不会重新生成。
  65 + * 因此已确认计划范围内的日期,不允许在“节假日”与“非节假日”之间切换,避免与已生成的排班不一致 https://t.zsxq.com/XdcRW
  66 + */
  67 + private void validateHolidayChangeForConfirmedPlan(MesCalHolidayDO existing, MesCalHolidaySaveReqVO saveReqVO) {
  68 + boolean oldHoliday = existing != null && MesCalHolidayTypeEnum.HOLIDAY.getType().equals(existing.getType());
  69 + boolean newHoliday = MesCalHolidayTypeEnum.HOLIDAY.getType().equals(saveReqVO.getType());
  70 + if (oldHoliday == newHoliday) {
  71 + return;
  72 + }
  73 + List<MesCalPlanDO> plans = planService.getPlanListByStatusAndDay(
  74 + MesCalPlanStatusEnum.CONFIRMED.getStatus(), saveReqVO.getDay().toLocalDate());
  75 + if (CollUtil.isEmpty(plans)) {
  76 + return;
  77 + }
  78 + throw exception(CAL_HOLIDAY_PLAN_CONFIRMED, saveReqVO.getDay().toLocalDate(),
  79 + StrUtil.join("、", convertList(plans, MesCalPlanDO::getName)));
  80 + }
  81 +
43 @Override 82 @Override
44 public List<MesCalHolidayDO> getHolidayList(LocalDateTime startDay, LocalDateTime endDay) { 83 public List<MesCalHolidayDO> getHolidayList(LocalDateTime startDay, LocalDateTime endDay) {
45 return holidayMapper.selectList(startDay, endDay); 84 return holidayMapper.selectList(startDay, endDay);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanService.java
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.MesCalPlanSaveRe @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.MesCalPlanSaveRe
6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; 6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
7 import jakarta.validation.Valid; 7 import jakarta.validation.Valid;
8 8
  9 +import java.time.LocalDate;
9 import java.util.Collection; 10 import java.util.Collection;
10 import java.util.List; 11 import java.util.List;
11 import java.util.Map; 12 import java.util.Map;
@@ -90,4 +91,13 @@ public interface MesCalPlanService { @@ -90,4 +91,13 @@ public interface MesCalPlanService {
90 */ 91 */
91 PageResult<MesCalPlanDO> getPlanPage(MesCalPlanPageReqVO pageReqVO); 92 PageResult<MesCalPlanDO> getPlanPage(MesCalPlanPageReqVO pageReqVO);
92 93
  94 + /**
  95 + * 获得指定状态、且起止日期包含指定日期的排班计划列表
  96 + *
  97 + * @param status 状态
  98 + * @param day 日期
  99 + * @return 排班计划列表
  100 + */
  101 + List<MesCalPlanDO> getPlanListByStatusAndDay(Integer status, LocalDate day);
  102 +
93 } 103 }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImpl.java
@@ -18,6 +18,7 @@ import org.springframework.stereotype.Service; @@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
18 import org.springframework.transaction.annotation.Transactional; 18 import org.springframework.transaction.annotation.Transactional;
19 import org.springframework.validation.annotation.Validated; 19 import org.springframework.validation.annotation.Validated;
20 20
  21 +import java.time.LocalDate;
21 import java.util.Collection; 22 import java.util.Collection;
22 import java.util.Collections; 23 import java.util.Collections;
23 import java.util.List; 24 import java.util.List;
@@ -143,6 +144,11 @@ public class MesCalPlanServiceImpl implements MesCalPlanService { @@ -143,6 +144,11 @@ public class MesCalPlanServiceImpl implements MesCalPlanService {
143 } 144 }
144 145
145 @Override 146 @Override
  147 + public List<MesCalPlanDO> getPlanListByStatusAndDay(Integer status, LocalDate day) {
  148 + return planMapper.selectListByStatusAndDay(status, day);
  149 + }
  150 +
  151 + @Override
146 public MesCalPlanDO validatePlanPrepare(Long planId) { 152 public MesCalPlanDO validatePlanPrepare(Long planId) {
147 MesCalPlanDO plan = validatePlanExists(planId); 153 MesCalPlanDO plan = validatePlanExists(planId);
148 if (ObjUtil.notEqual(MesCalPlanStatusEnum.PREPARE.getStatus(), plan.getStatus())) { 154 if (ObjUtil.notEqual(MesCalPlanStatusEnum.PREPARE.getStatus(), plan.getStatus())) {
@@ -171,11 +177,13 @@ public class MesCalPlanServiceImpl implements MesCalPlanService { @@ -171,11 +177,13 @@ public class MesCalPlanServiceImpl implements MesCalPlanService {
171 177
172 /** 178 /**
173 * 确认排班计划时,校验班组数量是否与轮班方式匹配 179 * 确认排班计划时,校验班组数量是否与轮班方式匹配
  180 + *
  181 + * 排班只会使用轮班方式所需数量的班组,多出的班组不会参与轮休或替班,因此要求数量一致,避免被静默忽略 https://t.zsxq.com/XdcRW
174 */ 182 */
175 private void validateTeamCountForConfirm(Long planId, Integer shiftType) { 183 private void validateTeamCountForConfirm(Long planId, Integer shiftType) {
176 Long teamCount = planTeamService.getPlanTeamCountByPlanId(planId); 184 Long teamCount = planTeamService.getPlanTeamCountByPlanId(planId);
177 MesCalShiftTypeEnum shiftTypeEnum = MesCalShiftTypeEnum.valueOf(shiftType); 185 MesCalShiftTypeEnum shiftTypeEnum = MesCalShiftTypeEnum.valueOf(shiftType);
178 - if (teamCount < shiftTypeEnum.getRequiredTeamCount()) { 186 + if (teamCount == null || teamCount != shiftTypeEnum.getRequiredTeamCount()) {
179 throw exception(CAL_PLAN_TEAM_COUNT_NOT_MATCH); 187 throw exception(CAL_PLAN_TEAM_COUNT_NOT_MATCH);
180 } 188 }
181 } 189 }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/cal/team/MesCalTeamShiftServiceImpl.java
1 package cn.iocoder.yudao.module.mes.service.cal.team; 1 package cn.iocoder.yudao.module.mes.service.cal.team;
2 2
  3 +import cn.hutool.core.collection.CollUtil;
3 import cn.iocoder.yudao.module.mes.controller.admin.cal.team.vo.shift.MesCalTeamShiftListReqVO; 4 import cn.iocoder.yudao.module.mes.controller.admin.cal.team.vo.shift.MesCalTeamShiftListReqVO;
  5 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO;
4 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; 6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
5 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO; 7 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO;
6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO; 8 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO;
  9 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamDO;
7 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO; 10 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO;
8 import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper; 11 import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper;
  12 +import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum;
9 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum; 13 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum;
10 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum; 14 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum;
  15 +import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService;
11 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService; 16 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService;
12 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService; 17 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService;
13 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService; 18 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService;
@@ -20,14 +25,25 @@ import org.springframework.validation.annotation.Validated; @@ -20,14 +25,25 @@ import org.springframework.validation.annotation.Validated;
20 25
21 import java.time.LocalDate; 26 import java.time.LocalDate;
22 import java.time.LocalDateTime; 27 import java.time.LocalDateTime;
  28 +import java.time.LocalTime;
23 import java.time.temporal.ChronoUnit; 29 import java.time.temporal.ChronoUnit;
24 import java.util.ArrayList; 30 import java.util.ArrayList;
  31 +import java.util.Comparator;
  32 +import java.util.HashMap;
25 import java.util.List; 33 import java.util.List;
  34 +import java.util.Map;
  35 +import java.util.Set;
  36 +import java.util.regex.Matcher;
  37 +import java.util.regex.Pattern;
26 38
27 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 39 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  40 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
  41 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  42 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_REST_INVALID;
28 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_COUNT_INVALID; 43 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_COUNT_INVALID;
29 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_METHOD_INVALID; 44 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_METHOD_INVALID;
30 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH; 45 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH;
  46 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID;
31 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_TEAM_NOT_ENOUGH; 47 import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_TEAM_NOT_ENOUGH;
32 48
33 /** 49 /**
@@ -39,6 +55,11 @@ import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIF @@ -39,6 +55,11 @@ import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIF
39 @Validated 55 @Validated
40 public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { 56 public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService {
41 57
  58 + /**
  59 + * 班次时间格式:H:mm 或 HH:mm
  60 + */
  61 + private static final Pattern SHIFT_TIME_PATTERN = Pattern.compile("^(\\d{1,2}):(\\d{2})$");
  62 +
42 @Resource 63 @Resource
43 private MesCalTeamShiftMapper teamShiftMapper; 64 private MesCalTeamShiftMapper teamShiftMapper;
44 65
@@ -51,6 +72,12 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { @@ -51,6 +72,12 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService {
51 @Resource 72 @Resource
52 @Lazy 73 @Lazy
53 private MesCalPlanTeamService planTeamService; 74 private MesCalPlanTeamService planTeamService;
  75 + @Resource
  76 + @Lazy
  77 + private MesCalHolidayService holidayService;
  78 + @Resource
  79 + @Lazy
  80 + private MesCalTeamService teamService;
54 81
55 @Transactional(rollbackFor = Exception.class) 82 @Transactional(rollbackFor = Exception.class)
56 @Override 83 @Override
@@ -71,25 +98,118 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { @@ -71,25 +98,118 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService {
71 throw exception(CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH); 98 throw exception(CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH);
72 } 99 }
73 100
74 - // 2. 计算日期跨度,遍历每天生成排班记录,最后统一写入数据库 101 + // 2.1 计算日期跨度,查询范围内的节假日
75 LocalDate startDate = plan.getStartDate().toLocalDate(); 102 LocalDate startDate = plan.getStartDate().toLocalDate();
76 LocalDate endDate = plan.getEndDate().toLocalDate(); 103 LocalDate endDate = plan.getEndDate().toLocalDate();
77 long days = ChronoUnit.DAYS.between(startDate, endDate) + 1; 104 long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
  105 + Set<LocalDate> holidayDates = getHolidayDates(startDate, endDate);
  106 + // 2.2 遍历每天生成排班记录,最后统一写入数据库
  107 + // 假期停班:节假日不生成排班,按天倒班只累计有效排班日(假期不消耗倒班天数);按周/月/季度仍按日历周期换班 https://t.zsxq.com/XdcRW
  108 + boolean dayMethod = MesCalShiftMethodEnum.DAY.getMethod().equals(plan.getShiftMethod());
78 List<MesCalTeamShiftDO> allRecords = new ArrayList<>(); 109 List<MesCalTeamShiftDO> allRecords = new ArrayList<>();
79 int shiftIndex = 0; 110 int shiftIndex = 0;
  111 + int workDayOffset = 0;
80 for (int i = 0; i < days; i++) { 112 for (int i = 0; i < days; i++) {
81 LocalDate currentDate = startDate.plusDays(i); 113 LocalDate currentDate = startDate.plusDays(i);
82 LocalDateTime currentDay = currentDate.atStartOfDay(); 114 LocalDateTime currentDay = currentDate.atStartOfDay();
83 - // 2.2.1 根据倒班方式计算 shiftIndex 单白班不倒班,跳过  
84 - if (!MesCalShiftTypeEnum.SINGLE.getType().equals(plan.getShiftType())) {  
85 - shiftIndex = calculateShiftIndex(shiftIndex, i, currentDate, startDate, plan); 115 + boolean holiday = holidayDates.contains(currentDate);
  116 + // 2.2.1 根据倒班方式计算 shiftIndex 单白班不倒班,跳过;周期起点恰逢假期时,也照常换班
  117 + if (!MesCalShiftTypeEnum.SINGLE.getType().equals(plan.getShiftType()) && !(dayMethod && holiday)) {
  118 + shiftIndex = calculateShiftIndex(shiftIndex, dayMethod ? workDayOffset : i, currentDate, startDate, plan);
  119 + }
  120 + if (holiday) {
  121 + continue;
86 } 122 }
  123 + workDayOffset++;
87 // 2.2.2 根据轮班方式生成排班记录,收集到列表 124 // 2.2.2 根据轮班方式生成排班记录,收集到列表
88 buildRecordsForDay(allRecords, planId, currentDay, shiftIndex, plan.getShiftType(), teams, shifts); 125 buildRecordsForDay(allRecords, planId, currentDay, shiftIndex, plan.getShiftType(), teams, shifts);
89 } 126 }
  127 + // 2.3 校验同一班组相邻两次排班之间留有休息间隔 https://t.zsxq.com/XdcRW
  128 + validateTeamShiftRest(allRecords, shifts);
90 129
91 // 3. 批量插入排班记录 130 // 3. 批量插入排班记录
92 - teamShiftMapper.insertBatch(allRecords); 131 + if (CollUtil.isNotEmpty(allRecords)) {
  132 + teamShiftMapper.insertBatch(allRecords);
  133 + }
  134 + }
  135 +
  136 + /**
  137 + * 获得日期范围内的节假日(日期类型为节假日的,排班时停班)
  138 + */
  139 + private Set<LocalDate> getHolidayDates(LocalDate startDate, LocalDate endDate) {
  140 + List<MesCalHolidayDO> holidays = holidayService.getHolidayList(
  141 + startDate.atStartOfDay(), endDate.atTime(23, 59, 59));
  142 + return convertSet(holidays, holiday -> holiday.getDay().toLocalDate(),
  143 + holiday -> MesCalHolidayTypeEnum.HOLIDAY.getType().equals(holiday.getType()));
  144 + }
  145 +
  146 + /**
  147 + * 校验同一班组相邻两次排班之间留有休息间隔
  148 + *
  149 + * 按全部排班的上班开始时刻排序,前一班的结束时刻不早于同一班组下一班的开始时刻,
  150 + * 即上班时间重叠或首尾相接(例如夜班次日 08:00 下班后立即接 08:00 的白班),不允许生成
  151 + */
  152 + private void validateTeamShiftRest(List<MesCalTeamShiftDO> records, List<MesCalPlanShiftDO> shifts) {
  153 + // 1. 解析班次的开始、结束时间
  154 + Map<Long, MesCalPlanShiftDO> shiftMap = convertMap(shifts, MesCalPlanShiftDO::getId);
  155 + Map<Long, LocalTime[]> shiftTimeMap = convertMap(shifts, MesCalPlanShiftDO::getId,
  156 + shift -> new LocalTime[]{parseShiftTime(shift, shift.getStartTime(), false),
  157 + parseShiftTime(shift, shift.getEndTime(), true)});
  158 +
  159 + // 2. 按上班开始时刻排序,逐条与同一班组的上一次排班比较
  160 + List<MesCalTeamShiftDO> sortedRecords = new ArrayList<>(records);
  161 + sortedRecords.sort(Comparator.comparing(record -> buildShiftTimeRange(record, shiftTimeMap)[0]));
  162 + Map<Long, MesCalTeamShiftDO> lastRecordMap = new HashMap<>();
  163 + for (MesCalTeamShiftDO record : sortedRecords) {
  164 + MesCalTeamShiftDO lastRecord = lastRecordMap.put(record.getTeamId(), record);
  165 + if (lastRecord == null || buildShiftTimeRange(lastRecord, shiftTimeMap)[1]
  166 + .isBefore(buildShiftTimeRange(record, shiftTimeMap)[0])) {
  167 + continue;
  168 + }
  169 + MesCalTeamDO team = teamService.getTeam(record.getTeamId());
  170 + throw exception(CAL_TEAM_SHIFT_GENERATE_REST_INVALID, team != null ? team.getName() : record.getTeamId(),
  171 + lastRecord.getDay().toLocalDate(), shiftMap.get(lastRecord.getShiftId()).getName(),
  172 + record.getDay().toLocalDate(), shiftMap.get(record.getShiftId()).getName());
  173 + }
  174 + }
  175 +
  176 + /**
  177 + * 计算排班记录的实际上班时间段
  178 + *
  179 + * 开始时间在排班日期当天;结束时间不晚于开始时间(例如 20:00~08:00、16:00~00:00)时,跨午夜到次日结束
  180 + *
  181 + * @return [开始时刻, 结束时刻]
  182 + */
  183 + private static LocalDateTime[] buildShiftTimeRange(MesCalTeamShiftDO record, Map<Long, LocalTime[]> shiftTimeMap) {
  184 + LocalTime[] shiftTime = shiftTimeMap.get(record.getShiftId());
  185 + LocalDate day = record.getDay().toLocalDate();
  186 + LocalDateTime start = day.atTime(shiftTime[0]);
  187 + LocalDateTime end = shiftTime[1].isAfter(shiftTime[0]) ? day.atTime(shiftTime[1])
  188 + : day.plusDays(1).atTime(shiftTime[1]);
  189 + return new LocalDateTime[]{start, end};
  190 + }
  191 +
  192 + /**
  193 + * 解析班次时间,兼容 H:mm(例如 8:00)
  194 + *
  195 + * 结束时间允许 24:00,表示当天 24:00,按 00:00 处理后由“结束时间不晚于开始时间时跨午夜到次日”规则得到次日 00:00 https://t.zsxq.com/XdcRW
  196 + *
  197 + * @param endTime 是否为结束时间
  198 + */
  199 + private static LocalTime parseShiftTime(MesCalPlanShiftDO shift, String time, boolean endTime) {
  200 + Matcher matcher = time != null ? SHIFT_TIME_PATTERN.matcher(time.trim()) : null;
  201 + if (matcher == null || !matcher.matches()) {
  202 + throw exception(CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID, shift.getName());
  203 + }
  204 + int hour = Integer.parseInt(matcher.group(1));
  205 + int minute = Integer.parseInt(matcher.group(2));
  206 + if (endTime && hour == 24 && minute == 0) {
  207 + return LocalTime.MIDNIGHT;
  208 + }
  209 + if (hour > 23 || minute > 59) {
  210 + throw exception(CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID, shift.getName());
  211 + }
  212 + return LocalTime.of(hour, minute);
93 } 213 }
94 214
95 /** 215 /**
@@ -180,7 +300,7 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { @@ -180,7 +300,7 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService {
180 .sort(1).build()); 300 .sort(1).build());
181 } 301 }
182 } else if (MesCalShiftTypeEnum.THREE.getType().equals(shiftType)) { 302 } else if (MesCalShiftTypeEnum.THREE.getType().equals(shiftType)) {
183 - // 三班倒 303 + // 三班倒:按 白班 → 夜班 → 中班 轮转。原 白班 → 中班 → 夜班 轮转时,换班当天中班 24:00 下班的班组会立即接 00:00 的夜班 https://t.zsxq.com/XdcRW
184 if (shiftIndex % 3 == 0) { 304 if (shiftIndex % 3 == 0) {
185 // A 组上白班、B 组上中班、C 组上夜班 305 // A 组上白班、B 组上中班、C 组上夜班
186 allRecords.add(MesCalTeamShiftDO.builder() 306 allRecords.add(MesCalTeamShiftDO.builder()
@@ -199,39 +319,39 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { @@ -199,39 +319,39 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService {
199 .shiftId(shifts.get(2).getId()) 319 .shiftId(shifts.get(2).getId())
200 .sort(3).build()); 320 .sort(3).build());
201 } else if (shiftIndex % 3 == 1) { 321 } else if (shiftIndex % 3 == 1) {
202 - // A 组上中班、B 组上夜班、C 组上白班 322 + // A 组上夜班、B 组上白班、C 组上中班
203 allRecords.add(MesCalTeamShiftDO.builder() 323 allRecords.add(MesCalTeamShiftDO.builder()
204 .planId(planId).day(currentDay) 324 .planId(planId).day(currentDay)
205 .teamId(teams.get(0).getTeamId()) 325 .teamId(teams.get(0).getTeamId())
206 - .shiftId(shifts.get(1).getId())  
207 - .sort(2).build());  
208 - allRecords.add(MesCalTeamShiftDO.builder()  
209 - .planId(planId).day(currentDay)  
210 - .teamId(teams.get(1).getTeamId())  
211 .shiftId(shifts.get(2).getId()) 326 .shiftId(shifts.get(2).getId())
212 .sort(3).build()); 327 .sort(3).build());
213 allRecords.add(MesCalTeamShiftDO.builder() 328 allRecords.add(MesCalTeamShiftDO.builder()
214 .planId(planId).day(currentDay) 329 .planId(planId).day(currentDay)
215 - .teamId(teams.get(2).getTeamId()) 330 + .teamId(teams.get(1).getTeamId())
216 .shiftId(shifts.get(0).getId()) 331 .shiftId(shifts.get(0).getId())
217 .sort(1).build()); 332 .sort(1).build());
  333 + allRecords.add(MesCalTeamShiftDO.builder()
  334 + .planId(planId).day(currentDay)
  335 + .teamId(teams.get(2).getTeamId())
  336 + .shiftId(shifts.get(1).getId())
  337 + .sort(2).build());
218 } else { 338 } else {
219 - // A 组上夜班、B 组上白班、C 组上中班 339 + // A 组上中班、B 组上夜班、C 组上白班
220 allRecords.add(MesCalTeamShiftDO.builder() 340 allRecords.add(MesCalTeamShiftDO.builder()
221 .planId(planId).day(currentDay) 341 .planId(planId).day(currentDay)
222 .teamId(teams.get(0).getTeamId()) 342 .teamId(teams.get(0).getTeamId())
223 - .shiftId(shifts.get(2).getId())  
224 - .sort(3).build()); 343 + .shiftId(shifts.get(1).getId())
  344 + .sort(2).build());
225 allRecords.add(MesCalTeamShiftDO.builder() 345 allRecords.add(MesCalTeamShiftDO.builder()
226 .planId(planId).day(currentDay) 346 .planId(planId).day(currentDay)
227 .teamId(teams.get(1).getTeamId()) 347 .teamId(teams.get(1).getTeamId())
228 - .shiftId(shifts.get(0).getId())  
229 - .sort(1).build()); 348 + .shiftId(shifts.get(2).getId())
  349 + .sort(3).build());
230 allRecords.add(MesCalTeamShiftDO.builder() 350 allRecords.add(MesCalTeamShiftDO.builder()
231 .planId(planId).day(currentDay) 351 .planId(planId).day(currentDay)
232 .teamId(teams.get(2).getTeamId()) 352 .teamId(teams.get(2).getTeamId())
233 - .shiftId(shifts.get(1).getId())  
234 - .sort(2).build()); 353 + .shiftId(shifts.get(0).getId())
  354 + .sort(1).build());
235 } 355 }
236 } 356 }
237 } 357 }
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/holiday/MesCalHolidayServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.service.cal.holiday;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  4 +import cn.iocoder.yudao.module.mes.controller.admin.cal.holiday.vo.MesCalHolidaySaveReqVO;
  5 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO;
  6 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
  7 +import cn.iocoder.yudao.module.mes.dal.mysql.cal.holiday.MesCalHolidayMapper;
  8 +import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum;
  9 +import cn.iocoder.yudao.module.mes.enums.cal.MesCalPlanStatusEnum;
  10 +import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService;
  11 +import jakarta.annotation.Resource;
  12 +import org.junit.jupiter.api.Test;
  13 +import org.springframework.context.annotation.Import;
  14 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  15 +
  16 +import java.time.LocalDateTime;
  17 +
  18 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
  19 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  20 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  21 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_HOLIDAY_PLAN_CONFIRMED;
  22 +import static java.util.Collections.emptyList;
  23 +import static java.util.Collections.singletonList;
  24 +import static org.junit.jupiter.api.Assertions.*;
  25 +import static org.mockito.Mockito.*;
  26 +
  27 +/**
  28 + * {@link MesCalHolidayServiceImpl} 的单元测试
  29 + *
  30 + * @author 芋道源码
  31 + */
  32 +@Import(MesCalHolidayServiceImpl.class)
  33 +public class MesCalHolidayServiceImplTest extends BaseDbUnitTest {
  34 +
  35 + @Resource
  36 + private MesCalHolidayServiceImpl holidayService;
  37 +
  38 + @Resource
  39 + private MesCalHolidayMapper holidayMapper;
  40 +
  41 + @MockitoBean
  42 + private MesCalPlanService planService;
  43 +
  44 + @Test
  45 + public void testSaveHoliday_addHolidayInConfirmedPlan() {
  46 + // 准备参数
  47 + MesCalHolidaySaveReqVO reqVO = randomPojo(MesCalHolidaySaveReqVO.class, o -> {
  48 + o.setId(null); // 避免 id 被赋值
  49 + o.setDay(LocalDateTime.of(2026, 10, 20, 0, 0));
  50 + o.setType(MesCalHolidayTypeEnum.HOLIDAY.getType());
  51 + });
  52 + // mock planService 的方法:该日期在已确认的排班计划范围内
  53 + MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> o.setStatus(MesCalPlanStatusEnum.CONFIRMED.getStatus()));
  54 + when(planService.getPlanListByStatusAndDay(eq(MesCalPlanStatusEnum.CONFIRMED.getStatus()),
  55 + eq(reqVO.getDay().toLocalDate()))).thenReturn(singletonList(plan));
  56 +
  57 + // 调用,并断言异常
  58 + assertServiceException(() -> holidayService.saveHoliday(reqVO), CAL_HOLIDAY_PLAN_CONFIRMED,
  59 + reqVO.getDay().toLocalDate(), plan.getName());
  60 + // 断言:未写入假期设置
  61 + assertNull(holidayMapper.selectByDay(reqVO.getDay()));
  62 + }
  63 +
  64 + @Test
  65 + public void testSaveHoliday_cancelHolidayInConfirmedPlan() {
  66 + // mock 数据:已有节假日
  67 + MesCalHolidayDO dbHoliday = randomPojo(MesCalHolidayDO.class, o -> {
  68 + o.setId(null); // 避免 id 被赋值
  69 + o.setDay(LocalDateTime.of(2026, 10, 20, 0, 0));
  70 + o.setType(MesCalHolidayTypeEnum.HOLIDAY.getType());
  71 + });
  72 + holidayMapper.insert(dbHoliday);
  73 + // 准备参数:改为工作日
  74 + MesCalHolidaySaveReqVO reqVO = randomPojo(MesCalHolidaySaveReqVO.class, o -> {
  75 + o.setId(null); // 避免 id 被赋值
  76 + o.setDay(dbHoliday.getDay());
  77 + o.setType(MesCalHolidayTypeEnum.WORKDAY.getType());
  78 + });
  79 + // mock planService 的方法:该日期在已确认的排班计划范围内
  80 + MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> o.setStatus(MesCalPlanStatusEnum.CONFIRMED.getStatus()));
  81 + when(planService.getPlanListByStatusAndDay(eq(MesCalPlanStatusEnum.CONFIRMED.getStatus()),
  82 + eq(reqVO.getDay().toLocalDate()))).thenReturn(singletonList(plan));
  83 +
  84 + // 调用,并断言异常
  85 + assertServiceException(() -> holidayService.saveHoliday(reqVO), CAL_HOLIDAY_PLAN_CONFIRMED,
  86 + reqVO.getDay().toLocalDate(), plan.getName());
  87 + // 断言:假期设置未被修改
  88 + assertPojoEquals(dbHoliday, holidayMapper.selectById(dbHoliday.getId()));
  89 + }
  90 +
  91 + @Test
  92 + public void testSaveHoliday_updateRemarkInConfirmedPlan() {
  93 + // mock 数据:已有节假日
  94 + MesCalHolidayDO dbHoliday = randomPojo(MesCalHolidayDO.class, o -> {
  95 + o.setId(null); // 避免 id 被赋值
  96 + o.setDay(LocalDateTime.of(2026, 10, 20, 0, 0));
  97 + o.setType(MesCalHolidayTypeEnum.HOLIDAY.getType());
  98 + });
  99 + holidayMapper.insert(dbHoliday);
  100 + // 准备参数:仍为节假日,只修改备注
  101 + MesCalHolidaySaveReqVO reqVO = randomPojo(MesCalHolidaySaveReqVO.class, o -> {
  102 + o.setId(null); // 避免 id 被赋值
  103 + o.setDay(dbHoliday.getDay());
  104 + o.setType(MesCalHolidayTypeEnum.HOLIDAY.getType());
  105 + });
  106 +
  107 + // 调用
  108 + Long id = holidayService.saveHoliday(reqVO);
  109 + // 断言:更新已有记录,且是否节假日未变化,无需校验排班计划
  110 + assertEquals(dbHoliday.getId(), id);
  111 + assertPojoEquals(reqVO, holidayMapper.selectById(id), "id");
  112 + verify(planService, never()).getPlanListByStatusAndDay(any(), any());
  113 + }
  114 +
  115 + @Test
  116 + public void testSaveHoliday_createWithoutConfirmedPlan() {
  117 + // 准备参数
  118 + MesCalHolidaySaveReqVO reqVO = randomPojo(MesCalHolidaySaveReqVO.class, o -> {
  119 + o.setId(null); // 避免 id 被赋值
  120 + o.setDay(LocalDateTime.of(2026, 10, 20, 0, 0));
  121 + o.setType(MesCalHolidayTypeEnum.HOLIDAY.getType());
  122 + });
  123 + // mock planService 的方法:该日期没有已确认的排班计划
  124 + when(planService.getPlanListByStatusAndDay(eq(MesCalPlanStatusEnum.CONFIRMED.getStatus()),
  125 + eq(reqVO.getDay().toLocalDate()))).thenReturn(emptyList());
  126 +
  127 + // 调用
  128 + Long id = holidayService.saveHoliday(reqVO);
  129 + // 断言
  130 + assertPojoEquals(reqVO, holidayMapper.selectById(id), "id");
  131 + }
  132 +
  133 +}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java
1 package cn.iocoder.yudao.module.mes.service.cal.plan; 1 package cn.iocoder.yudao.module.mes.service.cal.plan;
2 2
  3 +import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; 4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
4 import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.MesCalPlanSaveReqVO; 5 import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.MesCalPlanSaveReqVO;
5 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; 6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
@@ -14,9 +15,17 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -14,9 +15,17 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
14 15
15 import jakarta.annotation.Resource; 16 import jakarta.annotation.Resource;
16 17
  18 +import java.time.LocalDate;
17 import java.time.LocalDateTime; 19 import java.time.LocalDateTime;
  20 +import java.util.List;
18 21
  22 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  23 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  24 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
19 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; 25 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  26 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_PLAN_NOT_PREPARE;
  27 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_PLAN_TEAM_COUNT_NOT_MATCH;
  28 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_REST_INVALID;
20 import static org.junit.jupiter.api.Assertions.*; 29 import static org.junit.jupiter.api.Assertions.*;
21 import static org.mockito.Mockito.*; 30 import static org.mockito.Mockito.*;
22 31
@@ -88,6 +97,85 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest { @@ -88,6 +97,85 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest {
88 assertEquals(MesCalPlanStatusEnum.CONFIRMED.getStatus(), updatePlan.getStatus()); 97 assertEquals(MesCalPlanStatusEnum.CONFIRMED.getStatus(), updatePlan.getStatus());
89 // 断言 2:触发生成班组排班记录 98 // 断言 2:触发生成班组排班记录
90 verify(teamShiftService).generateTeamShiftRecords(plan.getId()); 99 verify(teamShiftService).generateTeamShiftRecords(plan.getId());
  100 + // 断言 3:重复确认被拒绝,且不会再次生成班组排班记录
  101 + assertServiceException(() -> planService.confirmPlan(plan.getId()), CAL_PLAN_NOT_PREPARE);
  102 + verify(teamShiftService, times(1)).generateTeamShiftRecords(plan.getId());
  103 + }
  104 +
  105 + @Test
  106 + public void testConfirmPlan_generateFailed_rollbackStatus() {
  107 + // mock 数据:插入一条草稿状态的两班倒排班计划
  108 + MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> {
  109 + o.setCalendarType(1);
  110 + o.setStartDate(LocalDateTime.of(2026, 10, 19, 0, 0));
  111 + o.setEndDate(LocalDateTime.of(2026, 10, 21, 0, 0));
  112 + o.setStatus(MesCalPlanStatusEnum.PREPARE.getStatus());
  113 + o.setShiftType(MesCalShiftTypeEnum.TWO.getType());
  114 + o.setShiftMethod(MesCalShiftMethodEnum.DAY.getMethod());
  115 + o.setShiftCount(1);
  116 + });
  117 + planMapper.insert(plan);
  118 + // mock 方法:班组数量满足要求,但生成排班时校验到没有休息间隔
  119 + when(planTeamService.getPlanTeamCountByPlanId(plan.getId())).thenReturn(2L);
  120 + doThrow(exception(CAL_TEAM_SHIFT_GENERATE_REST_INVALID, "乙班",
  121 + LocalDate.of(2026, 10, 19), "夜班", LocalDate.of(2026, 10, 20), "白班"))
  122 + .when(teamShiftService).generateTeamShiftRecords(plan.getId());
  123 +
  124 + // 调用,并断言异常
  125 + assertServiceException(() -> planService.confirmPlan(plan.getId()), CAL_TEAM_SHIFT_GENERATE_REST_INVALID,
  126 + "乙班", LocalDate.of(2026, 10, 19), "夜班", LocalDate.of(2026, 10, 20), "白班");
  127 + // 断言:事务回滚,计划仍为草稿
  128 + MesCalPlanDO dbPlan = planMapper.selectById(plan.getId());
  129 + assertEquals(MesCalPlanStatusEnum.PREPARE.getStatus(), dbPlan.getStatus());
  130 + }
  131 +
  132 + @Test
  133 + public void testConfirmPlan_teamCountExceed() {
  134 + // mock 数据:插入一条草稿状态的两班倒排班计划
  135 + MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> {
  136 + o.setCalendarType(1);
  137 + o.setStartDate(LocalDateTime.of(2026, 10, 19, 0, 0));
  138 + o.setEndDate(LocalDateTime.of(2026, 10, 21, 0, 0));
  139 + o.setStatus(MesCalPlanStatusEnum.PREPARE.getStatus());
  140 + o.setShiftType(MesCalShiftTypeEnum.TWO.getType());
  141 + o.setShiftMethod(MesCalShiftMethodEnum.DAY.getMethod());
  142 + o.setShiftCount(1);
  143 + });
  144 + planMapper.insert(plan);
  145 + // mock 方法:分配了 3 个班组,多出的班组不会参与排班
  146 + when(planTeamService.getPlanTeamCountByPlanId(plan.getId())).thenReturn(3L);
  147 +
  148 + // 调用,并断言异常
  149 + assertServiceException(() -> planService.confirmPlan(plan.getId()), CAL_PLAN_TEAM_COUNT_NOT_MATCH);
  150 + // 断言:计划仍为草稿,且未生成排班记录
  151 + MesCalPlanDO dbPlan = planMapper.selectById(plan.getId());
  152 + assertEquals(MesCalPlanStatusEnum.PREPARE.getStatus(), dbPlan.getStatus());
  153 + verify(teamShiftService, never()).generateTeamShiftRecords(plan.getId());
  154 + }
  155 +
  156 + @Test
  157 + public void testGetPlanListByStatusAndDay() {
  158 + // mock 数据:已确认,结束日期为当天
  159 + MesCalPlanDO plan01 = randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0),
  160 + LocalDateTime.of(2026, 10, 19, 0, 0), MesCalPlanStatusEnum.CONFIRMED.getStatus());
  161 + planMapper.insert(plan01);
  162 + // mock 数据:已确认,开始日期为当天
  163 + MesCalPlanDO plan02 = randomPlan(LocalDateTime.of(2026, 10, 19, 0, 0),
  164 + LocalDateTime.of(2026, 10, 31, 0, 0), MesCalPlanStatusEnum.CONFIRMED.getStatus());
  165 + planMapper.insert(plan02);
  166 + // mock 数据:已确认,但不包含当天
  167 + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 20, 0, 0),
  168 + LocalDateTime.of(2026, 10, 31, 0, 0), MesCalPlanStatusEnum.CONFIRMED.getStatus()));
  169 + // mock 数据:包含当天,但为草稿
  170 + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0),
  171 + LocalDateTime.of(2026, 10, 31, 0, 0), MesCalPlanStatusEnum.PREPARE.getStatus()));
  172 +
  173 + // 调用
  174 + List<MesCalPlanDO> list = planService.getPlanListByStatusAndDay(
  175 + MesCalPlanStatusEnum.CONFIRMED.getStatus(), LocalDate.of(2026, 10, 19));
  176 +
  177 + // 断言:只返回包含当天的已确认计划(起止日期为当天也算)
  178 + assertEquals(SetUtils.asSet(plan01.getId(), plan02.getId()), convertSet(list, MesCalPlanDO::getId));
91 } 179 }
92 180
93 @Test 181 @Test
@@ -116,4 +204,16 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest { @@ -116,4 +204,16 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest {
116 verify(teamShiftService, never()).deleteByPlanId(plan.getId()); 204 verify(teamShiftService, never()).deleteByPlanId(plan.getId());
117 } 205 }
118 206
  207 + private static MesCalPlanDO randomPlan(LocalDateTime startDate, LocalDateTime endDate, Integer status) {
  208 + return randomPojo(MesCalPlanDO.class, o -> {
  209 + o.setCalendarType(1);
  210 + o.setStartDate(startDate);
  211 + o.setEndDate(endDate);
  212 + o.setStatus(status);
  213 + o.setShiftType(MesCalShiftTypeEnum.TWO.getType());
  214 + o.setShiftMethod(MesCalShiftMethodEnum.DAY.getMethod());
  215 + o.setShiftCount(1);
  216 + });
  217 + }
  218 +
119 } 219 }
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/team/MesCalTeamShiftServiceImplTest.java
@@ -2,13 +2,17 @@ package cn.iocoder.yudao.module.mes.service.cal.team; @@ -2,13 +2,17 @@ package cn.iocoder.yudao.module.mes.service.cal.team;
2 2
3 import cn.hutool.core.collection.ListUtil; 3 import cn.hutool.core.collection.ListUtil;
4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; 4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  5 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO;
5 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; 6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO;
6 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO; 7 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO;
7 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO; 8 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO;
  9 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamDO;
8 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO; 10 import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO;
9 import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper; 11 import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper;
  12 +import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum;
10 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum; 13 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum;
11 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum; 14 import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum;
  15 +import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService;
12 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService; 16 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService;
13 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService; 17 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService;
14 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService; 18 import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService;
@@ -18,10 +22,15 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -18,10 +22,15 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
18 22
19 import jakarta.annotation.Resource; 23 import jakarta.annotation.Resource;
20 24
  25 +import java.time.LocalDate;
21 import java.time.LocalDateTime; 26 import java.time.LocalDateTime;
  27 +import java.util.ArrayList;
22 import java.util.Comparator; 28 import java.util.Comparator;
23 import java.util.List; 29 import java.util.List;
24 30
  31 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  32 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_REST_INVALID;
  33 +import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID;
25 import static org.junit.jupiter.api.Assertions.*; 34 import static org.junit.jupiter.api.Assertions.*;
26 import static org.mockito.Mockito.*; 35 import static org.mockito.Mockito.*;
27 36
@@ -45,6 +54,10 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest { @@ -45,6 +54,10 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest {
45 private MesCalPlanShiftService planShiftService; 54 private MesCalPlanShiftService planShiftService;
46 @MockitoBean 55 @MockitoBean
47 private MesCalPlanTeamService planTeamService; 56 private MesCalPlanTeamService planTeamService;
  57 + @MockitoBean
  58 + private MesCalHolidayService holidayService;
  59 + @MockitoBean
  60 + private MesCalTeamService teamService;
48 61
49 @Test 62 @Test
50 public void testGenerateTeamShiftRecords_threeShift_rotateThreeStates() { 63 public void testGenerateTeamShiftRecords_threeShift_rotateThreeStates() {
@@ -79,22 +92,22 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest { @@ -79,22 +92,22 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest {
79 // 调用 92 // 调用
80 teamShiftService.generateTeamShiftRecords(planId); 93 teamShiftService.generateTeamShiftRecords(planId);
81 94
82 - // 断言:3 天 × 3 班次 = 9 条排班记录,班组按天轮转  
83 - List<MesCalTeamShiftDO> records = teamShiftMapper.selectListByPlanId(planId);  
84 - records.sort(Comparator.comparing(MesCalTeamShiftDO::getDay).thenComparing(MesCalTeamShiftDO::getSort)); 95 + // 断言:3 天 × 3 班次 = 9 条排班记录,班组按 白班 → 夜班 → 中班 轮转
  96 + // 说明:原先按 白班 → 中班 → 夜班 轮转时,B 组 4/1 中班 24:00 下班后会立即接 4/2 00:00 的夜班,没有休息间隔
  97 + List<MesCalTeamShiftDO> records = getRecords(planId);
85 assertEquals(9, records.size()); 98 assertEquals(9, records.size());
86 // 断言:第 1 天(shiftIndex=0):A 白班、B 中班、C 夜班 99 // 断言:第 1 天(shiftIndex=0):A 白班、B 中班、C 夜班
87 assertRecord(records.get(0), LocalDateTime.of(2026, 4, 1, 0, 0), 1, 101L, 11L); 100 assertRecord(records.get(0), LocalDateTime.of(2026, 4, 1, 0, 0), 1, 101L, 11L);
88 assertRecord(records.get(1), LocalDateTime.of(2026, 4, 1, 0, 0), 2, 102L, 12L); 101 assertRecord(records.get(1), LocalDateTime.of(2026, 4, 1, 0, 0), 2, 102L, 12L);
89 assertRecord(records.get(2), LocalDateTime.of(2026, 4, 1, 0, 0), 3, 103L, 13L); 102 assertRecord(records.get(2), LocalDateTime.of(2026, 4, 1, 0, 0), 3, 103L, 13L);
90 - // 断言:第 2 天(shiftIndex=1):C 白班、A 中班、B 夜班  
91 - assertRecord(records.get(3), LocalDateTime.of(2026, 4, 2, 0, 0), 1, 103L, 11L);  
92 - assertRecord(records.get(4), LocalDateTime.of(2026, 4, 2, 0, 0), 2, 101L, 12L);  
93 - assertRecord(records.get(5), LocalDateTime.of(2026, 4, 2, 0, 0), 3, 102L, 13L);  
94 - // 断言:第 3 天(shiftIndex=2):B 白班、C 中班、A 夜班  
95 - assertRecord(records.get(6), LocalDateTime.of(2026, 4, 3, 0, 0), 1, 102L, 11L);  
96 - assertRecord(records.get(7), LocalDateTime.of(2026, 4, 3, 0, 0), 2, 103L, 12L);  
97 - assertRecord(records.get(8), LocalDateTime.of(2026, 4, 3, 0, 0), 3, 101L, 13L); 103 + // 断言:第 2 天(shiftIndex=1):B 白班、C 中班、A 夜班(A、B 休息 8 小时,C 休息 32 小时)
  104 + assertRecord(records.get(3), LocalDateTime.of(2026, 4, 2, 0, 0), 1, 102L, 11L);
  105 + assertRecord(records.get(4), LocalDateTime.of(2026, 4, 2, 0, 0), 2, 103L, 12L);
  106 + assertRecord(records.get(5), LocalDateTime.of(2026, 4, 2, 0, 0), 3, 101L, 13L);
  107 + // 断言:第 3 天(shiftIndex=2):C 白班、A 中班、B 夜班
  108 + assertRecord(records.get(6), LocalDateTime.of(2026, 4, 3, 0, 0), 1, 103L, 11L);
  109 + assertRecord(records.get(7), LocalDateTime.of(2026, 4, 3, 0, 0), 2, 101L, 12L);
  110 + assertRecord(records.get(8), LocalDateTime.of(2026, 4, 3, 0, 0), 3, 102L, 13L);
98 } 111 }
99 112
100 @Test 113 @Test
@@ -121,6 +134,392 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest { @@ -121,6 +134,392 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest {
121 assertRecord(records.get(1), start.plusDays(1), 1, 101L, 11L); 134 assertRecord(records.get(1), start.plusDays(1), 1, 101L, 11L);
122 } 135 }
123 136
  137 + @Test
  138 + public void testGenerateTeamShiftRecords_singleCrossMidnight() {
  139 + // 准备参数
  140 + Long planId = 3003L;
  141 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  142 + LocalDate endDate = LocalDate.of(2026, 10, 21);
  143 + // mock 方法:单班次 22:00~06:00,跨午夜到次日结束,每天之间休息 16 小时
  144 + mockPlan(planId, MesCalShiftTypeEnum.SINGLE, null, null, startDate, endDate);
  145 + when(planShiftService.getPlanShiftListByPlanId(planId)).thenReturn(ListUtil.of(
  146 + MesCalPlanShiftDO.builder().id(11L).planId(planId).sort(1).name("夜班")
  147 + .startTime("22:00").endTime("06:00").build()));
  148 + mockTeams(planId, 101L);
  149 +
  150 + // 调用
  151 + teamShiftService.generateTeamShiftRecords(planId);
  152 +
  153 + // 断言:每天 1 条排班记录
  154 + List<MesCalTeamShiftDO> records = getRecords(planId);
  155 + assertEquals(3, records.size());
  156 + assertRecord(records.get(0), startDate.atStartOfDay(), 1, 101L, 11L);
  157 + assertRecord(records.get(1), startDate.plusDays(1).atStartOfDay(), 1, 101L, 11L);
  158 + assertRecord(records.get(2), endDate.atStartOfDay(), 1, 101L, 11L);
  159 + }
  160 +
  161 + @Test
  162 + public void testGenerateTeamShiftRecords_twoShiftFullDay_rotateWithoutRest() {
  163 + // 准备参数
  164 + Long planId = 3004L;
  165 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  166 + LocalDate endDate = LocalDate.of(2026, 10, 21);
  167 + // mock 方法:两班倒、按天倒班 1 天,默认班次 08:00~20:00 / 20:00~08:00 覆盖全天
  168 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, startDate, endDate);
  169 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "08:00");
  170 + mockTeams(planId, 101L, 102L);
  171 + when(teamService.getTeam(eq(102L))).thenReturn(MesCalTeamDO.builder().id(102L).name("乙班").build());
  172 +
  173 + // 调用,并断言异常:B 组 10-19 夜班次日 08:00 下班后,立即接 10-20 08:00 的白班,连续上班 24 小时
  174 + assertServiceException(() -> teamShiftService.generateTeamShiftRecords(planId),
  175 + CAL_TEAM_SHIFT_GENERATE_REST_INVALID, "乙班",
  176 + LocalDate.of(2026, 10, 19), "夜班", LocalDate.of(2026, 10, 20), "白班");
  177 + // 断言:没有写入任何排班记录
  178 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  179 + }
  180 +
  181 + @Test
  182 + public void testGenerateTeamShiftRecords_twoShiftFullDay_longerShiftCountOnlyDelay() {
  183 + // 准备参数
  184 + Long planId = 3005L;
  185 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  186 + LocalDate endDate = LocalDate.of(2026, 10, 23);
  187 + // mock 方法:两班倒、按天倒班 3 天,默认班次覆盖全天
  188 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 3, startDate, endDate);
  189 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "08:00");
  190 + mockTeams(planId, 101L, 102L);
  191 + when(teamService.getTeam(eq(102L))).thenReturn(MesCalTeamDO.builder().id(102L).name("乙班").build());
  192 +
  193 + // 调用,并断言异常:增加倒班天数只是推迟到 10-22 换班,B 组仍然夜班下班后立即接白班
  194 + assertServiceException(() -> teamShiftService.generateTeamShiftRecords(planId),
  195 + CAL_TEAM_SHIFT_GENERATE_REST_INVALID, "乙班",
  196 + LocalDate.of(2026, 10, 21), "夜班", LocalDate.of(2026, 10, 22), "白班");
  197 + // 断言:没有写入任何排班记录
  198 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  199 + }
  200 +
  201 + @Test
  202 + public void testGenerateTeamShiftRecords_twoShiftOverlap() {
  203 + // 准备参数
  204 + Long planId = 3006L;
  205 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  206 + LocalDate endDate = LocalDate.of(2026, 10, 20);
  207 + // mock 方法:两班倒、按天倒班 1 天,夜班 20:00~08:30 与次日 08:00 的白班重叠
  208 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, startDate, endDate);
  209 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "08:30");
  210 + mockTeams(planId, 101L, 102L);
  211 + when(teamService.getTeam(eq(102L))).thenReturn(MesCalTeamDO.builder().id(102L).name("乙班").build());
  212 +
  213 + // 调用,并断言异常
  214 + assertServiceException(() -> teamShiftService.generateTeamShiftRecords(planId),
  215 + CAL_TEAM_SHIFT_GENERATE_REST_INVALID, "乙班",
  216 + LocalDate.of(2026, 10, 19), "夜班", LocalDate.of(2026, 10, 20), "白班");
  217 + // 断言:没有写入任何排班记录
  218 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  219 + }
  220 +
  221 + @Test
  222 + public void testGenerateTeamShiftRecords_twoShiftOneMinuteRest() {
  223 + // 准备参数
  224 + Long planId = 3007L;
  225 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  226 + LocalDate endDate = LocalDate.of(2026, 10, 21);
  227 + // mock 方法:两班倒、按天倒班 1 天,夜班 20:00~07:59,与次日 08:00 的白班之间留 1 分钟
  228 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, startDate, endDate);
  229 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "07:59");
  230 + mockTeams(planId, 101L, 102L);
  231 +
  232 + // 调用
  233 + teamShiftService.generateTeamShiftRecords(planId);
  234 +
  235 + // 断言:3 天 × 2 班次 = 6 条排班记录,每天换班
  236 + List<MesCalTeamShiftDO> records = getRecords(planId);
  237 + assertEquals(6, records.size());
  238 + assertRecord(records.get(0), LocalDateTime.of(2026, 10, 19, 0, 0), 1, 101L, 11L);
  239 + assertRecord(records.get(1), LocalDateTime.of(2026, 10, 19, 0, 0), 2, 102L, 12L);
  240 + assertRecord(records.get(2), LocalDateTime.of(2026, 10, 20, 0, 0), 1, 102L, 11L);
  241 + assertRecord(records.get(3), LocalDateTime.of(2026, 10, 20, 0, 0), 2, 101L, 12L);
  242 + assertRecord(records.get(4), LocalDateTime.of(2026, 10, 21, 0, 0), 1, 101L, 11L);
  243 + assertRecord(records.get(5), LocalDateTime.of(2026, 10, 21, 0, 0), 2, 102L, 12L);
  244 + }
  245 +
  246 + @Test
  247 + public void testGenerateTeamShiftRecords_twoShiftFullDay_singleDay() {
  248 + // 准备参数
  249 + Long planId = 3008L;
  250 + LocalDate day = LocalDate.of(2026, 10, 19);
  251 + // mock 方法:两班倒、默认班次覆盖全天,只排 1 天,不发生换班
  252 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, day, day);
  253 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "08:00");
  254 + mockTeams(planId, 101L, 102L);
  255 +
  256 + // 调用
  257 + teamShiftService.generateTeamShiftRecords(planId);
  258 +
  259 + // 断言:1 天 × 2 班次 = 2 条排班记录
  260 + List<MesCalTeamShiftDO> records = getRecords(planId);
  261 + assertEquals(2, records.size());
  262 + assertRecord(records.get(0), day.atStartOfDay(), 1, 101L, 11L);
  263 + assertRecord(records.get(1), day.atStartOfDay(), 2, 102L, 12L);
  264 + }
  265 +
  266 + @Test
  267 + public void testGenerateTeamShiftRecords_shiftTimeInvalid() {
  268 + // 准备参数
  269 + Long planId = 3009L;
  270 + LocalDate day = LocalDate.of(2026, 10, 19);
  271 + // mock 方法:白班结束时间不合法
  272 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, day, day);
  273 + mockTwoShifts(planId, "08:00", "25:00", "20:00", "08:00");
  274 + mockTeams(planId, 101L, 102L);
  275 +
  276 + // 调用,并断言异常
  277 + assertServiceException(() -> teamShiftService.generateTeamShiftRecords(planId),
  278 + CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID, "白班");
  279 + // 断言:没有写入任何排班记录
  280 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  281 + }
  282 +
  283 + @Test
  284 + public void testGenerateTeamShiftRecords_threeShift_hourWithoutZeroAndEnd24() {
  285 + // 准备参数
  286 + Long planId = 3016L;
  287 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  288 + LocalDate endDate = LocalDate.of(2026, 10, 20);
  289 + // mock 方法:三班倒、按天倒班 1 天,时间写作 8:00、0:00,中班结束写作 24:00
  290 + mockPlan(planId, MesCalShiftTypeEnum.THREE, MesCalShiftMethodEnum.DAY, 1, startDate, endDate);
  291 + when(planShiftService.getPlanShiftListByPlanId(planId)).thenReturn(ListUtil.of(
  292 + MesCalPlanShiftDO.builder().id(11L).planId(planId).sort(1).name("白班").startTime("8:00").endTime("16:00").build(),
  293 + MesCalPlanShiftDO.builder().id(12L).planId(planId).sort(2).name("中班").startTime("16:00").endTime("24:00").build(),
  294 + MesCalPlanShiftDO.builder().id(13L).planId(planId).sort(3).name("夜班").startTime("0:00").endTime("8:00").build()));
  295 + mockTeams(planId, 101L, 102L, 103L);
  296 +
  297 + // 调用
  298 + teamShiftService.generateTeamShiftRecords(planId);
  299 +
  300 + // 断言:与默认班次一致,2 天 × 3 班次 = 6 条排班记录,换班后休息 8 / 8 / 32 小时
  301 + List<MesCalTeamShiftDO> records = getRecords(planId);
  302 + assertEquals(6, records.size());
  303 + assertRecord(records.get(0), startDate.atStartOfDay(), 1, 101L, 11L);
  304 + assertRecord(records.get(1), startDate.atStartOfDay(), 2, 102L, 12L);
  305 + assertRecord(records.get(2), startDate.atStartOfDay(), 3, 103L, 13L);
  306 + assertRecord(records.get(3), endDate.atStartOfDay(), 1, 102L, 11L);
  307 + assertRecord(records.get(4), endDate.atStartOfDay(), 2, 103L, 12L);
  308 + assertRecord(records.get(5), endDate.atStartOfDay(), 3, 101L, 13L);
  309 + }
  310 +
  311 + @Test
  312 + public void testGenerateTeamShiftRecords_singleFullDayEnd24_withoutRest() {
  313 + // 准备参数
  314 + Long planId = 3017L;
  315 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  316 + LocalDate endDate = LocalDate.of(2026, 10, 20);
  317 + // mock 方法:单班次 00:00~24:00,结束于次日 00:00,与次日的班次首尾相接
  318 + mockPlan(planId, MesCalShiftTypeEnum.SINGLE, null, null, startDate, endDate);
  319 + when(planShiftService.getPlanShiftListByPlanId(planId)).thenReturn(ListUtil.of(
  320 + MesCalPlanShiftDO.builder().id(11L).planId(planId).sort(1).name("全天班")
  321 + .startTime("00:00").endTime("24:00").build()));
  322 + mockTeams(planId, 101L);
  323 + when(teamService.getTeam(eq(101L))).thenReturn(MesCalTeamDO.builder().id(101L).name("甲班").build());
  324 +
  325 + // 调用,并断言异常:24:00 不放宽连班校验
  326 + assertServiceException(() -> teamShiftService.generateTeamShiftRecords(planId),
  327 + CAL_TEAM_SHIFT_GENERATE_REST_INVALID, "甲班", startDate, "全天班", endDate, "全天班");
  328 + // 断言:没有写入任何排班记录
  329 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  330 + }
  331 +
  332 + @Test
  333 + public void testGenerateTeamShiftRecords_shiftTimeInvalid_boundary() {
  334 + // 准备参数:开始时间不允许 24:00;结束时间只允许 24:00 整;分钟、小时越界;非 H:mm 格式
  335 + String[][] cases = {
  336 + {"24:00", "08:00"}, {"08:00", "24:30"}, {"08:60", "16:00"},
  337 + {"08:00", "16:5"}, {"8点", "16:00"}, {"08:00:00", "16:00"}
  338 + };
  339 + for (int i = 0; i < cases.length; i++) {
  340 + Long planId = 3020L + i;
  341 + LocalDate day = LocalDate.of(2026, 10, 19);
  342 + // mock 方法:单班次
  343 + mockPlan(planId, MesCalShiftTypeEnum.SINGLE, null, null, day, day);
  344 + when(planShiftService.getPlanShiftListByPlanId(planId)).thenReturn(ListUtil.of(
  345 + MesCalPlanShiftDO.builder().id(11L).planId(planId).sort(1).name("白班")
  346 + .startTime(cases[i][0]).endTime(cases[i][1]).build()));
  347 + mockTeams(planId, 101L);
  348 +
  349 + // 调用,并断言异常
  350 + assertServiceException(() -> teamShiftService.generateTeamShiftRecords(planId),
  351 + CAL_TEAM_SHIFT_GENERATE_SHIFT_TIME_INVALID, "白班");
  352 + // 断言:没有写入任何排班记录
  353 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  354 + }
  355 + }
  356 +
  357 + @Test
  358 + public void testGenerateTeamShiftRecords_dayMethod_holidayNotConsumeShiftCount() {
  359 + // 准备参数
  360 + Long planId = 3010L;
  361 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  362 + LocalDate endDate = LocalDate.of(2026, 10, 22);
  363 + // mock 方法:两班倒、按天倒班 2 天,班次之间留有空档
  364 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 2, startDate, endDate);
  365 + mockTwoShifts(planId, "08:00", "17:00", "20:00", "05:00");
  366 + mockTeams(planId, 101L, 102L);
  367 + // mock 方法:10-20 为节假日;10-21 为工作日,照常排班
  368 + mockHolidays(startDate, endDate, buildHoliday(LocalDate.of(2026, 10, 20), MesCalHolidayTypeEnum.HOLIDAY),
  369 + buildHoliday(LocalDate.of(2026, 10, 21), MesCalHolidayTypeEnum.WORKDAY));
  370 +
  371 + // 调用
  372 + teamShiftService.generateTeamShiftRecords(planId);
  373 +
  374 + // 断言:节假日停班,3 个有效排班日 × 2 班次 = 6 条排班记录
  375 + List<MesCalTeamShiftDO> records = getRecords(planId);
  376 + assertEquals(6, records.size());
  377 + // 断言:10-19、10-21 为第 1、2 个有效排班日,A 组白班
  378 + assertRecord(records.get(0), LocalDateTime.of(2026, 10, 19, 0, 0), 1, 101L, 11L);
  379 + assertRecord(records.get(1), LocalDateTime.of(2026, 10, 19, 0, 0), 2, 102L, 12L);
  380 + assertRecord(records.get(2), LocalDateTime.of(2026, 10, 21, 0, 0), 1, 101L, 11L);
  381 + assertRecord(records.get(3), LocalDateTime.of(2026, 10, 21, 0, 0), 2, 102L, 12L);
  382 + // 断言:10-22 为第 3 个有效排班日,才换班
  383 + assertRecord(records.get(4), LocalDateTime.of(2026, 10, 22, 0, 0), 1, 102L, 11L);
  384 + assertRecord(records.get(5), LocalDateTime.of(2026, 10, 22, 0, 0), 2, 101L, 12L);
  385 + }
  386 +
  387 + @Test
  388 + public void testGenerateTeamShiftRecords_dayMethod_firstDayHoliday() {
  389 + // 准备参数
  390 + Long planId = 3011L;
  391 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  392 + LocalDate endDate = LocalDate.of(2026, 10, 21);
  393 + // mock 方法:两班倒、按天倒班 1 天,首日 10-19 为节假日
  394 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, startDate, endDate);
  395 + mockTwoShifts(planId, "08:00", "17:00", "20:00", "05:00");
  396 + mockTeams(planId, 101L, 102L);
  397 + mockHolidays(startDate, endDate, buildHoliday(startDate, MesCalHolidayTypeEnum.HOLIDAY));
  398 +
  399 + // 调用
  400 + teamShiftService.generateTeamShiftRecords(planId);
  401 +
  402 + // 断言:从 10-20 开始排班,10-20 为第 1 个有效排班日,不换班
  403 + List<MesCalTeamShiftDO> records = getRecords(planId);
  404 + assertEquals(4, records.size());
  405 + assertRecord(records.get(0), LocalDateTime.of(2026, 10, 20, 0, 0), 1, 101L, 11L);
  406 + assertRecord(records.get(1), LocalDateTime.of(2026, 10, 20, 0, 0), 2, 102L, 12L);
  407 + assertRecord(records.get(2), LocalDateTime.of(2026, 10, 21, 0, 0), 1, 102L, 11L);
  408 + assertRecord(records.get(3), LocalDateTime.of(2026, 10, 21, 0, 0), 2, 101L, 12L);
  409 + }
  410 +
  411 + @Test
  412 + public void testGenerateTeamShiftRecords_allHoliday() {
  413 + // 准备参数
  414 + Long planId = 3012L;
  415 + LocalDate startDate = LocalDate.of(2026, 10, 19);
  416 + LocalDate endDate = LocalDate.of(2026, 10, 20);
  417 + // mock 方法:两班倒、计划范围内全部为节假日
  418 + mockPlan(planId, MesCalShiftTypeEnum.TWO, MesCalShiftMethodEnum.DAY, 1, startDate, endDate);
  419 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "08:00");
  420 + mockTeams(planId, 101L, 102L);
  421 + mockHolidays(startDate, endDate, buildHoliday(startDate, MesCalHolidayTypeEnum.HOLIDAY),
  422 + buildHoliday(endDate, MesCalHolidayTypeEnum.HOLIDAY));
  423 +
  424 + // 调用
  425 + teamShiftService.generateTeamShiftRecords(planId);
  426 +
  427 + // 断言:不生成排班记录
  428 + assertTrue(teamShiftMapper.selectListByPlanId(planId).isEmpty());
  429 + }
  430 +
  431 + @Test
  432 + public void testGenerateTeamShiftRecords_weekMethod_holidayOnWeekStart() {
  433 + // 10-18 周日、10-19 周一为节假日、10-20 周二
  434 + assertRotateAcrossHoliday(3013L, MesCalShiftMethodEnum.WEEK,
  435 + LocalDate.of(2026, 10, 18), LocalDate.of(2026, 10, 19));
  436 + }
  437 +
  438 + @Test
  439 + public void testGenerateTeamShiftRecords_monthMethod_holidayOnMonthStart() {
  440 + // 09-30、10-01 月初为节假日、10-02
  441 + assertRotateAcrossHoliday(3014L, MesCalShiftMethodEnum.MONTH,
  442 + LocalDate.of(2026, 9, 30), LocalDate.of(2026, 10, 1));
  443 + }
  444 +
  445 + @Test
  446 + public void testGenerateTeamShiftRecords_quarterMethod_holidayOnQuarterStart() {
  447 + // 06-30、07-01 季度初为节假日、07-02
  448 + assertRotateAcrossHoliday(3015L, MesCalShiftMethodEnum.QUARTER,
  449 + LocalDate.of(2026, 6, 30), LocalDate.of(2026, 7, 1));
  450 + }
  451 +
  452 + /**
  453 + * 断言按周/月/季度倒班时,周期起点恰逢节假日也照常换班
  454 + *
  455 + * 默认两班倒班次覆盖全天,换班前一天夜班的班组,隔着节假日停班一天再上白班,留有 24 小时休息
  456 + */
  457 + private void assertRotateAcrossHoliday(Long planId, MesCalShiftMethodEnum shiftMethod,
  458 + LocalDate startDate, LocalDate holidayDate) {
  459 + // 准备参数
  460 + LocalDate endDate = holidayDate.plusDays(1);
  461 + // mock 方法:两班倒、默认班次覆盖全天,周期起点为节假日
  462 + mockPlan(planId, MesCalShiftTypeEnum.TWO, shiftMethod, null, startDate, endDate);
  463 + mockTwoShifts(planId, "08:00", "20:00", "20:00", "08:00");
  464 + mockTeams(planId, 101L, 102L);
  465 + mockHolidays(startDate, endDate, buildHoliday(holidayDate, MesCalHolidayTypeEnum.HOLIDAY));
  466 +
  467 + // 调用
  468 + teamShiftService.generateTeamShiftRecords(planId);
  469 +
  470 + // 断言:节假日停班,2 天 × 2 班次 = 4 条排班记录
  471 + List<MesCalTeamShiftDO> records = getRecords(planId);
  472 + assertEquals(4, records.size());
  473 + // 断言:周期起点前,A 组白班、B 组夜班
  474 + assertRecord(records.get(0), startDate.atStartOfDay(), 1, 101L, 11L);
  475 + assertRecord(records.get(1), startDate.atStartOfDay(), 2, 102L, 12L);
  476 + // 断言:周期起点虽为节假日,仍然换班:B 组白班、A 组夜班
  477 + assertRecord(records.get(2), endDate.atStartOfDay(), 1, 102L, 11L);
  478 + assertRecord(records.get(3), endDate.atStartOfDay(), 2, 101L, 12L);
  479 + }
  480 +
  481 + private void mockPlan(Long planId, MesCalShiftTypeEnum shiftType, MesCalShiftMethodEnum shiftMethod,
  482 + Integer shiftCount, LocalDate startDate, LocalDate endDate) {
  483 + when(planService.getPlan(planId)).thenReturn(MesCalPlanDO.builder()
  484 + .id(planId).code("PLAN-" + planId).name("排班计划" + planId)
  485 + .calendarType(1).startDate(startDate.atStartOfDay()).endDate(endDate.atStartOfDay())
  486 + .shiftType(shiftType.getType())
  487 + .shiftMethod(shiftMethod != null ? shiftMethod.getMethod() : null)
  488 + .shiftCount(shiftCount).build());
  489 + }
  490 +
  491 + private void mockTwoShifts(Long planId, String dayStartTime, String dayEndTime,
  492 + String nightStartTime, String nightEndTime) {
  493 + when(planShiftService.getPlanShiftListByPlanId(planId)).thenReturn(ListUtil.of(
  494 + MesCalPlanShiftDO.builder().id(11L).planId(planId).sort(1).name("白班")
  495 + .startTime(dayStartTime).endTime(dayEndTime).build(),
  496 + MesCalPlanShiftDO.builder().id(12L).planId(planId).sort(2).name("夜班")
  497 + .startTime(nightStartTime).endTime(nightEndTime).build()));
  498 + }
  499 +
  500 + private void mockTeams(Long planId, Long... teamIds) {
  501 + List<MesCalPlanTeamDO> teams = new ArrayList<>();
  502 + for (int i = 0; i < teamIds.length; i++) {
  503 + teams.add(MesCalPlanTeamDO.builder().id(21L + i).planId(planId).teamId(teamIds[i]).build());
  504 + }
  505 + when(planTeamService.getPlanTeamListByPlanId(planId)).thenReturn(teams);
  506 + }
  507 +
  508 + private void mockHolidays(LocalDate startDate, LocalDate endDate, MesCalHolidayDO... holidays) {
  509 + when(holidayService.getHolidayList(startDate.atStartOfDay(), endDate.atTime(23, 59, 59)))
  510 + .thenReturn(ListUtil.of(holidays));
  511 + }
  512 +
  513 + private static MesCalHolidayDO buildHoliday(LocalDate day, MesCalHolidayTypeEnum type) {
  514 + return MesCalHolidayDO.builder().day(day.atStartOfDay()).type(type.getType()).build();
  515 + }
  516 +
  517 + private List<MesCalTeamShiftDO> getRecords(Long planId) {
  518 + List<MesCalTeamShiftDO> records = teamShiftMapper.selectListByPlanId(planId);
  519 + records.sort(Comparator.comparing(MesCalTeamShiftDO::getDay).thenComparing(MesCalTeamShiftDO::getSort));
  520 + return records;
  521 + }
  522 +
124 private static void assertRecord(MesCalTeamShiftDO actual, LocalDateTime day, Integer sort, 523 private static void assertRecord(MesCalTeamShiftDO actual, LocalDateTime day, Integer sort,
125 Long teamId, Long shiftId) { 524 Long teamId, Long shiftId) {
126 assertEquals(day, actual.getDay()); 525 assertEquals(day, actual.getDay());
yudao-module-mes/src/test/resources/sql/clean.sql
@@ -2,6 +2,7 @@ DELETE FROM &quot;mes_cal_team_shift&quot;; @@ -2,6 +2,7 @@ DELETE FROM &quot;mes_cal_team_shift&quot;;
2 DELETE FROM "mes_cal_plan_team"; 2 DELETE FROM "mes_cal_plan_team";
3 DELETE FROM "mes_cal_plan_shift"; 3 DELETE FROM "mes_cal_plan_shift";
4 DELETE FROM "mes_cal_plan"; 4 DELETE FROM "mes_cal_plan";
  5 +DELETE FROM "mes_cal_holiday";
5 DELETE FROM "mes_md_auto_code_rule"; 6 DELETE FROM "mes_md_auto_code_rule";
6 DELETE FROM "mes_md_auto_code_part"; 7 DELETE FROM "mes_md_auto_code_part";
7 DELETE FROM "mes_md_auto_code_record"; 8 DELETE FROM "mes_md_auto_code_record";
yudao-module-mes/src/test/resources/sql/create_tables.sql
@@ -80,6 +80,23 @@ CREATE TABLE IF NOT EXISTS &quot;mes_cal_team_shift&quot; ( @@ -80,6 +80,23 @@ CREATE TABLE IF NOT EXISTS &quot;mes_cal_team_shift&quot; (
80 ); 80 );
81 81
82 -- ---------------------------- 82 -- ----------------------------
  83 +-- MES 假期设置
  84 +-- ----------------------------
  85 +CREATE TABLE IF NOT EXISTS "mes_cal_holiday" (
  86 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  87 + "day" timestamp DEFAULT NULL,
  88 + "type" tinyint DEFAULT NULL,
  89 + "remark" varchar(500) DEFAULT NULL,
  90 + "creator" varchar(64) DEFAULT '',
  91 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  92 + "updater" varchar(64) DEFAULT '',
  93 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  94 + "deleted" bit NOT NULL DEFAULT FALSE,
  95 + "tenant_id" bigint NOT NULL DEFAULT 0,
  96 + PRIMARY KEY ("id")
  97 +);
  98 +
  99 +-- ----------------------------
83 -- MES 编码规则表 100 -- MES 编码规则表
84 -- ---------------------------- 101 -- ----------------------------
85 CREATE TABLE IF NOT EXISTS "mes_md_auto_code_rule" ( 102 CREATE TABLE IF NOT EXISTS "mes_md_auto_code_rule" (