diff --git a/yudao-module-mes/yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java b/yudao-module-mes/yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java index 473d3f69d..160e39697 100644 --- a/yudao-module-mes/yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java +++ b/yudao-module-mes/yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java @@ -185,6 +185,9 @@ public interface ErrorCodeConstants { ErrorCode UNSUPPORTED_CAPACITY_TYPE = new ErrorCode(100_301_0009, "不支持的产能来源: {}"); ErrorCode SCHEDULE_DELIVERY_DATE_EMPTY = new ErrorCode(100_301_0010, "订单交期不能为空,taskDetailId={}"); ErrorCode SCHEDULE_DEVICE_SELECT_FAILED = new ErrorCode(100_301_0011, "设备分配失败,无可用设备,productId={}"); + ErrorCode SCHEDULE_TIME_FORMAT_INVALID = new ErrorCode(100_301_0012, "排产时间格式错误,start={}, end={},格式需为HH:mm"); + ErrorCode SCHEDULE_TIME_RANGE_INVALID = new ErrorCode(100_301_0013, "排产时间范围非法,start={}, end={},结束时间必须晚于开始时间"); + ErrorCode SCHEDULE_WORK_HOURS_INVALID = new ErrorCode(100_301_0014, "排产工时非法,start={}, end={},可用工时必须大于0"); diff --git a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/DeviceCandidate.java b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/DeviceCandidate.java index 6ab716c67..71e7dbf75 100644 --- a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/DeviceCandidate.java +++ b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/DeviceCandidate.java @@ -2,14 +2,17 @@ package cn.iocoder.yudao.module.mes.controller.admin.task.vo; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; @Data @AllArgsConstructor +@NoArgsConstructor public class DeviceCandidate { private Long deviceId; private String deviceName; private Integer capacityValue; private LocalDateTime nextAvailableTime; + } diff --git a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/TaskOneClickScheduleReqVO.java b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/TaskOneClickScheduleReqVO.java index b380f850a..b40e2596b 100644 --- a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/TaskOneClickScheduleReqVO.java +++ b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/task/vo/TaskOneClickScheduleReqVO.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import javax.validation.Valid; +import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import java.util.List; @@ -31,4 +32,17 @@ public class TaskOneClickScheduleReqVO { @Schema(description = "产能来源:1-额定产能 2-每日报工平均值 3-数据采集产能", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "产能来源不能为空") private Integer capacityType; + + @Schema(description = "每日开工时间(HH:mm)") + @NotBlank(message = "每日开工时间不能为空") + private String workStartTime; + + @Schema(description = "每日收工时间(HH:mm)") + @NotBlank(message = "每日收工时间不能为空") + private String workEndTime; + + @Schema(description = "是否跳过节假日:true-跳过") + @NotNull(message = "是否跳过节假日不能为空") + private Boolean skipHoliday; + } diff --git a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/calholiday/CalHolidayMapper.java b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/calholiday/CalHolidayMapper.java index 53d5f0ab6..51f07f855 100644 --- a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/calholiday/CalHolidayMapper.java +++ b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/calholiday/CalHolidayMapper.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.mes.dal.mysql.calholiday; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.*; import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -9,6 +10,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.module.mes.dal.dataobject.calholiday.CalHolidayDO; import org.apache.ibatis.annotations.Mapper; import cn.iocoder.yudao.module.mes.controller.admin.calholiday.vo.*; +import org.apache.ibatis.annotations.Param; + import java.time.ZoneId; import java.time.LocalTime; /** @@ -44,4 +47,7 @@ public interface CalHolidayMapper extends BaseMapperX { } + Integer countHolidayInRange( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } \ No newline at end of file diff --git a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/service/task/TaskServiceImpl.java b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/service/task/TaskServiceImpl.java index 8bb50d796..aa8b1bb7d 100644 --- a/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/service/task/TaskServiceImpl.java +++ b/yudao-module-mes/yudao-module-mes-biz/src/main/java/cn/iocoder/yudao/module/mes/service/task/TaskServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.mes.service.task; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.collection.MapUtils; @@ -18,11 +19,13 @@ import cn.iocoder.yudao.module.mes.controller.admin.deviceledger.enums.CapacityT import cn.iocoder.yudao.module.mes.controller.admin.plan.vo.PlanSaveReqVO; import cn.iocoder.yudao.module.mes.controller.admin.plan.vo.PlanStatusEnum; import cn.iocoder.yudao.module.mes.controller.admin.task.vo.*; +import cn.iocoder.yudao.module.mes.dal.dataobject.calholiday.CalHolidayDO; import cn.iocoder.yudao.module.mes.dal.dataobject.deviceledger.DeviceLedgerDO; import cn.iocoder.yudao.module.mes.dal.dataobject.plan.PlanDO; import cn.iocoder.yudao.module.mes.dal.dataobject.task.TaskDO; import cn.iocoder.yudao.module.mes.dal.dataobject.task.TaskDetailDO; import cn.iocoder.yudao.module.mes.dal.dataobject.task.ViewTaskProductSummary; +import cn.iocoder.yudao.module.mes.dal.mysql.calholiday.CalHolidayMapper; import cn.iocoder.yudao.module.mes.dal.mysql.deviceledger.DeviceLedgerMapper; import cn.iocoder.yudao.module.mes.dal.mysql.plan.PlanMapper; import cn.iocoder.yudao.module.mes.dal.mysql.task.TaskDetailMapper; @@ -41,8 +44,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import javax.annotation.Resource; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.*; @@ -82,6 +88,9 @@ public class TaskServiceImpl implements TaskService { @Resource private DeviceLedgerService deviceLedgerService; + @Resource + private CalHolidayMapper calHolidayMapper; + @Resource private AutoCodeUtil autoCodeUtil; @Resource @@ -428,9 +437,35 @@ public class TaskServiceImpl implements TaskService { if (reqVO == null || CollUtil.isEmpty(reqVO.getCreateReqVO())) { return Collections.emptyList(); } + Map taskCache = new HashMap<>(); Map productCache = new HashMap<>(); + //是否跳过节假日 + boolean skipHoliday = Boolean.TRUE.equals(reqVO.getSkipHoliday()); + + + // 解析本次请求的工作时段参数 + LocalTime workStart; + LocalTime workEnd; + try { + workStart = LocalTime.parse(reqVO.getWorkStartTime()); // HH:mm + workEnd = LocalTime.parse(reqVO.getWorkEndTime()); // HH:mm + } catch (Exception e) { + throw exception(SCHEDULE_TIME_FORMAT_INVALID, reqVO.getWorkStartTime(), reqVO.getWorkEndTime()); + } + if (!workEnd.isAfter(workStart)) { + throw exception(SCHEDULE_TIME_RANGE_INVALID, reqVO.getWorkStartTime(), reqVO.getWorkEndTime()); + } + long dailyWorkMinutes = ChronoUnit.MINUTES.between(workStart, workEnd); + if (dailyWorkMinutes <= 0) { + throw exception(SCHEDULE_WORK_HOURS_INVALID, reqVO.getWorkStartTime(), reqVO.getWorkEndTime()); + } + BigDecimal dailyWorkHours = BigDecimal.valueOf(dailyWorkMinutes) + .divide(BigDecimal.valueOf(60), 6, RoundingMode.HALF_UP); + + + // 1) 先按规则排序 List sortedPlans = new ArrayList<>(reqVO.getCreateReqVO()); // 排序前:若明细交期为空,则回填为订单交期 @@ -484,34 +519,30 @@ public class TaskServiceImpl implements TaskService { // 构建设备候选(含选中口径的产能 + nextAvailable) List candidates = new ArrayList<>(); for (ProductRelationRespVO rel : deviceRels) { - if (rel == null || rel.getId() == null) { + DeviceLedgerDO device = deviceLedgerMapper.selectById(rel.getId()); + Integer dailyCapacity = capacityType.getCapacity(device); // 每日产能 + if (dailyCapacity == null || dailyCapacity <= 0) { continue; } - Long deviceId = rel.getId(); - DeviceLedgerDO device = deviceLedgerMapper.selectById(deviceId); - if (device == null) { - continue; - } - - Integer capacityValue = capacityType.getCapacity(device); - if (capacityValue == null || capacityValue <= 0) { - continue; // 当前口径产能无效则跳过 - } - LocalDateTime nextTime = deviceNextAvailableTime.get(deviceId); - if (nextTime == null) { - nextTime = queryDeviceNextAvailableTime(deviceId); - deviceNextAvailableTime.put(deviceId, nextTime); - } + LocalDateTime nextAvailable = deviceNextAvailableTime.computeIfAbsent( + rel.getId(), + id -> queryDeviceNextAvailableTime(id, workStart, workEnd, skipHoliday) + ); + nextAvailable = normalizeToWorkTime(nextAvailable, workStart, workEnd, skipHoliday); - candidates.add(new DeviceCandidate(deviceId, device.getDeviceName(), capacityValue, nextTime)); - } + DeviceCandidate c = new DeviceCandidate(); + c.setDeviceId(rel.getId()); + c.setDeviceName(rel.getName()); + c.setCapacityValue(dailyCapacity); + c.setNextAvailableTime(nextAvailable); + candidates.add(c); } if (CollUtil.isEmpty(candidates)) { throw exception(SCHEDULE_PRODUCT_DEVICE_UNAVAILABLE, item.getProductId()); } - // 5) 选设备:优先空闲/最早可开工(nextAvailable最小),并列按deviceId升序 + // 5) 选设备:优先空闲/最早可开工(nextAvailable最小),并列按deviceId倒序 DeviceCandidate chosen = candidates.stream() .sorted(Comparator .comparing(DeviceCandidate::getNextAvailableTime) @@ -519,39 +550,23 @@ public class TaskServiceImpl implements TaskService { .findFirst() .orElseThrow(() -> exception(SCHEDULE_PRODUCT_DEVICE_UNAVAILABLE, item.getProductId())); + BigDecimal planNumber = BigDecimal.valueOf(item.getPlanNumber()); + BigDecimal dailyCapacity = BigDecimal.valueOf(chosen.getCapacityValue()); // 每日产能 + // needHours = planNumber * dailyWorkHours / dailyCapacity + BigDecimal needHours = planNumber.multiply(dailyWorkHours) + .divide(dailyCapacity, 6, RoundingMode.HALF_UP); - // 6) 按 数量/额定产能 计算天数 - int days = (int) Math.ceil((double) item.getPlanNumber() / chosen.getCapacityValue()); - if (days <= 0) { - days = 1; - } + LocalDateTime planStartTime = normalizeToWorkTime(chosen.getNextAvailableTime(), workStart, workEnd, skipHoliday); + LocalDateTime planEndTime = addWorkingHours(planStartTime, needHours, workStart, workEnd, skipHoliday); + LocalDate dueDate = LocalDate.from(item.getOrderDetailDeliveryDate() != null + ? item.getOrderDetailDeliveryDate() + : item.getDeliveryDate()); + LocalDateTime dueEnd = LocalDateTime.of(dueDate, workEnd); + LocalDateTime latestStartTime = subtractWorkingHours(dueEnd, needHours, workStart, workEnd, skipHoliday); - // 7) 计算开始/结束时间(算法生成) - LocalDate startDate = chosen.getNextAvailableTime().toLocalDate(); - LocalDateTime planStartTime = startDate.atStartOfDay(); - LocalDateTime planEndTime = startDate.plusDays(days - 1).atTime(23, 59, 59); - - // 7.1) 计算最晚开工时间(优先 orderDetailDeliveryDate,缺失则降级 deliveryDate) - LocalDate dueDate; - if (item.getOrderDetailDeliveryDate() != null) { - dueDate = item.getOrderDetailDeliveryDate().toLocalDate(); - } else if (item.getDeliveryDate() != null) { - dueDate = item.getDeliveryDate().toLocalDate();; // 如果你的 deliveryDate 是 LocalDateTime,这里改成 toLocalDate() - } else { - throw exception(SCHEDULE_DELIVERY_DATE_EMPTY, item.getTaskDetailId()); - } - - // 天数A = (截止日期 - 计划开始日期) - scheduleDays - long availableDays = ChronoUnit.DAYS.between(startDate, dueDate) + 1; - // 最晚可向后平移天数 - long dayA = availableDays - days; - LocalDateTime latestStartTime = dayA <= 0 - ? planStartTime - : planStartTime.plusDays(dayA); - - // 8) 更新该设备下次可开工时间(下一天00:00:00) - LocalDateTime nextAvailable = planEndTime.plusSeconds(1); - deviceNextAvailableTime.put(chosen.getDeviceId(), nextAvailable); + // 更新设备下次可开工 + LocalDateTime newNextAvailable = normalizeToWorkTime(planEndTime, workStart, workEnd, skipHoliday); + deviceNextAvailableTime.put(chosen.getDeviceId(), newNextAvailable); // 9) 组装返回(按设备分组) TaskOneClickScheduleRespVO deviceResp = deviceResultMap.computeIfAbsent(chosen.getDeviceId(), k -> { @@ -571,10 +586,6 @@ public class TaskServiceImpl implements TaskService { p.setTaskId(item.getTaskId()); p.setTaskDetailId(item.getTaskDetailId()); p.setPlanNumber(item.getPlanNumber()); - p.setScheduleDays(days); -// p.setPlanStartTime(planStartTime); -// p.setPlanEndTime(planEndTime); -// p.setLatestStartTime(latestStartTime); p.setSourceType("CURRENT"); p.setTaskCode(taskDO == null ? null : taskDO.getCode()); p.setProductCode(productDO == null ? null : productDO.getBarCode()); @@ -663,24 +674,166 @@ public class TaskServiceImpl implements TaskService { * - 有历史启用计划:取最后结束时间+1秒 * - 没有历史计划:取当前时间(并对齐到当天开始) */ - private LocalDateTime queryDeviceNextAvailableTime(Long deviceId) { + // ===================== 6) 替换 queryDeviceNextAvailableTime ===================== +// 文件:TaskServiceImpl.java + + private LocalDateTime queryDeviceNextAvailableTime(Long deviceId, LocalTime workStart, LocalTime workEnd, boolean skipHoliday) { PlanDO lastPlan = planMapper.selectOne(new LambdaQueryWrapper() .eq(PlanDO::getDeviceId, deviceId) .eq(PlanDO::getIsEnable, true) .orderByDesc(PlanDO::getPlanEndTime) .last("limit 1")); - LocalDate today = LocalDate.now(); - LocalDate historyNextDate; - if (lastPlan == null || lastPlan.getPlanEndTime() == null) { - historyNextDate = today; - } else { - historyNextDate = lastPlan.getPlanEndTime().toLocalDate().plusDays(1); + LocalDateTime base = (lastPlan == null || lastPlan.getPlanEndTime() == null) + ? LocalDateTime.now() + : lastPlan.getPlanEndTime(); + + return normalizeToWorkTime(base, workStart, workEnd, skipHoliday); + } + + + private LocalDateTime normalizeToWorkTime(LocalDateTime time, LocalTime workStart, LocalTime workEnd, boolean skipHoliday) { + LocalDate date = time.toLocalDate(); + LocalTime t = time.toLocalTime(); + + if (skipHoliday && !isWorkingDay(date)) { + date = nextWorkingDate(date.plusDays(1)); + return LocalDateTime.of(date, workStart); + } + + if (t.isBefore(workStart)) { + return LocalDateTime.of(date, workStart); + } + if (!t.isBefore(workEnd)) { + LocalDate next = date.plusDays(1); + if (skipHoliday) { + next = nextWorkingDate(next); + } + return LocalDateTime.of(next, workStart); + } + return time; + } + + private LocalDateTime addWorkingHours(LocalDateTime start, BigDecimal hours, + LocalTime workStart, LocalTime workEnd, + boolean skipHoliday) { + LocalDateTime cursor = normalizeToWorkTime(start, workStart, workEnd, skipHoliday); + long remainMinutes = hours.multiply(BigDecimal.valueOf(60)) + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + + while (remainMinutes > 0) { + if (skipHoliday && !isWorkingDay(cursor.toLocalDate())) { + cursor = LocalDateTime.of(nextWorkingDate(cursor.toLocalDate().plusDays(1)), workStart); + continue; + } + + LocalDateTime dayEnd = LocalDateTime.of(cursor.toLocalDate(), workEnd); + long available = ChronoUnit.MINUTES.between(cursor, dayEnd); + + if (available <= 0) { + LocalDate next = cursor.toLocalDate().plusDays(1); + if (skipHoliday) { + next = nextWorkingDate(next); + } + cursor = LocalDateTime.of(next, workStart); + continue; + } + + if (remainMinutes <= available) { + return cursor.plusMinutes(remainMinutes); + } + + remainMinutes -= available; + LocalDate next = cursor.toLocalDate().plusDays(1); + if (skipHoliday) { + next = nextWorkingDate(next); + } + cursor = LocalDateTime.of(next, workStart); } - // 取 max(今天, 历史下一天) - LocalDate nextDate = historyNextDate.isBefore(today) ? today : historyNextDate; - return nextDate.atStartOfDay(); + return cursor; + } + + + private LocalDateTime subtractWorkingHours(LocalDateTime end, BigDecimal hours, + LocalTime workStart, LocalTime workEnd, + boolean skipHoliday) { + LocalDateTime cursor = end; + LocalTime t = cursor.toLocalTime(); + + if (t.isAfter(workEnd)) { + cursor = LocalDateTime.of(cursor.toLocalDate(), workEnd); + } else if (!t.isAfter(workStart)) { + LocalDate prev = cursor.toLocalDate().minusDays(1); + if (skipHoliday) { + prev = prevWorkingDate(prev); + } + cursor = LocalDateTime.of(prev, workEnd); + } + + if (skipHoliday && !isWorkingDay(cursor.toLocalDate())) { + LocalDate prev = prevWorkingDate(cursor.toLocalDate().minusDays(1)); + cursor = LocalDateTime.of(prev, workEnd); + } + + long remainMinutes = hours.multiply(BigDecimal.valueOf(60)) + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + + while (remainMinutes > 0) { + if (skipHoliday && !isWorkingDay(cursor.toLocalDate())) { + LocalDate prev = prevWorkingDate(cursor.toLocalDate().minusDays(1)); + cursor = LocalDateTime.of(prev, workEnd); + continue; + } + + LocalDateTime dayStart = LocalDateTime.of(cursor.toLocalDate(), workStart); + long available = ChronoUnit.MINUTES.between(dayStart, cursor); + + if (available <= 0) { + LocalDate prev = cursor.toLocalDate().minusDays(1); + if (skipHoliday) { + prev = prevWorkingDate(prev); + } + cursor = LocalDateTime.of(prev, workEnd); + continue; + } + + if (remainMinutes <= available) { + return cursor.minusMinutes(remainMinutes); + } + + remainMinutes -= available; + LocalDate prev = cursor.toLocalDate().minusDays(1); + if (skipHoliday) { + prev = prevWorkingDate(prev); + } + cursor = LocalDateTime.of(prev, workEnd); + } + + return cursor; + } + + // 规则:周末默认上班;仅 HOLIDAY 休息 + private boolean isWorkingDay(LocalDate date) { + return !isHolidayByDate(date); + } + + private LocalDate nextWorkingDate(LocalDate date) { + LocalDate d = date; + while (!isWorkingDay(d)) { + d = d.plusDays(1); + } + return d; + } + + private LocalDate prevWorkingDate(LocalDate date) { + LocalDate d = date; + while (!isWorkingDay(d)) { + d = d.minusDays(1); + } + return d; } private Set queryDeviceIdsFromCurrentMonth() { @@ -725,4 +878,10 @@ public class TaskServiceImpl implements TaskService { } + private boolean isHolidayByDate(LocalDate day) { + LocalDateTime start = day.atStartOfDay(); + LocalDateTime end = day.plusDays(1).atStartOfDay(); + Integer cnt = calHolidayMapper.countHolidayInRange(start, end); + return cnt != null && cnt > 0; + } } \ No newline at end of file diff --git a/yudao-module-mes/yudao-module-mes-biz/src/main/resources/mapper/calholiday/CalHolidayMapper.xml b/yudao-module-mes/yudao-module-mes-biz/src/main/resources/mapper/calholiday/CalHolidayMapper.xml index 2e83ba08d..9fe01868b 100644 --- a/yudao-module-mes/yudao-module-mes-biz/src/main/resources/mapper/calholiday/CalHolidayMapper.xml +++ b/yudao-module-mes/yudao-module-mes-biz/src/main/resources/mapper/calholiday/CalHolidayMapper.xml @@ -9,4 +9,12 @@ 文档可见:https://www.iocoder.cn/MyBatis/x-plugins/ --> + \ No newline at end of file