package cn.iocoder.yudao.module.mes.service.pro.task.schedule; import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi; import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.*; import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProBatchSchedulePreviewRespVO.*; import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO; import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProcessDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO; import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper; import cn.iocoder.yudao.module.mes.dal.mysql.dv.machinery.MesDvMachineryMapper; import cn.iocoder.yudao.module.mes.enums.pro.*; import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService; import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService; import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService; import cn.iocoder.yudao.module.mes.service.pro.route.*; import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService; import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService; import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService; import jakarta.annotation.Resource; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.*; import java.util.*; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*; @Service public class MesProBatchScheduleService { @Resource private MesProTaskMapper taskMapper; @Resource private MesProWorkOrderMapper workOrderMapper; @Resource private MesDvMachineryMapper machineryMapper; @Resource private MesMdWorkstationService workstationService; @Resource private MesProWorkOrderProcessService workOrderProcessService; @Resource private MesProRouteService routeService; @Resource private MesProRouteProcessService routeProcessService; @Resource private MesProRouteProductService routeProductService; @Resource private MesProProcessService processService; @Resource private MesMdItemService itemService; @Resource private MesCalHolidayService holidayService; @Resource @Lazy private cn.iocoder.yudao.module.mes.service.pd.processparamtemplate.MesPdProcessParamTemplateService paramTemplateService; @Resource private MesProScheduleGuardService guard; @Resource private CrmCustomerApi customerApi; @Resource @Lazy private MesProTaskService taskService; private LocalDateTime now() { return LocalDateTime.now(ZoneId.of("Asia/Shanghai")).withNano(0); } public MesProBatchSchedulePreviewRespVO preview(MesProBatchScheduleReqVO request) { validateRequest(request); return build(request); } @Transactional(rollbackFor = Exception.class, isolation = Isolation.SERIALIZABLE, timeout = 60) public List execute(MesProBatchScheduleReqVO request) { validateRequest(request); if (request.getPreviewTime() == null || request.getPreviewHash() == null || request.getPreviewTime().isAfter(now()) || request.getPreviewTime().plusMinutes(15).isBefore(now())) { throw exception(PRO_TASK_SCHEDULE_PREVIEW_CHANGED); } guard.lock(); workOrderMapper.lockScheduleOrders(request.getWorkOrderIds().stream().sorted().toList()); var preview = build(request); if (!preview.isCanExecute()) { throw exception(PRO_TASK_SCHEDULE_INVALID, preview.getOrders().stream() .filter(order -> order.getError() != null).map(OrderPlan::getError).collect(Collectors.joining(";"))); } if (!Objects.equals(request.getPreviewHash(), preview.getPreviewHash())) { throw exception(PRO_TASK_SCHEDULE_PREVIEW_CHANGED); } if (preview.getTasks().stream().anyMatch(task -> task.getStartTime().isBefore(now()))) { throw exception(PRO_TASK_SCHEDULE_PREVIEW_CHANGED); } if (!preview.getWarnings().isEmpty() && !Boolean.TRUE.equals(request.getAllowLate())) { throw exception(PRO_TASK_SCHEDULE_INVALID, "请先确认预览中的交期延期风险"); } List ids = new ArrayList<>(); for (var plan : preview.getTasks()) { var task = new MesProTaskSaveReqVO(); task.setWorkOrderId(plan.getWorkOrderId()); task.setRouteId(plan.getRouteId()); task.setProcessId(plan.getProcessId()); task.setItemId(plan.getItemId()); task.setParamTemplateId(plan.getParamTemplateId()); task.setColorCode(plan.getColorCode()); task.setWorkstationId(plan.getWorkstationId()); task.setQuantity(plan.getQuantity()); task.setDuration(plan.getDuration()); task.setStartTime(plan.getStartTime()); task.setEndTime(plan.getEndTime()); task.setRemark("一键排产:09:00–18:00,排除周末及节假日"); ids.add(taskService.createTask(task)); } // 每道工序均补齐后,工单的累计排产数量只记一次,不能按工序数累加。 for (var order : preview.getOrders()) { workOrderMapper.updateById(new MesProWorkOrderDO().setId(order.getWorkOrderId()) .setQuantityScheduled(order.getQuantity())); } return ids; } private void validateRequest(MesProBatchScheduleReqVO request) { var ids = request.getWorkOrderIds(); if (ids == null || ids.isEmpty() || ids.size() > 30 || ids.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(ids).size() != ids.size()) { throw exception(PRO_TASK_SCHEDULE_INVALID, "请选择1至30个不同的有效工单"); } if (request.getStartTime() == null || request.getStartTime().isBefore(now().minusMinutes(15)) || request.getStartTime().isAfter(now().plusDays(365))) { throw exception(PRO_TASK_SCHEDULE_INVALID, "最早开工时间应为当前时间至未来一年以内,请重新选择"); } } private MesProBatchSchedulePreviewRespVO build(MesProBatchScheduleReqVO request) { var result = new MesProBatchSchedulePreviewRespVO(); result.setPreviewTime(now()); var orders = workOrderMapper.selectByIds(request.getWorkOrderIds()); var orderMap = orders.stream().collect(Collectors.toMap(MesProWorkOrderDO::getId, order -> order)); List permitted = customerApi.getPermittedCustomerIds(); var stations = workstationService.getWorkstationListByStatus(0).stream() .sorted(Comparator.comparing(MesMdWorkstationDO::getId)).toList(); var machineMap = machineryMapper.selectList().stream().collect(Collectors.toMap(MesDvMachineryDO::getId, machine -> machine)); var stationMachines = guard.machineIdsByStation(); Map allRelevantTasks = new TreeMap<>(); taskMapper.selectSchedulingTasks().forEach(task -> allRelevantTasks.put(task.getId(), task)); taskMapper.selectListByWorkOrderIds(request.getWorkOrderIds()).stream() .filter(task -> !Objects.equals(task.getStatus(), MesProTaskStatusEnum.CANCELED.getStatus())) .forEach(task -> allRelevantTasks.put(task.getId(), task)); var existingTasks = new ArrayList<>(allRelevantTasks.values()); var holidays = holidayService.getHolidayList(request.getStartTime().toLocalDate().atStartOfDay(), request.getStartTime().plusYears(1)); Set holidayDates = holidays.stream().filter(day -> Objects.equals(day.getType(), 2)) .map(day -> day.getDay().toLocalDate()).collect(Collectors.toSet()); var calendar = new MesProScheduleEngine.Calendar(holidayDates, request.getStartTime().toLocalDate().plusYears(1)); List busy = new ArrayList<>(); Set unknownStations = new HashSet<>(); for (var task : existingTasks) { if (MesProTaskStatusEnum.isEndStatus(task.getStatus()) || task.getWorkstationId() == null) continue; if (task.getStartTime() == null || task.getEndTime() == null || !task.getEndTime().isAfter(task.getStartTime()) || task.getEndTime().isBefore(now())) { unknownStations.add(task.getWorkstationId()); } else { busy.add(new MesProScheduleEngine.Busy(task.getWorkstationId(), stationMachines.getOrDefault(task.getWorkstationId(), Set.of()), task.getStartTime(), task.getEndTime())); } } var sorted = orders.stream().sorted(Comparator.comparing(MesProWorkOrderDO::getRequestDate, Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(MesProWorkOrderDO::getId)).toList(); List orderSequence = new ArrayList<>(); Set visiting = new HashSet<>(); for (var order : sorted) addChildrenFirst(order.getId(), orderMap, orderSequence, visiting); for (Long id : request.getWorkOrderIds()) if (!orderMap.containsKey(id)) orderSequence.add(id); Map ends = new HashMap<>(); Map templateHashes = new TreeMap<>(); final List existingBusy = List.copyOf(busy); for (Long id : orderSequence) { var order = orderMap.get(id); var orderPlan = new OrderPlan(); orderPlan.setWorkOrderId(id); result.getOrders().add(orderPlan); try { if (order == null) throw new IllegalArgumentException("工单不存在或已删除"); if (permitted != null && order.getClientId() != null && !permitted.contains(order.getClientId())) { throw new IllegalArgumentException("无权操作该客户的工单"); } orderPlan.setWorkOrderCode(order.getCode()); orderPlan.setWorkOrderName(order.getName()); orderPlan.setQuantity(order.getQuantity()); orderPlan.setRequestDate(order.getRequestDate()); if (!Objects.equals(order.getStatus(), MesProWorkOrderStatusEnum.CONFIRMED.getStatus()) || !Objects.equals(order.getType(), MesProWorkOrderTypeEnum.SELF.getType())) { throw new IllegalArgumentException("仅支持已确认的自行生产工单"); } if (order.getQuantity() == null || order.getQuantity().signum() <= 0 || (order.getQuantityProduced() != null && order.getQuantityProduced().compareTo(order.getQuantity()) >= 0)) { throw new IllegalArgumentException("工单数量无效或已生产完成"); } LocalDateTime earliest = calendar.nextWorkingTime(request.getStartTime()); // 父工单必须等子工单;同一批次按依赖顺序排,未纳入的未完成子工单阻止排产。 for (var child : workOrderMapper.selectList(MesProWorkOrderDO::getParentId, id)) { if (Objects.equals(child.getStatus(), MesProWorkOrderStatusEnum.FINISHED.getStatus())) continue; if (!ends.containsKey(child.getId())) throw new IllegalArgumentException("请将未完成子工单" + child.getCode() + "一并选中并先完成排产"); if (ends.get(child.getId()).isAfter(earliest)) earliest = ends.get(child.getId()); } Long routeId = order.getRouteId(); if (routeId == null) { var relation = routeProductService.getRouteProductByItemId(order.getProductId()); if (relation != null) routeId = relation.getRouteId(); } if (routeId == null) throw new IllegalArgumentException("未配置工艺路线,请先维护工单工序及产品路线"); var route = routeService.validateRouteExists(routeId); if (!Objects.equals(route.getStatus(), 0)) throw new IllegalArgumentException("工艺路线已停用"); List processes = processes(id, routeId); if (processes.isEmpty()) throw new IllegalArgumentException("没有工序,请先维护工单工序"); Set processIds = new HashSet<>(); Set sorts = new HashSet<>(); Map processSorts = processes.stream().filter(process -> process.getProcessId() != null && process.getSort() != null) .collect(Collectors.toMap(MesProRouteProcessDO::getProcessId, MesProRouteProcessDO::getSort, (first, duplicate) -> first)); List plans = new ArrayList<>(); Map targetQuantities = calcTargetQuantities(processes, order.getQuantity()); List trialBusy = new ArrayList<>(busy); for (var process : processes) { if (process.getProcessId() == null || process.getSort() == null || !processIds.add(process.getProcessId()) || !sorts.add(process.getSort())) { throw new IllegalArgumentException("工序及顺序必须完整且不能重复,请先维护工单工序"); } if (process.getNextProcessId() != null && (!processSorts.containsKey(process.getNextProcessId()) || processSorts.get(process.getNextProcessId()) <= process.getSort())) { throw new IllegalArgumentException("工序衔接与顺序不一致,请先维护工单工序"); } int prepareMinutes = process.getPrepareTime() == null ? 0 : process.getPrepareTime(); int waitMinutes = process.getWaitTime() == null ? 0 : process.getWaitTime(); if (prepareMinutes < 0 || waitMinutes < 0) throw new IllegalArgumentException("工序准备时间和等待时间不能为负数"); if (process.getParamTemplateId() != null) { paramTemplateService.validateProcessParamTemplateExists(process.getParamTemplateId()); var details = paramTemplateService.getProcessParamTemplateDetailListByTemplateId(process.getParamTemplateId()); if (details.isEmpty()) { throw new IllegalArgumentException("工序参数模板没有明细,请先完善参数模板"); } templateHashes.put(process.getParamTemplateId(), DigestUtil.sha256Hex(JsonUtils.toJsonString(details))); } processService.validateProcessExistsAndEnable(process.getProcessId()); var processData = processService.getProcess(process.getProcessId()); if (processData == null) throw new IllegalArgumentException("工序配置已变化,请重新维护后预览"); var ownTasks = existingTasks.stream().filter(task -> Objects.equals(task.getWorkOrderId(), id) && Objects.equals(task.getProcessId(), process.getProcessId())).toList(); if (ownTasks.stream().anyMatch(task -> task.getQuantity() == null || task.getQuantity().signum() <= 0)) { throw new IllegalArgumentException("当前工序已有任务数量无效,请先修正历史任务数量"); } var assigned = ownTasks.stream().map(task -> task.getQuantity() == null ? BigDecimal.ZERO : task.getQuantity()).reduce(BigDecimal.ZERO, BigDecimal::add); // 各道工序按收率倒推的目标产出量:末道=订单量,前面各道逐级放大 var targetQuantity = targetQuantities.getOrDefault(process.getProcessId(), order.getQuantity()); var remaining = targetQuantity.subtract(assigned); if (remaining.signum() < 0) throw new IllegalArgumentException("工序" + processData.getName() + "已有排产数量超限,请先核对"); for (var task : ownTasks) { if (!MesProTaskStatusEnum.isEndStatus(task.getStatus()) && (task.getStartTime() == null || task.getEndTime() == null || !task.getEndTime().isAfter(task.getStartTime()) || task.getEndTime().isBefore(now()))) { throw new IllegalArgumentException("工序" + processData.getName() + "已有任务时间不完整或已超时未完成,请先维护"); } } LocalDateTime existingEnd = ownTasks.stream().map(task -> task.getEndTime() != null ? task.getEndTime() : task.getFinishDate()) .filter(Objects::nonNull).max(Comparator.naturalOrder()).orElse(earliest); if (remaining.signum() == 0) { if (existingEnd.isAfter(earliest)) earliest = existingEnd; earliest = earliest.plusMinutes(waitMinutes); continue; } Long itemId = order.getProductId(); if (process.getOutputItemId() != null && !process.getOutputItemId().isBlank()) { String[] output = process.getOutputItemId().split(","); if (output.length != 1) throw new IllegalArgumentException("多产出工序请使用手工排产确认各产出数量"); itemId = Long.valueOf(output[0].trim()); } var item = itemService.validateItemExists(itemId); if (!Objects.equals(item.getStatus(), 0)) throw new IllegalArgumentException("工序产出产品已停用"); TaskPlan best = null; for (var station : stations) { if (!Objects.equals(station.getProcessId(), process.getProcessId())) continue; Set machineIds = stationMachines.getOrDefault(station.getId(), Set.of()); if (unknownStations.contains(station.getId()) || machineIds.isEmpty()) continue; if (unknownStations.stream().anyMatch(other -> !Collections.disjoint(machineIds, stationMachines.getOrDefault(other, Set.of())))) continue; List machines = machineIds.stream().map(machineMap::get).filter(Objects::nonNull) .filter(machine -> Objects.equals(machine.getStatus(), 1) || Objects.equals(machine.getStatus(), 2)).toList(); if (machines.isEmpty() || machines.stream().anyMatch(machine -> machine.getProductionEfficiency() == null || machine.getProductionEfficiency().signum() <= 0)) continue; Set usableIds = machines.stream().map(MesDvMachineryDO::getId).collect(Collectors.toCollection(TreeSet::new)); if (machines.stream().anyMatch(machine -> Objects.equals(machine.getStatus(), 2) && existingBusy.stream().noneMatch(interval -> interval.machineryIds().contains(machine.getId())))) continue; BigDecimal capacity = machines.stream().map(machine -> machine.getProductionEfficiency().multiply(BigDecimal.valueOf( machine.getConveyorBelt() == null || machine.getConveyorBelt() <= 0 ? 1 : machine.getConveyorBelt()))) .reduce(BigDecimal.ZERO, BigDecimal::add); MesProScheduleEngine.Slot slot; try { int hours = Math.addExact(MesProScheduleEngine.duration(remaining, capacity), Math.toIntExact(((long) prepareMinutes + 59) / 60)); slot = MesProScheduleEngine.findSlotHours(station.getId(), machineIds, hours, earliest, trialBusy, calendar); } catch (IllegalArgumentException | ArithmeticException unavailable) { continue; // 一台低产能工作站超出排产窗口时,仍继续尝试其他工作站。 } if (best == null || slot.end().isBefore(best.getEndTime())) { best = new TaskPlan(); best.setWorkOrderId(id); best.setWorkOrderCode(order.getCode()); best.setRouteId(routeId); best.setRouteName(route.getName()); best.setProcessId(process.getProcessId()); best.setProcessName(processData.getName()); best.setSort(process.getSort()); best.setItemId(itemId); best.setItemName(item.getName()); best.setParamTemplateId(process.getParamTemplateId()); best.setColorCode(process.getColorCode()); best.setWorkstationId(station.getId()); best.setWorkstationName(station.getName()); best.setMachineryIds(new ArrayList<>(usableIds)); best.setMachineryNames(machines.stream().map(MesDvMachineryDO::getName).collect(Collectors.joining("、"))); best.setHourlyCapacity(capacity); best.setQuantity(remaining); best.setDuration(slot.hours()); best.setStartTime(slot.start()); best.setEndTime(slot.end()); } } if (best == null) throw new IllegalArgumentException("工序" + processData.getName() + "没有可用工作站及有效设备产能,或资源存在未知/超时任务,请先维护"); plans.add(best); trialBusy.add(new MesProScheduleEngine.Busy(best.getWorkstationId(), stationMachines.getOrDefault(best.getWorkstationId(), Set.of()), best.getStartTime(), best.getEndTime())); earliest = best.getEndTime().isAfter(existingEnd) ? best.getEndTime() : existingEnd; earliest = earliest.plusMinutes(waitMinutes); } if (plans.isEmpty()) throw new IllegalArgumentException("所有工序已完成排产,不可重复提交"); if (result.getTasks().size() + plans.size() > 200) throw new IllegalArgumentException("一次最多生成200个任务,请拆分工单批次排产"); result.getTasks().addAll(plans); busy = trialBusy; orderPlan.setTaskCount(plans.size()); orderPlan.setEndTime(earliest); ends.put(id, earliest); if (order.getRequestDate() != null && earliest.toLocalDate().isAfter(order.getRequestDate().toLocalDate())) { orderPlan.setLate(true); result.getWarnings().add("工单" + order.getCode() + "预计完成时间晚于交期"); } } catch (ServiceException | IllegalArgumentException | ArithmeticException error) { orderPlan.setError((orderPlan.getWorkOrderCode() == null ? "工单#" + id : orderPlan.getWorkOrderCode()) + ":" + error.getMessage()); } } result.setCanExecute(result.getOrders().stream().noneMatch(order -> order.getError() != null) && !result.getTasks().isEmpty()); // 不包含生成时间;同一输入与业务数据应得到稳定的预览指纹。 var fingerprint = new LinkedHashMap(); fingerprint.put("startTime", request.getStartTime()); fingerprint.put("orders", result.getOrders()); fingerprint.put("tasks", result.getTasks()); fingerprint.put("holidays", holidayDates.stream().sorted().toList()); fingerprint.put("workOrders", orders.stream().sorted(Comparator.comparing(MesProWorkOrderDO::getId)).toList()); fingerprint.put("paramTemplates", templateHashes); result.setPreviewHash(DigestUtil.sha256Hex(JsonUtils.toJsonString(fingerprint))); return result; } private List processes(Long id, Long routeId) { var saved = workOrderProcessService.getWorkOrderProcessListByWorkOrderId(id); List result = new ArrayList<>(); if (!saved.isEmpty()) { for (var row : saved) result.add(new MesProRouteProcessDO().setProcessId(row.getProcessId()).setSort(row.getSort()) .setParamTemplateId(row.getParamTemplateId()).setYieldRate(row.getYieldRate()).setOutputItemId(row.getOutputItemId()).setColorCode(row.getColorCode()) .setPrepareTime(row.getPrepareTime()).setWaitTime(row.getWaitTime()).setNextProcessId(row.getNextProcessId()).setLinkType(row.getLinkType())); } else result.addAll(routeProcessService.getRouteProcessListByRouteId(routeId)); result.sort(Comparator.comparing(MesProRouteProcessDO::getSort, Comparator.nullsLast(Comparator.naturalOrder()))); return result; } /** * 按工序收率从末道往前倒推各道工序的目标产出数量:末道=订单量,前面各道除以后方工序的累计收率后向上取整; * 收率为空按 1.0 处理。 */ private Map calcTargetQuantities(List processes, BigDecimal orderQuantity) { return MesProScheduleQuantityService.targetQuantities(processes, orderQuantity); } private void addChildrenFirst(Long id, Map orders, List sequence, Set visiting) { if (sequence.contains(id)) return; if (!visiting.add(id)) throw exception(PRO_TASK_SCHEDULE_INVALID, "工单父子关系存在循环,请先修复"); orders.values().stream().filter(order -> Objects.equals(order.getParentId(), id)).sorted(Comparator.comparing(MesProWorkOrderDO::getId)) .forEach(order -> addChildrenFirst(order.getId(), orders, sequence, visiting)); visiting.remove(id); sequence.add(id); } }