云
4 小时以前 e09f103259fa602382bb5ce2a03361a6397af46a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
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<Long> 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<Long> 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<Long> 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<Long, MesProTaskDO> 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<LocalDate> 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<MesProScheduleEngine.Busy> busy = new ArrayList<>();
        Set<Long> 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<Long> orderSequence = new ArrayList<>();
        Set<Long> 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<Long, LocalDateTime> ends = new HashMap<>();
        Map<Long, String> templateHashes = new TreeMap<>();
        final List<MesProScheduleEngine.Busy> 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<MesProRouteProcessDO> processes = processes(id, routeId);
                if (processes.isEmpty()) throw new IllegalArgumentException("没有工序,请先维护工单工序");
                Set<Long> processIds = new HashSet<>();
                Set<Integer> sorts = new HashSet<>();
                Map<Long, Integer> processSorts = processes.stream().filter(process -> process.getProcessId() != null && process.getSort() != null)
                        .collect(Collectors.toMap(MesProRouteProcessDO::getProcessId, MesProRouteProcessDO::getSort, (first, duplicate) -> first));
                List<TaskPlan> plans = new ArrayList<>();
                Map<Long, BigDecimal> targetQuantities = calcTargetQuantities(processes, order.getQuantity());
                List<MesProScheduleEngine.Busy> 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<Long> 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<MesDvMachineryDO> 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<Long> 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<String, Object>();
        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<MesProRouteProcessDO> processes(Long id, Long routeId) {
        var saved = workOrderProcessService.getWorkOrderProcessListByWorkOrderId(id);
        List<MesProRouteProcessDO> 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<Long, BigDecimal> calcTargetQuantities(List<MesProRouteProcessDO> processes, BigDecimal orderQuantity) {
        return MesProScheduleQuantityService.targetQuantities(processes, orderQuantity);
    }
 
    private void addChildrenFirst(Long id, Map<Long, MesProWorkOrderDO> orders, List<Long> sequence, Set<Long> 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);
    }
}