yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleService.java
@@ -14,6 +14,13 @@
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.dal.mysql.pro.workorder.MesProWorkOrderBomMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.warehouse.MesWmWarehouseMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
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;
@@ -22,6 +29,7 @@
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 cn.iocoder.yudao.module.mes.util.MesOutputItemUtils;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -41,6 +49,9 @@
public class MesProBatchScheduleService {
    @Resource private MesProTaskMapper taskMapper;
    @Resource private MesProWorkOrderMapper workOrderMapper;
    @Resource private MesProWorkOrderBomMapper workOrderBomMapper;
    @Resource private MesWmMaterialStockMapper materialStockMapper;
    @Resource private MesWmWarehouseMapper warehouseMapper;
    @Resource private MesDvMachineryMapper machineryMapper;
    @Resource private MesMdWorkstationService workstationService;
    @Resource private MesProWorkOrderProcessService workOrderProcessService;
@@ -127,7 +138,34 @@
        result.setPreviewTime(now());
        var orders = workOrderMapper.selectByIds(request.getWorkOrderIds());
        var orderMap = orders.stream().collect(Collectors.toMap(MesProWorkOrderDO::getId, order -> order));
        List<Long> permitted = customerApi.getPermittedCustomerIds();
        var bomByOrder = new HashMap<Long, List<MesProWorkOrderBomDO>>();
        for (var order : orders) bomByOrder.put(order.getId(), workOrderBomMapper.selectListByWorkOrderId(order.getId()).stream()
                .sorted(Comparator.comparing(MesProWorkOrderBomDO::getId)).toList());
        var materialIds = bomByOrder.values().stream().flatMap(Collection::stream).map(MesProWorkOrderBomDO::getItemId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        var warehouses = warehouseMapper.selectList().stream().collect(Collectors.toMap(MesWmWarehouseDO::getId, w -> w));
        var stocks = materialIds.isEmpty() ? List.<MesWmMaterialStockDO>of() : materialStockMapper.selectList(
                new LambdaQueryWrapperX<MesWmMaterialStockDO>().in(MesWmMaterialStockDO::getItemId, materialIds));
        Map<Long, BigDecimal> availableStock = new HashMap<>();
        for (var stock : stocks) {
            var warehouse = warehouses.get(stock.getWarehouseId());
            if (warehouse == null || Boolean.TRUE.equals(warehouse.getFrozen()) || Boolean.TRUE.equals(stock.getFrozen())
                    || MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE.equals(warehouse.getCode())) continue;
            var available = Optional.ofNullable(stock.getQuantity()).orElse(BigDecimal.ZERO)
                    .subtract(Optional.ofNullable(stock.getFrozenQuantity()).orElse(BigDecimal.ZERO))
                    .subtract(Optional.ofNullable(stock.getReservedQuantity()).orElse(BigDecimal.ZERO)).max(BigDecimal.ZERO);
            availableStock.merge(stock.getItemId(), available, BigDecimal::add);
        }
        var materialInfo = materialIds.isEmpty() ? Map.<Long, cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO>of()
                : itemService.getItemMap(materialIds);
        for (Long itemId : materialIds) {
            var item = materialInfo.get(itemId);
            if (item != null && Boolean.TRUE.equals(item.getSafeStockFlag()) && item.getMinStock() != null) {
                availableStock.compute(itemId, (key, value) -> Optional.ofNullable(value).orElse(BigDecimal.ZERO)
                        .subtract(item.getMinStock().max(BigDecimal.ZERO)).max(BigDecimal.ZERO));
            }
        }
        var stockSnapshot = new TreeMap<>(availableStock);
        var stations = workstationService.getWorkstationListByStatus(0).stream()
                .sorted(Comparator.comparing(MesMdWorkstationDO::getId)).toList();
        var machineMap = machineryMapper.selectList().stream().collect(Collectors.toMap(MesDvMachineryDO::getId, machine -> machine));
@@ -154,8 +192,7 @@
                        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();
        var sorted = orders.stream().sorted(MesSchedulePriority.comparator()).toList();
        List<Long> orderSequence = new ArrayList<>();
        Set<Long> visiting = new HashSet<>();
        for (var order : sorted) addChildrenFirst(order.getId(), orderMap, orderSequence, visiting);
@@ -170,9 +207,6 @@
            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());
@@ -185,6 +219,20 @@
                        || (order.getQuantityProduced() != null && order.getQuantityProduced().compareTo(order.getQuantity()) >= 0)) {
                    throw new IllegalArgumentException("工单数量无效或已生产完成");
                }
                var boms = bomByOrder.getOrDefault(id, List.of());
                if (boms.isEmpty()) throw new IllegalArgumentException("工单没有 BOM,无法核对物料齐套,请先维护");
                BigDecimal unscheduled = order.getQuantity().subtract(Optional.ofNullable(order.getQuantityScheduled()).orElse(BigDecimal.ZERO));
                if (unscheduled.signum() <= 0) throw new IllegalArgumentException("工单已完成排产");
                Map<Long, BigDecimal> demand = new HashMap<>();
                for (var bom : boms) {
                    if (bom.getItemId() == null || bom.getQuantity() == null || bom.getQuantity().signum() <= 0)
                        throw new IllegalArgumentException("工单 BOM 物料或需求数量无效,请先维护");
                    var requiredItem = materialInfo.get(bom.getItemId());
                    if (requiredItem == null || !Objects.equals(requiredItem.getStatus(), 0))
                        throw new IllegalArgumentException("工单 BOM 存在已删除或停用的物料,请先维护");
                    demand.merge(bom.getItemId(), bom.getQuantity().multiply(unscheduled).divide(order.getQuantity(), 6, RoundingMode.UP), BigDecimal::add);
                }
                var trialStock = MesScheduleMaterialCheck.allocate(availableStock, demand);
                LocalDateTime earliest = calendar.nextWorkingTime(request.getStartTime());
                // 父工单必须等子工单;同一批次按依赖顺序排,未纳入的未完成子工单阻止排产。
                for (var child : workOrderMapper.selectList(MesProWorkOrderDO::getParentId, id)) {
@@ -256,9 +304,9 @@
                    }
                    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());
                        List<Long> outputIds = MesOutputItemUtils.parseItemIds(process.getOutputItemId());
                        if (outputIds.size() != 1) throw new IllegalArgumentException("多产出工序请使用手工排产确认各产出数量");
                        itemId = outputIds.get(0);
                    }
                    var item = itemService.validateItemExists(itemId);
                    if (!Objects.equals(item.getStatus(), 0)) throw new IllegalArgumentException("工序产出产品已停用");
@@ -303,7 +351,7 @@
                }
                if (plans.isEmpty()) throw new IllegalArgumentException("所有工序已完成排产,不可重复提交");
                if (result.getTasks().size() + plans.size() > 200) throw new IllegalArgumentException("一次最多生成200个任务,请拆分工单批次排产");
                result.getTasks().addAll(plans); busy = trialBusy;
                result.getTasks().addAll(plans); busy = trialBusy; availableStock = trialStock;
                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() + "预计完成时间晚于交期");
@@ -319,6 +367,8 @@
        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);
        fingerprint.put("materialStock", stockSnapshot);
        fingerprint.put("workOrderBoms", new TreeMap<>(bomByOrder));
        result.setPreviewHash(DigestUtil.sha256Hex(JsonUtils.toJsonString(fingerprint)));
        return result;
    }