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,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 "mes_cal_team_shift"; | @@ -2,6 +2,7 @@ DELETE FROM "mes_cal_team_shift"; | ||
| 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 "mes_cal_team_shift" ( | @@ -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 | -- 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" ( |