gaoluyang
9 天以前 e1590017b716d77f88f075364c55cfbdb480286f
诚发五金:生产链路支持无工序/无工艺路线单工序模式

- 工作站保存允许不选工序,工时/设备绑定仍可用
- 工单/任务/报工去掉路线与工序必填校验,无路线时按单工序生产,产出回落到虚拟在制品仓
- MPS 下发不再要求工艺路线,routeId 为空时只生成工单不生成工序
- 修复工单列表/详情按 processId 分组时对 null 抛 NPE

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改6个文件
189 ■■■■■ 文件已修改
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/md/workstation/vo/MesMdWorkstationSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskServiceImpl.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/md/workstation/vo/MesMdWorkstationSaveReqVO.java
@@ -27,8 +27,7 @@
    @NotNull(message = "所在车间不能为空")
    private Long workshopId;
    @Schema(description = "工序编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "所属工序不能为空")
    @Schema(description = "工序编号(可选,未配置工序时工作站仍可正常使用)", example = "1")
    private Long processId;
    @Schema(description = "线边库编号", example = "1")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderController.java
@@ -285,7 +285,10 @@
            List<MesProWorkOrderProcessProgressVO> progressList = new ArrayList<>();
            Map<Long, List<MesProTaskDO>> processTaskMap = convertMultiMap(tasks, MesProTaskDO::getProcessId);
            // 未配置工序的单工序任务 processId 为空,分组前先过滤,避免 null key 抛 NPE
            Map<Long, List<MesProTaskDO>> processTaskMap = convertMultiMap(
                    tasks.stream().filter(task -> task.getProcessId() != null).toList(),
                    MesProTaskDO::getProcessId);
            
            for (MesProWorkOrderProcessDO process : processes) {
                List<MesProTaskDO> pTasks = processTaskMap.getOrDefault(process.getProcessId(), Collections.emptyList());
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationServiceImpl.java
@@ -98,8 +98,10 @@
        validateWorkstationNameUnique(id, reqVO.getName());
        // 校验车间存在
        validateWorkshopExists(reqVO.getWorkshopId());
        // 校验工序存在
        processService.validateProcessExistsAndEnable(reqVO.getProcessId());
        // 校验工序存在(工序为可选项,未配置工序时工作站仍可正常使用)
        if (reqVO.getProcessId() != null) {
            processService.validateProcessExistsAndEnable(reqVO.getProcessId());
        }
        // 处理仓库层级(未指定仓库时自动设置虚拟线边库)
        handleWarehouseHierarchy(reqVO);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java
@@ -311,7 +311,12 @@
        // 2.1 查询工序的关键工序标识 + 检验标识(需在 uncheckQuantity 校验之前,因为质检工序允许 uncheckQuantity > 0)
        MesProRouteProcessDO routeProcess = routeProcessService.getRouteProcessByRouteIdAndProcessId(
                feedback.getRouteId(), feedback.getProcessId());
        boolean keyFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getKeyFlag());
        // 未配置工艺路线工序时,视为单工序生产,按最后一道工序更新工单产量
        boolean noProcessChain = routeProcess == null
                && CollUtil.isEmpty(workOrderProcessService.getWorkOrderProcessListByWorkOrderId(feedback.getWorkOrderId()));
        boolean keyFlag = routeProcess != null
                ? Boolean.TRUE.equals(routeProcess.getKeyFlag())
                : noProcessChain;
        boolean checkFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getCheckFlag());
        // 2.2 非质检工序:合格品/不良品数量由质检确定;本工序不经过质检,报工未填写时默认全部为合格品
        if (!checkFlag
@@ -422,7 +427,9 @@
        // 3. 更新订单的已生产数量
        // 新增逻辑:只有在该笔订单最后一个工序才更新生产订单的已生产数量
        MesProWorkOrderProcessDO lastProcess = workOrderProcessService.getLastWorkOrderProcessByWorkOrderId(feedback.getWorkOrderId());
        if (lastProcess != null && ObjUtil.equal(lastProcess.getProcessId(), feedback.getProcessId())) {
        // 未配置工序链路的单工序工单,报工即视为完成最后一道工序
        boolean noProcessChain = lastProcess == null && feedback.getRouteId() == null;
        if (noProcessChain || (lastProcess != null && ObjUtil.equal(lastProcess.getProcessId(), feedback.getProcessId()))) {
            workOrderService.updateProducedQuantity(feedback.getWorkOrderId(),
                    feedback.getFeedbackQuantity());
            // 4. 更新 MPS 的已生产数量(如果是销售订单来源)
@@ -508,12 +515,7 @@
        reqVO.setItemId(task.getItemId());
        reqVO.setWorkstationId(task.getWorkstationId());
        // 3. 校验工艺路线 + 工序配置有效
        MesProRouteProcessDO routeProcess = routeProcessService.getRouteProcessByRouteIdAndProcessId(
                task.getRouteId(), task.getProcessId());
        if (routeProcess == null) {
            throw exception(PRO_FEEDBACK_ROUTE_PROCESS_INVALID);
        }
        // 3. 未配置工艺路线/工序时按单工序报工处理,不再强制校验路线工序配置
        // 4.1 校验数量:无论是否需要检验,报工只需填报工数量,且必须 > 0
        // 合格品/不良品数量由质检确定(非质检工序在审批时默认全部为合格品)
        if (reqVO.getFeedbackQuantity() == null
@@ -603,12 +605,7 @@
        // 1.1 顺序报工校验:同一生产工单内,上一道工序必须已报工完成,否则不允许报工
        validatePreviousProcessFinished(task);
        // 2. 校验工艺路线工序配置
        MesProRouteProcessDO routeProcess = routeProcessService.getRouteProcessByRouteIdAndProcessId(
                task.getRouteId(), task.getProcessId());
        if (routeProcess == null) {
            throw exception(PRO_FEEDBACK_ROUTE_PROCESS_INVALID);
        }
        // 2. 未配置工艺路线/工序时按单工序报工处理,不再强制校验路线工序配置
        // 3. 校验数量:只填报工数量,且必须 > 0(合格品/不良品数量由质检确定)
        if (reqVO.getFeedbackQuantity() == null
                || reqVO.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java
@@ -676,19 +676,12 @@
     * 根据 MPS 创建生产工单
     */
    private Long createWorkOrderFromMps(MesProMpsDO mps, BigDecimal quantity) {
        // 1. 校验产品是否存在工艺路线
        // 1. 查找产品工艺路线;未配置时不阻断,按单工序生产处理
        MesProRouteProductDO routeProduct = routeProductService.getRouteProductByItemId(mps.getProductId());
        if (routeProduct == null) {
            throw exception(PRO_ROUTE_NOT_EXISTS);
        }
        Long routeId = routeProduct.getRouteId();
        // 2. 校验工艺路线是否有工序(按产品过滤其它产品的产出物料)
        List<MesProRouteProcessDO> routeProcesses = routeProcessService
                .getRouteProcessListByProductId(mps.getProductId());
        if (CollUtil.isEmpty(routeProcesses)) {
            throw exception(PRO_ROUTE_ENABLE_NO_PROCESS);
        }
        Long routeId = routeProduct != null ? routeProduct.getRouteId() : null;
        List<MesProRouteProcessDO> routeProcesses = routeProduct != null
                ? routeProcessService.getRouteProcessListByProductId(mps.getProductId())
                : Collections.emptyList();
        // 3. 创建生产工单
        MesProWorkOrderSaveReqVO workOrderReq = new MesProWorkOrderSaveReqVO();
@@ -705,46 +698,50 @@
        Long workOrderId = workOrderService.createWorkOrder(workOrderReq);
        // 4. 更新工单工艺路线
        workOrderMapper.updateById(new MesProWorkOrderDO().setId(workOrderId).setRouteId(routeId));
        // 5. 保存工单工序(从工艺路线工序带入,含投料BOM)
        Map<Long, List<MesProRouteProductBomDO>> bomMap = routeProcessService.getRouteProcessBomMapByProductId(mps.getProductId());
        List<MesProWorkOrderProcessSaveReqVO> processList = new ArrayList<>();
        for (MesProRouteProcessDO rp : routeProcesses) {
            MesProWorkOrderProcessSaveReqVO process = new MesProWorkOrderProcessSaveReqVO();
            process.setWorkOrderId(workOrderId);
            process.setProcessId(rp.getProcessId());
            process.setSort(rp.getSort());
            process.setKeyFlag(rp.getKeyFlag());
            process.setCheckFlag(rp.getCheckFlag());
            process.setBackflushFlag(rp.getBackflushFlag());
            if (rp.getOutputItemId() != null && !rp.getOutputItemId().isBlank()) {
                process.setOutputItemIds(Arrays.stream(rp.getOutputItemId().split(","))
                        .map(String::trim).filter(s -> !s.isEmpty())
                        .map(Long::valueOf).collect(Collectors.toList()));
            }
            process.setColorCode(rp.getColorCode());
            // 带入工艺路线产品配置的报工仓库
            process.setWarehouseId(routeProduct.getWarehouseId());
            process.setLocationId(routeProduct.getLocationId());
            process.setAreaId(routeProduct.getAreaId());
            // 带入工序投料BOM
            List<MesProRouteProductBomDO> routeBomItems = bomMap != null ? bomMap.get(rp.getProcessId()) : null;
            if (CollUtil.isNotEmpty(routeBomItems)) {
                List<MesProWorkOrderProcessSaveReqVO.BomItem> bomItems = new ArrayList<>();
                for (MesProRouteProductBomDO bomDO : routeBomItems) {
                    MesProWorkOrderProcessSaveReqVO.BomItem bomItem = new MesProWorkOrderProcessSaveReqVO.BomItem();
                    bomItem.setItemId(bomDO.getItemId());
                    bomItem.setQuantity(bomDO.getQuantity());
                    bomItem.setRemark(bomDO.getRemark());
                    bomItems.add(bomItem);
                }
                process.setBomItems(bomItems);
            }
            processList.add(process);
        // 4. 更新工单工艺路线(未配置路线时保持为空,按单工序生产)
        if (routeId != null) {
            workOrderMapper.updateById(new MesProWorkOrderDO().setId(workOrderId).setRouteId(routeId));
        }
        workOrderProcessService.batchSaveWorkOrderProcess(processList);
        // 5. 保存工单工序(仅配置了工艺路线工序时带入,含投料BOM)
        if (CollUtil.isNotEmpty(routeProcesses)) {
            Map<Long, List<MesProRouteProductBomDO>> bomMap = routeProcessService.getRouteProcessBomMapByProductId(mps.getProductId());
            List<MesProWorkOrderProcessSaveReqVO> processList = new ArrayList<>();
            for (MesProRouteProcessDO rp : routeProcesses) {
                MesProWorkOrderProcessSaveReqVO process = new MesProWorkOrderProcessSaveReqVO();
                process.setWorkOrderId(workOrderId);
                process.setProcessId(rp.getProcessId());
                process.setSort(rp.getSort());
                process.setKeyFlag(rp.getKeyFlag());
                process.setCheckFlag(rp.getCheckFlag());
                process.setBackflushFlag(rp.getBackflushFlag());
                if (rp.getOutputItemId() != null && !rp.getOutputItemId().isBlank()) {
                    process.setOutputItemIds(Arrays.stream(rp.getOutputItemId().split(","))
                            .map(String::trim).filter(s -> !s.isEmpty())
                            .map(Long::valueOf).collect(Collectors.toList()));
                }
                process.setColorCode(rp.getColorCode());
                // 带入工艺路线产品配置的报工仓库
                process.setWarehouseId(routeProduct.getWarehouseId());
                process.setLocationId(routeProduct.getLocationId());
                process.setAreaId(routeProduct.getAreaId());
                // 带入工序投料BOM
                List<MesProRouteProductBomDO> routeBomItems = bomMap != null ? bomMap.get(rp.getProcessId()) : null;
                if (CollUtil.isNotEmpty(routeBomItems)) {
                    List<MesProWorkOrderProcessSaveReqVO.BomItem> bomItems = new ArrayList<>();
                    for (MesProRouteProductBomDO bomDO : routeBomItems) {
                        MesProWorkOrderProcessSaveReqVO.BomItem bomItem = new MesProWorkOrderProcessSaveReqVO.BomItem();
                        bomItem.setItemId(bomDO.getItemId());
                        bomItem.setQuantity(bomDO.getQuantity());
                        bomItem.setRemark(bomDO.getRemark());
                        bomItems.add(bomItem);
                    }
                    process.setBomItems(bomItems);
                }
                processList.add(process);
            }
            workOrderProcessService.batchSaveWorkOrderProcess(processList);
        }
        // 6. 工单直接确认为已确认状态
        workOrderService.confirmWorkOrder(workOrderId);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskServiceImpl.java
@@ -138,19 +138,18 @@
        scheduleGuard.lock();
        // 1. 校验关联数据存在
        MesProWorkOrderDO workOrder = workOrderService.validateWorkOrderConfirmed(createReqVO.getWorkOrderId());
        var station = workstationService.validateWorkstationExistsAndEnable(createReqVO.getWorkstationId());
        if (!java.util.Objects.equals(station.getProcessId(), createReqVO.getProcessId())) {
            throw exception(cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_SCHEDULE_INVALID, "工作站与工序不匹配");
        }
        routeService.validateRouteExists(createReqVO.getRouteId());
        processService.validateProcessExistsAndEnable(createReqVO.getProcessId());
        workstationService.validateWorkstationExistsAndEnable(createReqVO.getWorkstationId());
        // 2. 自动确定 itemId:优先前端传入 → 工艺路线工序的产出产品 → 工单成品
        // 未配置工序/工艺路线时,任务直接按工单成品产出
        Long itemId = createReqVO.getItemId();
        if (itemId == null) {
        if (itemId == null && createReqVO.getRouteId() != null && createReqVO.getProcessId() != null) {
            MesProRouteProcessDO routeProcess = routeProcessService
                    .getRouteProcessByRouteIdAndProcessId(createReqVO.getRouteId(), createReqVO.getProcessId());
            itemId = resolveTaskItemId(routeProcess, workOrder.getProductId());
        }
        if (itemId == null) {
            itemId = workOrder.getProductId();
        }
        MesMdItemDO item = itemService.validateItemExists(itemId);
@@ -204,12 +203,6 @@
        if (updateReqVO.getWorkstationId() != null) {
            workstationService.validateWorkstationExists(updateReqVO.getWorkstationId());
        }
        if (updateReqVO.getRouteId() != null) {
            routeService.validateRouteExists(updateReqVO.getRouteId());
        }
        if (updateReqVO.getProcessId() != null) {
            processService.validateProcessExistsAndEnable(updateReqVO.getProcessId());
        }
        MesMdItemDO item;
        if (updateReqVO.getItemId() != null) {
            item = itemService.validateItemExists(updateReqVO.getItemId());
@@ -225,10 +218,6 @@
                .setQuantity(quantity)
                .setStartTime(updateReqVO.getStartTime() != null ? updateReqVO.getStartTime() : task.getStartTime())
                .setEndTime(updateReqVO.getEndTime() != null ? updateReqVO.getEndTime() : task.getEndTime());
        var station = workstationService.validateWorkstationExistsAndEnable(candidate.getWorkstationId());
        if (!java.util.Objects.equals(station.getProcessId(), candidate.getProcessId())) {
            throw exception(cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_SCHEDULE_INVALID, "工作站与工序不匹配");
        }
        scheduleGuard.validate(workOrderService.validateWorkOrderConfirmed(candidate.getWorkOrderId()), candidate, task.getId());
        // 2. 更新
@@ -249,13 +238,24 @@
    private void synchronizeScheduledQuantity(MesProWorkOrderDO order, Long routeId) {
        List<MesProRouteProcessDO> required = buildRouteProcessList(routeId, order.getId());
        List<MesProTaskDO> tasks = taskMapper.selectListByWorkOrderId(order.getId());
        BigDecimal scheduled = order.getQuantity();
        for (var process : required) {
            BigDecimal amount = tasks.stream().filter(task -> java.util.Objects.equals(task.getProcessId(), process.getProcessId())
                            && !java.util.Objects.equals(task.getStatus(), MesProTaskStatusEnum.CANCELED.getStatus()))
        BigDecimal orderQuantity = order.getQuantity() == null ? BigDecimal.ZERO : order.getQuantity();
        BigDecimal scheduled;
        if (CollUtil.isEmpty(required)) {
            // 未配置工序/工艺路线:整张工单只有一个生产步骤,已排产量按任务数量合计
            scheduled = tasks.stream()
                    .filter(task -> !java.util.Objects.equals(task.getStatus(), MesProTaskStatusEnum.CANCELED.getStatus()))
                    .map(task -> task.getQuantity() == null ? BigDecimal.ZERO : task.getQuantity())
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            scheduled = scheduled.min(amount);
                    .reduce(BigDecimal.ZERO, BigDecimal::add)
                    .min(orderQuantity);
        } else {
            scheduled = orderQuantity;
            for (var process : required) {
                BigDecimal amount = tasks.stream().filter(task -> java.util.Objects.equals(task.getProcessId(), process.getProcessId())
                                && !java.util.Objects.equals(task.getStatus(), MesProTaskStatusEnum.CANCELED.getStatus()))
                        .map(task -> task.getQuantity() == null ? BigDecimal.ZERO : task.getQuantity())
                        .reduce(BigDecimal.ZERO, BigDecimal::add);
                scheduled = scheduled.min(amount);
            }
        }
        scheduleWorkOrderMapper.updateById(new MesProWorkOrderDO().setId(order.getId()).setQuantityScheduled(scheduled));
    }
@@ -340,7 +340,10 @@
            }
            return result;
        }
        // 2. 回退到工艺路线模板工序
        // 2. 回退到工艺路线模板工序;未配置工艺路线时视为单工序生产
        if (routeId == null) {
            return new java.util.ArrayList<>();
        }
        List<MesProRouteProcessDO> routeProcesses = routeProcessService.getRouteProcessListByRouteId(routeId);
        if (CollUtil.isEmpty(routeProcesses)) {
            throw exception(cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_ROUTE_ENABLE_NO_PROCESS);