gaoluyang
10 天以前 cbadfd8e691ab335965202a984db16e55ac8e42c
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java
@@ -5,9 +5,17 @@
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.erp.api.purchase.ErpPurchaseOrderApi;
import cn.iocoder.yudao.module.erp.api.purchase.ErpPurchaseRequestApi;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseOrderItemRespDTO;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseRequestCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsAuditReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.MesProWorkOrderItemRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.MesProWorkOrderSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.mps.MesProMpsDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.mps.MesProMpsMergeItemDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.mps.MesProMpsMapper;
@@ -22,7 +30,9 @@
import cn.iocoder.yudao.module.mes.enums.pro.MesProMpsStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderSourceTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.pro.material.MesProMaterialRequirementService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProcessService;
@@ -45,6 +55,8 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -53,7 +65,7 @@
/**
 * MES 生产主计划(MPS)Service 实现类
 *
 * @author 芋道源码
 * @author 超级管理员
 */
@Service
@Validated
@@ -87,6 +99,12 @@
    private MesProRouteProcessService routeProcessService;
    @Resource
    private MesProWorkOrderProcessService workOrderProcessService;
    @Resource
    private MesProMaterialRequirementService materialRequirementService;
    @Resource
    private ErpPurchaseOrderApi purchaseOrderApi;
    @Resource
    private ErpPurchaseRequestApi purchaseRequestApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -140,6 +158,13 @@
    public Long createMps(MesProMpsSaveReqVO createReqVO) {
        // 校验产品存在
        itemService.validateItemExists(createReqVO.getProductId());
        // 计量单位未填写时,默认取产品单位
        if (createReqVO.getUnitMeasureId() == null) {
            MesMdItemDO product = itemService.getItem(createReqVO.getProductId());
            if (product != null) {
                createReqVO.setUnitMeasureId(product.getUnitMeasureId());
            }
        }
        // 如果未填写编码,自动生成
        if (StrUtil.isBlank(createReqVO.getCode())) {
@@ -163,22 +188,25 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateMps(MesProMpsSaveReqVO updateReqVO) {
        // 校验存在 + 草稿状态
        validateMpsExistsAndDraft(updateReqVO.getId());
        // 校验存在 + 可编辑状态(未开始、审核中)
        MesProMpsDO mps = validateMpsExistsAndEditable(updateReqVO.getId());
        // 校验编码唯一
        validateMpsCodeUnique(updateReqVO.getId(), updateReqVO.getCode());
        // 更新
        // 更新;审核中的计划被修改后需要重新提交审核
        MesProMpsDO updateObj = BeanUtils.toBean(updateReqVO, MesProMpsDO.class);
        if (MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
            updateObj.setStatus(MesProMpsStatusEnum.DRAFT.getStatus());
        }
        mpsMapper.updateById(updateObj);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteMps(Long id) {
        // 校验存在 + 草稿状态
        MesProMpsDO mps = validateMpsExistsAndDraft(id);
        // 校验存在 + 可编辑状态(未开始、审核中)
        MesProMpsDO mps = validateMpsExistsAndEditable(id);
        // 校验是否已关联工单
        if (mps.getWorkOrderId() != null) {
@@ -191,9 +219,98 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitMps(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())
                && !MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_DRAFT);
        }
        mpsMapper.updateById(new MesProMpsDO()
                .setId(id)
                .setStatus(MesProMpsStatusEnum.AUDITING.getStatus()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void auditMps(MesProMpsAuditReqVO reqVO) {
        MesProMpsDO mps = validateMpsExists(reqVO.getId());
        if (!MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_AUDITING);
        }
        boolean approved = Boolean.TRUE.equals(reqVO.getApproved());
        if (!approved && StrUtil.isBlank(reqVO.getAuditRemark())) {
            throw exception(PRO_MPS_AUDIT_REMARK_REQUIRED);
        }
        mpsMapper.updateById(new MesProMpsDO()
                .setId(reqVO.getId())
                .setStatus(approved ? MesProMpsStatusEnum.APPROVED.getStatus()
                        : MesProMpsStatusEnum.DRAFT.getStatus())
                .setAuditUserId(SecurityFrameworkUtils.getLoginUserId())
                .setAuditTime(LocalDateTime.now())
                .setAuditRemark(approved
                        ? StrUtil.blankToDefault(reqVO.getAuditRemark(), "审核通过")
                        : reqVO.getAuditRemark()));
    }
    @Override
    public List<MesProWorkOrderItemRespVO> getMpsItemList(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        // 按产品 BOM(优先工艺路线 BOM)展开叶子物料,并按计划数量折算需求;
        // 计划尚未生成工单,无来源为本计划的在途采购,直接按可用库存判断是否充足
        return materialRequirementService.getItemListByProduct(mps.getProductId(), mps.getQuantity());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long generatePurchaseRequest(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        if (!MesProMpsStatusEnum.APPROVED.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_APPROVED);
        }
        // 1. 只对缺口物料生成采购明细:缺口 = 需求 - 可用库存 - 在途采购
        List<ErpPurchaseRequestCreateReqDTO.Item> shortageItems = new ArrayList<>();
        for (MesProWorkOrderItemRespVO item : getMpsItemList(id)) {
            if (Boolean.TRUE.equals(item.getStockSufficient())) {
                continue;
            }
            BigDecimal supply = nullToZero(item.getStockQuantity())
                    .add(nullToZero(item.getPurchaseOrderQuantity()));
            BigDecimal shortage = nullToZero(item.getQuantity()).subtract(supply);
            if (shortage.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            MesMdItemDO itemDO = itemService.getItem(item.getItemId());
            if (itemDO == null) {
                continue;
            }
            shortageItems.add(new ErpPurchaseRequestCreateReqDTO.Item()
                    .setProductId(item.getItemId())
                    .setProductUnitId(itemDO.getUnitMeasureId())
                    .setCount(shortage)
                    .setDemandTime(mps.getRequestDate() != null ? mps.getRequestDate().toLocalDate() : null)
                    .setRemark("生产计划 " + mps.getCode() + " 库存缺口 " + shortage.stripTrailingZeros().toPlainString()));
        }
        if (CollUtil.isEmpty(shortageItems)) {
            throw exception(PRO_MPS_NO_MATERIAL_SHORTAGE);
        }
        // 2. 生成采购申请单(草稿,无供应商,后续由采购人员补充并按流程提交)
        ErpPurchaseRequestCreateReqDTO createReqDTO = new ErpPurchaseRequestCreateReqDTO()
                .setRequestTime(LocalDateTime.now())
                .setRequestReason("生产计划 " + mps.getCode() + " 辅料采购")
                .setRemark("由生产计划自动生成,共 " + shortageItems.size() + " 项物料库存不足")
                .setItems(shortageItems);
        Long purchaseRequestId = purchaseRequestApi.createPurchaseRequest(createReqDTO).getCheckedData();
        // 3. 回写采购申请单编号,便于从计划追溯
        mpsMapper.updateById(new MesProMpsDO().setId(id).setPurchaseRequestId(purchaseRequestId));
        log.info("[generatePurchaseRequest] 计划({}) 生成采购申请单({}),明细 {} 项", mps.getCode(), purchaseRequestId, shortageItems.size());
        return purchaseRequestId;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long issueMps(Long id) {
        // 校验存在 + 草稿状态
        MesProMpsDO mps = validateMpsExistsAndDraft(id);
        // 校验存在 + 已审核状态
        MesProMpsDO mps = validateMpsExistsAndApproved(id);
        // 创建生产工单
        Long workOrderId = createWorkOrderFromMps(mps, mps.getQuantity());
@@ -222,11 +339,11 @@
            throw exception(PRO_MPS_NOT_EXISTS);
        }
        // 校验:必须是相同产品、草稿状态
        // 校验:必须是相同产品、已审核状态
        Long productId = null;
        for (MesProMpsDO mps : mpsList) {
            if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())) {
                throw exception(PRO_MPS_NOT_DRAFT);
            if (!MesProMpsStatusEnum.APPROVED.getStatus().equals(mps.getStatus())) {
                throw exception(PRO_MPS_NOT_APPROVED);
            }
            if (productId == null) {
                productId = mps.getProductId();
@@ -282,14 +399,32 @@
            throw exception(PRO_MPS_NOT_ISSUED);
        }
        // 级联取消关联的生产工单
        // 级联取消关联的生产工单(跟随工单当前状态,且不误伤合并下发的共享工单)
        if (mps.getWorkOrderId() != null) {
            try {
                workOrderService.cancelWorkOrder(mps.getWorkOrderId());
                log.info("[cancelMps] 级联取消生产工单,workOrderId={}", mps.getWorkOrderId());
            } catch (Exception e) {
                // 工单可能已完成或已取消,记录日志但不阻止 MPS 取消
                log.warn("[cancelMps] 取消生产工单失败,workOrderId={},原因:{}", mps.getWorkOrderId(), e.getMessage());
            // 新增逻辑:若该工单有报工记录,则不允许取消 MPS
            Long feedbackCount = feedbackMapper.selectCount(MesProFeedbackDO::getWorkOrderId, mps.getWorkOrderId());
            if (feedbackCount != null && feedbackCount > 0) {
                throw exception(PRO_MPS_WORK_ORDER_HAS_FEEDBACK);
            }
            // 1. 统计该工单被多少条 MPS 引用:合并下发的多条 MPS 共享同一个工单
            Long refCount = mpsMapper.selectCount(MesProMpsDO::getWorkOrderId, mps.getWorkOrderId());
            if (refCount != null && refCount > 1) {
                // 共享工单:只取消当前 MPS,不动工单
                log.info("[cancelMps] 生产工单({}) 被 {} 条 MPS 共用,跳过级联取消工单",
                        mps.getWorkOrderId(), refCount);
            } else {
                MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(mps.getWorkOrderId());
                if (workOrder != null
                        && MesProWorkOrderStatusEnum.CONFIRMED.getStatus().equals(workOrder.getStatus())) {
                    // 只有"已确认"状态的工单才需要级联取消
                    workOrderService.cancelWorkOrder(mps.getWorkOrderId());
                    log.info("[cancelMps] 级联取消生产工单,workOrderId={}", mps.getWorkOrderId());
                } else {
                    // 工单已是草稿/已完成/已取消,无需级联取消,直接取消 MPS
                    log.info("[cancelMps] 生产工单({}) 状态为 {},无需级联取消",
                            mps.getWorkOrderId(), workOrder != null ? workOrder.getStatus() : "null");
                }
            }
        }
@@ -329,9 +464,7 @@
    @Override
    public PageResult<MesProMpsDO> getMpsPage(MesProMpsPageReqVO pageReqVO) {
        // 获取用户有权限的客户 ID 列表
        List<Long> permittedClientIds = customerApi.getPermittedCustomerIds();
        return mpsMapper.selectPage(pageReqVO, permittedClientIds);
        return mpsMapper.selectPage(pageReqVO, (List<Long>) null);
    }
    @Override
@@ -424,6 +557,10 @@
     * 判断单个 MPS 是否完成
     */
    private boolean isMpsFinished(MesProMpsDO mps) {
        // 已取消的 MPS 视为终态,不再阻塞生产完成判断(与 checkCanCreateSalesNotice 的口径保持一致)
        if (MesProMpsStatusEnum.CANCELED.getStatus().equals(mps.getStatus())) {
            return true;
        }
        // 1. 已生产数量 >= 计划数量
        BigDecimal quantityProduced = ObjUtil.defaultIfNull(mps.getQuantityProduced(), BigDecimal.ZERO);
        if (quantityProduced.compareTo(mps.getQuantity()) < 0) {
@@ -490,6 +627,33 @@
    }
    /**
     * 校验 MPS 存在 + 已审核状态
     */
    private MesProMpsDO validateMpsExistsAndApproved(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        if (!MesProMpsStatusEnum.APPROVED.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_APPROVED);
        }
        return mps;
    }
    /**
     * 校验 MPS 存在 + 可编辑状态(未开始、审核中)
     */
    private MesProMpsDO validateMpsExistsAndEditable(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())
                && !MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_DRAFT);
        }
        return mps;
    }
    private static BigDecimal nullToZero(BigDecimal value) {
        return value != null ? value : BigDecimal.ZERO;
    }
    /**
     * 校验 MPS 编码唯一
     */
    private void validateMpsCodeUnique(Long id, String code) {
@@ -519,8 +683,9 @@
        }
        Long routeId = routeProduct.getRouteId();
        // 2. 校验工艺路线是否有工序
        List<MesProRouteProcessDO> routeProcesses = routeProcessService.getRouteProcessListByRouteId(routeId);
        // 2. 校验工艺路线是否有工序(按产品过滤其它产品的产出物料)
        List<MesProRouteProcessDO> routeProcesses = routeProcessService
                .getRouteProcessListByProductId(mps.getProductId());
        if (CollUtil.isEmpty(routeProcesses)) {
            throw exception(PRO_ROUTE_ENABLE_NO_PROCESS);
        }
@@ -560,6 +725,10 @@
                        .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)) {
@@ -595,4 +764,4 @@
        }
    }
}
}