Commit 33c90a7a61194478f0a6a335e0920b590b25d8e1
1 parent
8fdc34e9
fix(mes): 修复排班连班与节假日生成异常
Showing
11 changed files
with
878 additions
and
34 deletions
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 | 8 | import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
| 9 | 9 | import org.apache.ibatis.annotations.Mapper; |
| 10 | 10 | |
| 11 | +import java.time.LocalDate; | |
| 12 | +import java.util.List; | |
| 13 | + | |
| 11 | 14 | /** |
| 12 | 15 | * MES 排班计划 Mapper |
| 13 | 16 | * |
| ... | ... | @@ -32,6 +35,15 @@ public interface MesCalPlanMapper extends BaseMapperX<MesCalPlanDO> { |
| 32 | 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 | 47 | default void updateWithShiftFieldCleanup(MesCalPlanDO plan, boolean clearShiftMethod, boolean clearShiftCount) { |
| 36 | 48 | update(plan, Wrappers.<MesCalPlanDO>lambdaUpdate() |
| 37 | 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 | 140 | ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH = new ErrorCode(1_040_201_202, "班次数量不满足轮班方式要求"); |
| 141 | 141 | ErrorCode CAL_TEAM_SHIFT_GENERATE_SHIFT_COUNT_INVALID = new ErrorCode(1_040_201_203, "按天倒班必须填写合法的倒班天数"); |
| 142 | 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 | 147 | // ========== MES 日历排班-排班计划(1-040-202-000) ========== |
| 145 | 148 | ErrorCode CAL_PLAN_NOT_EXISTS = new ErrorCode(1_040_202_000, "排班计划不存在"); |
| ... | ... | @@ -152,6 +155,8 @@ public interface ErrorCodeConstants { |
| 152 | 155 | |
| 153 | 156 | // ========== MES 日历排班-假期设置(1-040-203-000) ========== |
| 154 | 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 | 161 | // ========== MES 设备管理-设备类型(1-040-300-000) ========== |
| 157 | 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 | 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 | 5 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 4 | 6 | import cn.iocoder.yudao.module.mes.controller.admin.cal.holiday.vo.MesCalHolidaySaveReqVO; |
| 5 | 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 | 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 | 13 | import jakarta.annotation.Resource; |
| 14 | +import org.springframework.context.annotation.Lazy; | |
| 8 | 15 | import org.springframework.stereotype.Service; |
| 9 | 16 | import org.springframework.validation.annotation.Validated; |
| 10 | 17 | |
| 11 | 18 | import java.time.LocalDateTime; |
| 12 | 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 | 26 | * MES 假期设置 Service 实现类 |
| 16 | 27 | * |
| ... | ... | @@ -23,10 +34,17 @@ public class MesCalHolidayServiceImpl implements MesCalHolidayService { |
| 23 | 34 | @Resource |
| 24 | 35 | private MesCalHolidayMapper holidayMapper; |
| 25 | 36 | |
| 37 | + @Resource | |
| 38 | + @Lazy | |
| 39 | + private MesCalPlanService planService; | |
| 40 | + | |
| 26 | 41 | @Override |
| 27 | 42 | public Long saveHoliday(MesCalHolidaySaveReqVO saveReqVO) { |
| 28 | - // Upsert 逻辑:如果该日期已有记录,则更新 | |
| 43 | + // 1. 校验是否节假日的变化,不影响已确认的排班计划 | |
| 29 | 44 | MesCalHolidayDO existing = holidayMapper.selectByDay(saveReqVO.getDay()); |
| 45 | + validateHolidayChangeForConfirmedPlan(existing, saveReqVO); | |
| 46 | + | |
| 47 | + // 2.1 Upsert 逻辑:如果该日期已有记录,则更新 | |
| 30 | 48 | if (existing != null) { |
| 31 | 49 | MesCalHolidayDO updateObj = BeanUtils.toBean(saveReqVO, MesCalHolidayDO.class); |
| 32 | 50 | updateObj.setId(existing.getId()); |
| ... | ... | @@ -34,12 +52,33 @@ public class MesCalHolidayServiceImpl implements MesCalHolidayService { |
| 34 | 52 | return existing.getId(); |
| 35 | 53 | } |
| 36 | 54 | |
| 37 | - // 插入新记录 | |
| 55 | + // 2.2 插入新记录 | |
| 38 | 56 | MesCalHolidayDO holiday = BeanUtils.toBean(saveReqVO, MesCalHolidayDO.class); |
| 39 | 57 | holidayMapper.insert(holiday); |
| 40 | 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 | 82 | @Override |
| 44 | 83 | public List<MesCalHolidayDO> getHolidayList(LocalDateTime startDay, LocalDateTime endDay) { |
| 45 | 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 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; |
| 7 | 7 | import jakarta.validation.Valid; |
| 8 | 8 | |
| 9 | +import java.time.LocalDate; | |
| 9 | 10 | import java.util.Collection; |
| 10 | 11 | import java.util.List; |
| 11 | 12 | import java.util.Map; |
| ... | ... | @@ -90,4 +91,13 @@ public interface MesCalPlanService { |
| 90 | 91 | */ |
| 91 | 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 | 18 | import org.springframework.transaction.annotation.Transactional; |
| 19 | 19 | import org.springframework.validation.annotation.Validated; |
| 20 | 20 | |
| 21 | +import java.time.LocalDate; | |
| 21 | 22 | import java.util.Collection; |
| 22 | 23 | import java.util.Collections; |
| 23 | 24 | import java.util.List; |
| ... | ... | @@ -143,6 +144,11 @@ public class MesCalPlanServiceImpl implements MesCalPlanService { |
| 143 | 144 | } |
| 144 | 145 | |
| 145 | 146 | @Override |
| 147 | + public List<MesCalPlanDO> getPlanListByStatusAndDay(Integer status, LocalDate day) { | |
| 148 | + return planMapper.selectListByStatusAndDay(status, day); | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Override | |
| 146 | 152 | public MesCalPlanDO validatePlanPrepare(Long planId) { |
| 147 | 153 | MesCalPlanDO plan = validatePlanExists(planId); |
| 148 | 154 | if (ObjUtil.notEqual(MesCalPlanStatusEnum.PREPARE.getStatus(), plan.getStatus())) { |
| ... | ... | @@ -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 | 183 | private void validateTeamCountForConfirm(Long planId, Integer shiftType) { |
| 176 | 184 | Long teamCount = planTeamService.getPlanTeamCountByPlanId(planId); |
| 177 | 185 | MesCalShiftTypeEnum shiftTypeEnum = MesCalShiftTypeEnum.valueOf(shiftType); |
| 178 | - if (teamCount < shiftTypeEnum.getRequiredTeamCount()) { | |
| 186 | + if (teamCount == null || teamCount != shiftTypeEnum.getRequiredTeamCount()) { | |
| 179 | 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 | 1 | package cn.iocoder.yudao.module.mes.service.cal.team; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 3 | 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 | 6 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; |
| 5 | 7 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO; |
| 6 | 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 | 10 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO; |
| 8 | 11 | import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper; |
| 12 | +import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum; | |
| 9 | 13 | import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum; |
| 10 | 14 | import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum; |
| 15 | +import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService; | |
| 11 | 16 | import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService; |
| 12 | 17 | import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService; |
| 13 | 18 | import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService; |
| ... | ... | @@ -20,14 +25,25 @@ import org.springframework.validation.annotation.Validated; |
| 20 | 25 | |
| 21 | 26 | import java.time.LocalDate; |
| 22 | 27 | import java.time.LocalDateTime; |
| 28 | +import java.time.LocalTime; | |
| 23 | 29 | import java.time.temporal.ChronoUnit; |
| 24 | 30 | import java.util.ArrayList; |
| 31 | +import java.util.Comparator; | |
| 32 | +import java.util.HashMap; | |
| 25 | 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 | 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 | 43 | import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_COUNT_INVALID; |
| 29 | 44 | import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.CAL_TEAM_SHIFT_GENERATE_SHIFT_METHOD_INVALID; |
| 30 | 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 | 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 | 55 | @Validated |
| 40 | 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 | 63 | @Resource |
| 43 | 64 | private MesCalTeamShiftMapper teamShiftMapper; |
| 44 | 65 | |
| ... | ... | @@ -51,6 +72,12 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { |
| 51 | 72 | @Resource |
| 52 | 73 | @Lazy |
| 53 | 74 | private MesCalPlanTeamService planTeamService; |
| 75 | + @Resource | |
| 76 | + @Lazy | |
| 77 | + private MesCalHolidayService holidayService; | |
| 78 | + @Resource | |
| 79 | + @Lazy | |
| 80 | + private MesCalTeamService teamService; | |
| 54 | 81 | |
| 55 | 82 | @Transactional(rollbackFor = Exception.class) |
| 56 | 83 | @Override |
| ... | ... | @@ -71,25 +98,118 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { |
| 71 | 98 | throw exception(CAL_TEAM_SHIFT_GENERATE_SHIFT_NOT_ENOUGH); |
| 72 | 99 | } |
| 73 | 100 | |
| 74 | - // 2. 计算日期跨度,遍历每天生成排班记录,最后统一写入数据库 | |
| 101 | + // 2.1 计算日期跨度,查询范围内的节假日 | |
| 75 | 102 | LocalDate startDate = plan.getStartDate().toLocalDate(); |
| 76 | 103 | LocalDate endDate = plan.getEndDate().toLocalDate(); |
| 77 | 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 | 109 | List<MesCalTeamShiftDO> allRecords = new ArrayList<>(); |
| 79 | 110 | int shiftIndex = 0; |
| 111 | + int workDayOffset = 0; | |
| 80 | 112 | for (int i = 0; i < days; i++) { |
| 81 | 113 | LocalDate currentDate = startDate.plusDays(i); |
| 82 | 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 | 124 | // 2.2.2 根据轮班方式生成排班记录,收集到列表 |
| 88 | 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 | 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 | 300 | .sort(1).build()); |
| 181 | 301 | } |
| 182 | 302 | } else if (MesCalShiftTypeEnum.THREE.getType().equals(shiftType)) { |
| 183 | - // 三班倒 | |
| 303 | + // 三班倒:按 白班 → 夜班 → 中班 轮转。原 白班 → 中班 → 夜班 轮转时,换班当天中班 24:00 下班的班组会立即接 00:00 的夜班 https://t.zsxq.com/XdcRW | |
| 184 | 304 | if (shiftIndex % 3 == 0) { |
| 185 | 305 | // A 组上白班、B 组上中班、C 组上夜班 |
| 186 | 306 | allRecords.add(MesCalTeamShiftDO.builder() |
| ... | ... | @@ -199,39 +319,39 @@ public class MesCalTeamShiftServiceImpl implements MesCalTeamShiftService { |
| 199 | 319 | .shiftId(shifts.get(2).getId()) |
| 200 | 320 | .sort(3).build()); |
| 201 | 321 | } else if (shiftIndex % 3 == 1) { |
| 202 | - // A 组上中班、B 组上夜班、C 组上白班 | |
| 322 | + // A 组上夜班、B 组上白班、C 组上中班 | |
| 203 | 323 | allRecords.add(MesCalTeamShiftDO.builder() |
| 204 | 324 | .planId(planId).day(currentDay) |
| 205 | 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 | 326 | .shiftId(shifts.get(2).getId()) |
| 212 | 327 | .sort(3).build()); |
| 213 | 328 | allRecords.add(MesCalTeamShiftDO.builder() |
| 214 | 329 | .planId(planId).day(currentDay) |
| 215 | - .teamId(teams.get(2).getTeamId()) | |
| 330 | + .teamId(teams.get(1).getTeamId()) | |
| 216 | 331 | .shiftId(shifts.get(0).getId()) |
| 217 | 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 | 338 | } else { |
| 219 | - // A 组上夜班、B 组上白班、C 组上中班 | |
| 339 | + // A 组上中班、B 组上夜班、C 组上白班 | |
| 220 | 340 | allRecords.add(MesCalTeamShiftDO.builder() |
| 221 | 341 | .planId(planId).day(currentDay) |
| 222 | 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 | 345 | allRecords.add(MesCalTeamShiftDO.builder() |
| 226 | 346 | .planId(planId).day(currentDay) |
| 227 | 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 | 350 | allRecords.add(MesCalTeamShiftDO.builder() |
| 231 | 351 | .planId(planId).day(currentDay) |
| 232 | 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 | 1 | package cn.iocoder.yudao.module.mes.service.cal.plan; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.util.collection.SetUtils; | |
| 3 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; |
| 4 | 5 | import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.MesCalPlanSaveReqVO; |
| 5 | 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 | 15 | |
| 15 | 16 | import jakarta.annotation.Resource; |
| 16 | 17 | |
| 18 | +import java.time.LocalDate; | |
| 17 | 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 | 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 | 29 | import static org.junit.jupiter.api.Assertions.*; |
| 21 | 30 | import static org.mockito.Mockito.*; |
| 22 | 31 | |
| ... | ... | @@ -88,6 +97,85 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest { |
| 88 | 97 | assertEquals(MesCalPlanStatusEnum.CONFIRMED.getStatus(), updatePlan.getStatus()); |
| 89 | 98 | // 断言 2:触发生成班组排班记录 |
| 90 | 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 | 181 | @Test |
| ... | ... | @@ -116,4 +204,16 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest { |
| 116 | 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 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.ListUtil; |
| 4 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; |
| 5 | +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.holiday.MesCalHolidayDO; | |
| 5 | 6 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanDO; |
| 6 | 7 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanShiftDO; |
| 7 | 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 | 10 | import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamShiftDO; |
| 9 | 11 | import cn.iocoder.yudao.module.mes.dal.mysql.cal.team.MesCalTeamShiftMapper; |
| 12 | +import cn.iocoder.yudao.module.mes.enums.cal.MesCalHolidayTypeEnum; | |
| 10 | 13 | import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftMethodEnum; |
| 11 | 14 | import cn.iocoder.yudao.module.mes.enums.cal.MesCalShiftTypeEnum; |
| 15 | +import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService; | |
| 12 | 16 | import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanService; |
| 13 | 17 | import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanShiftService; |
| 14 | 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 | 22 | |
| 19 | 23 | import jakarta.annotation.Resource; |
| 20 | 24 | |
| 25 | +import java.time.LocalDate; | |
| 21 | 26 | import java.time.LocalDateTime; |
| 27 | +import java.util.ArrayList; | |
| 22 | 28 | import java.util.Comparator; |
| 23 | 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 | 34 | import static org.junit.jupiter.api.Assertions.*; |
| 26 | 35 | import static org.mockito.Mockito.*; |
| 27 | 36 | |
| ... | ... | @@ -45,6 +54,10 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest { |
| 45 | 54 | private MesCalPlanShiftService planShiftService; |
| 46 | 55 | @MockitoBean |
| 47 | 56 | private MesCalPlanTeamService planTeamService; |
| 57 | + @MockitoBean | |
| 58 | + private MesCalHolidayService holidayService; | |
| 59 | + @MockitoBean | |
| 60 | + private MesCalTeamService teamService; | |
| 48 | 61 | |
| 49 | 62 | @Test |
| 50 | 63 | public void testGenerateTeamShiftRecords_threeShift_rotateThreeStates() { |
| ... | ... | @@ -79,22 +92,22 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest { |
| 79 | 92 | // 调用 |
| 80 | 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 | 98 | assertEquals(9, records.size()); |
| 86 | 99 | // 断言:第 1 天(shiftIndex=0):A 白班、B 中班、C 夜班 |
| 87 | 100 | assertRecord(records.get(0), LocalDateTime.of(2026, 4, 1, 0, 0), 1, 101L, 11L); |
| 88 | 101 | assertRecord(records.get(1), LocalDateTime.of(2026, 4, 1, 0, 0), 2, 102L, 12L); |
| 89 | 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 | 113 | @Test |
| ... | ... | @@ -121,6 +134,392 @@ public class MesCalTeamShiftServiceImplTest extends BaseDbUnitTest { |
| 121 | 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 | 523 | private static void assertRecord(MesCalTeamShiftDO actual, LocalDateTime day, Integer sort, |
| 125 | 524 | Long teamId, Long shiftId) { |
| 126 | 525 | assertEquals(day, actual.getDay()); | ... | ... |
yudao-module-mes/src/test/resources/sql/clean.sql
| ... | ... | @@ -2,6 +2,7 @@ DELETE FROM "mes_cal_team_shift"; |
| 2 | 2 | DELETE FROM "mes_cal_plan_team"; |
| 3 | 3 | DELETE FROM "mes_cal_plan_shift"; |
| 4 | 4 | DELETE FROM "mes_cal_plan"; |
| 5 | +DELETE FROM "mes_cal_holiday"; | |
| 5 | 6 | DELETE FROM "mes_md_auto_code_rule"; |
| 6 | 7 | DELETE FROM "mes_md_auto_code_part"; |
| 7 | 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 "mes_cal_team_shift" ( |
| 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 | 100 | -- MES 编码规则表 |
| 84 | 101 | -- ---------------------------- |
| 85 | 102 | CREATE TABLE IF NOT EXISTS "mes_md_auto_code_rule" ( | ... | ... |