| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | /** |
| | | * MES 生产主计划(MPS)Service 实现类 |
| | | * |
| | | * @author 芋道源码 |
| | | * @author 超级管理员 |
| | | */ |
| | | @Service |
| | | @Validated |
| | |
| | | private MesProRouteProcessService routeProcessService; |
| | | @Resource |
| | | private MesProWorkOrderProcessService workOrderProcessService; |
| | | @Resource |
| | | private MesProMaterialRequirementService materialRequirementService; |
| | | @Resource |
| | | private ErpPurchaseOrderApi purchaseOrderApi; |
| | | @Resource |
| | | private ErpPurchaseRequestApi purchaseRequestApi; |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | |
| | | 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())) { |
| | |
| | | @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) { |
| | |
| | | |
| | | @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()); |
| | |
| | | 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(); |
| | |
| | | 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"); |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | @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 |
| | |
| | | * 判断单个 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) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 校验 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) { |
| | |
| | | } |
| | | 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); |
| | | } |
| | |
| | | .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)) { |
| | |
| | | } |
| | | } |
| | | |
| | | } |
| | | } |