云
4 天以前 f02d0df2f1dfc7a8b775cf50d45375e57628a5ef
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrServiceImpl.java
@@ -85,12 +85,29 @@
     */
    private static final Set<Integer> DISPOSITION_ALLOWED_TYPES = Set.of(
            MesNcrDispositionEnum.SCRAP.getType(),
            MesNcrDispositionEnum.RETURN.getType(),
            MesNcrDispositionEnum.REPRODUCTION.getType(),
            MesNcrDispositionEnum.REWORK.getType(),
            MesNcrDispositionEnum.CONCESSION.getType(),
            MesNcrDispositionEnum.REPAIR.getType());
    @Resource
    private MesQcNcrMapper ncrMapper;
    @Resource
    private MesQcNcrReinspectionValidator reinspectionValidator;
    @Resource
    private MesQcNcrStockClosureGuard stockClosureGuard;
    @Resource private MesQcNcrScrapService scrapService;
    @Resource private MesQcNcrReturnSalesLink returnSalesLink;
    @Resource private MesQcPurchaseReturnService purchaseReturnService;
    @Resource private MesQcPurchaseConcessionService purchaseConcessionService;
    @Resource private MesQcNcrOqcSourceClaim oqcSourceClaim;
    @Resource private MesQcNcrOqcConcessionService oqcConcessionService;
    @Resource private MesQcNcrOqcReproductionService oqcReproductionService;
    @Resource private MesQcNcrOqcReworkService oqcReworkService;
    @Resource
    private MdmItemApi mdmItemApi;
@@ -170,6 +187,11 @@
    public void updateNcr(MesQcNcrSaveReqVO updateReqVO) {
        // 1. 校验存在 + 只允许待评审状态编辑
        MesQcNcrDO ncr = validateNcrStatusPendingReview(updateReqVO.getId());
        if (ncr.getQcId() != null && (!Objects.equals(ncr.getItemId(), updateReqVO.getItemId())
                || ncr.getUnqualifiedQuantity() == null || updateReqVO.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().compareTo(updateReqVO.getUnqualifiedQuantity()) != 0)) {
            throw new ServiceException(400, "来源质检单生成的不合格物料和数量不能修改");
        }
        validateNcrCodeUnique(updateReqVO.getId(), updateReqVO.getCode());
        validateItemExists(updateReqVO.getItemId());
@@ -185,7 +207,10 @@
    @Transactional(rollbackFor = Exception.class)
    public void deleteNcr(Long id) {
        // 校验存在 + 只允许删除待评审状态
        validateNcrStatusPendingReview(id);
        MesQcNcrDO ncr = validateNcrStatusPendingReview(id);
        if (ncr.getQcId() != null) {
            throw new ServiceException(400, "来源质检单生成的不合格处理单不能删除");
        }
        ncrMapper.deleteById(id);
    }
@@ -194,8 +219,9 @@
    public void reviewNcr(Long id, Integer disposition, Long responsibleDeptId,
                           Integer defectLevel, String reviewOpinion) {
        // 1. 校验存在 + 待评审状态
        validateNcrStatusPendingReview(id);
        MesQcNcrDO ncr = validateNcrStatusPendingReview(id);
        validateDisposition(disposition);
        validateDispositionForSource(ncr, disposition);
        // 2. 更新评审信息
        MesQcNcrDO updateObj = new MesQcNcrDO()
@@ -216,12 +242,13 @@
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO disposeNcr(MesQcNcrDisposeReqVO disposeReqVO) {
        // 1. 校验存在 + 已评审(审批通过)状态
        MesQcNcrDO ncr = validateNcrExists(disposeReqVO.getId());
        MesQcNcrDO ncr = validateNcrExistsForUpdate(disposeReqVO.getId());
        if (!MesNcrStatusEnum.REVIEWED.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_DISPOSE_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        // 2. 处置方式必填,且必须是合法枚举值
        validateDisposition(disposeReqVO.getDisposition());
        validateDispositionForSource(ncr, disposeReqVO.getDisposition());
        // 3. 不合格数量必须大于 0,否则无法生成入库单 / 生产订单
        if (ncr.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().compareTo(BigDecimal.ZERO) <= 0) {
@@ -230,10 +257,71 @@
        // 4. 按处置方式联动生成业务单据
        MesQcNcrDisposeRespVO respVO;
        if (Objects.equals(MesNcrDispositionEnum.CONCESSION.getType(), disposeReqVO.getDisposition())
                || Objects.equals(MesNcrDispositionEnum.SCRAP.getType(), disposeReqVO.getDisposition())) {
            // 让步放行 / 报废:生成杂项入库单并自动完成入库
        MesQcNcrScrapService.IpqcSource processSource = null;
        MesQcNcrOqcSourceClaim.OqcClaimedSource oqcSource = null;
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())) {
            oqcSource = oqcSourceClaim.claim(ncr);
        }
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())) {
            processSource = scrapService.validateIpqcSource(ncr);
            scrapService.claimIpqcSource(ncr, processSource);
            if (StrUtil.isNotBlank(processSource.batchCode()) && StrUtil.isNotBlank(disposeReqVO.getBatchCode())
                    && !Objects.equals(processSource.batchCode(), StrUtil.trim(disposeReqVO.getBatchCode()))) {
                throw new ServiceException(400, "处置批次必须与过程检验原批次一致");
            }
            if (StrUtil.isNotBlank(processSource.batchCode())) disposeReqVO.setBatchCode(processSource.batchCode());
        }
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType())) {
            String originalBatch = returnSalesLink.claim(ncr);
            if (StrUtil.isNotBlank(originalBatch) && StrUtil.isNotBlank(disposeReqVO.getBatchCode())
                    && !Objects.equals(originalBatch, StrUtil.trim(disposeReqVO.getBatchCode()))) {
                throw new ServiceException(400, "处置批次必须与客户退货原批次一致");
            }
            if (StrUtil.isNotBlank(originalBatch)) disposeReqVO.setBatchCode(originalBatch);
        }
        if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.RETURN.getType())) {
            var doc = purchaseReturnService.createForNcr(ncr);
            respVO = new MesQcNcrDisposeRespVO();
            respVO.setDisposition(disposeReqVO.getDisposition());
            respVO.setDocType("PURCHASE_RETURN");
            respVO.setDocId(doc.getId());
            respVO.setDocCode(doc.getCode());
            respVO.setDocName("采购退货单");
            respVO.setMessage("采购退货单已生成,请由仓库确认退货");
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.REPRODUCTION.getType())) {
            respVO = processSource != null
                    ? oqcReproductionService.reproduceIpqc(ncr, disposeReqVO.getDisposition(), processSource)
                    : oqcReproductionService.reproduce(ncr, disposeReqVO.getDisposition());
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                && Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())) {
            respVO = purchaseConcessionService.dispose(ncr, disposeReqVO);
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                && Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())) {
            oqcConcessionService.release(ncr, disposeReqVO);
            respVO = new MesQcNcrDisposeRespVO();
            respVO.setDisposition(disposeReqVO.getDisposition());
            respVO.setDocType("PRODUCT_SALES");
            MesQcOqcDO sourceOqc = oqcService.getOqc(ncr.getQcId());
            respVO.setDocId(sourceOqc.getSourceDocId());
            respVO.setDocCode(sourceOqc.getSourceDocCode());
            respVO.setDocName("销售出库单");
            respVO.setMessage("出货让步已完成原销售出库单拣货及出库");
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.SCRAP.getType())
                && (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                || Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType()))) {
            respVO = scrapService.receive(ncr, disposeReqVO);
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                && (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                || Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType()))) {
            respVO = createMiscReceiptForDispose(ncr, disposeReqVO);
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                || Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.SCRAP.getType())) {
            stockClosureGuard.validateBeforeDispose(disposeReqVO.getDisposition());
            throw new ServiceException(400, "该来源暂不支持此处置方式");
        } else if (oqcSource != null && (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                || Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.REPAIR.getType()))) {
            // 出货返工/返修:扣减原成品库存 → 取消原销售出库单 → 克隆返工工单
            respVO = oqcReworkService.dispose(ncr, oqcSource, disposeReqVO);
        } else {
            // 返工 / 返修:生成自行生产类型的生产订单
            respVO = createReworkWorkOrderForDispose(ncr, disposeReqVO);
@@ -462,7 +550,7 @@
        // 该方法会把所有异常统一包成 RuntimeException,因此这里不能直接 catch ServiceException,
        // 必须沿 cause 链判断是否为「流程定义不存在」。
        // 1. 校验存在 + 待评审(草稿/审批驳回后)状态
        MesQcNcrDO ncr = validateNcrExists(id);
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.PENDING_REVIEW.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_SUBMIT_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
@@ -519,10 +607,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateNcrAuditStatus(Long id, Integer bpmResult) {
    public void updateNcrAuditStatus(Long id, String processInstanceId, Integer bpmResult) {
        // 1. 校验存在 + 审批中(避免并发或重复回调覆盖已处置/已关闭状态)
        MesQcNcrDO ncr = validateNcrExists(id);
        if (!MesNcrStatusEnum.APPROVING.getStatus().equals(ncr.getStatus())) {
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.APPROVING.getStatus().equals(ncr.getStatus())
                || StrUtil.isBlank(processInstanceId)
                || !Objects.equals(processInstanceId, ncr.getProcessInstanceId())) {
            return;
        }
@@ -545,11 +635,38 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void closeNcr(Long id, String closeOpinion) {
    public void closeNcr(Long id, String closeOpinion, Long reinspectionIpqcId) {
        // 1. 校验存在 + 已处置状态
        MesQcNcrDO ncr = validateNcrExists(id);
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.DISPOSED.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_CLOSE_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.RETURN.getType())
                && Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())) {
            purchaseReturnService.validateConfirmed(ncr.getId());
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())
                && Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())) {
            oqcConcessionService.validateBeforeClose(ncr);
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())
                && Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())) {
            // 采购让步的入库和 ERP 回写与处置在同一事务完成。
        } else if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REPRODUCTION.getType())) {
            throw new ServiceException(400, "重新生产后需完成复检并处理原出库单,暂不能关闭");
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())
                && (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                || Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REPAIR.getType()))) {
            oqcReworkService.validateBeforeClose(ncr);
        } else if (!Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType())) {
            stockClosureGuard.validateBeforeClose(ncr);
        }
        if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                || Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REPAIR.getType())) {
            reinspectionValidator.validate(ncr, reinspectionIpqcId);
        } else {
            reinspectionIpqcId = null;
        }
        // 2. 更新关闭信息
@@ -558,6 +675,7 @@
                .setCloseUserId(getLoginUserId())
                .setCloseDate(LocalDateTime.now())
                .setCloseOpinion(closeOpinion)
                .setReinspectionIpqcId(reinspectionIpqcId)
                .setStatus(MesNcrStatusEnum.CLOSED.getStatus()));
    }
@@ -572,11 +690,19 @@
        return ncr;
    }
    private MesQcNcrDO validateNcrExistsForUpdate(Long id) {
        MesQcNcrDO ncr = ncrMapper.selectByIdForUpdate(id);
        if (ncr == null) {
            throw exception(QC_NCR_NOT_EXISTS, id);
        }
        return ncr;
    }
    /**
     * 校验 NCR 存在且为待评审状态
     */
    private MesQcNcrDO validateNcrStatusPendingReview(Long id) {
        MesQcNcrDO ncr = validateNcrExists(id);
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.PENDING_REVIEW.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_STATUS_NOT_PENDING, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
@@ -595,6 +721,22 @@
        }
    }
    private void validateDispositionForSource(MesQcNcrDO ncr, Integer disposition) {
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())) {
            if (!Objects.equals(disposition, MesNcrDispositionEnum.RETURN.getType())
                    && !Objects.equals(disposition, MesNcrDispositionEnum.CONCESSION.getType())) {
                throw new ServiceException(400, "采购检验不合格仅支持退货或让步放行");
            }
        } else if (Objects.equals(disposition, MesNcrDispositionEnum.RETURN.getType())) {
            throw new ServiceException(400, "只有采购检验不合格支持采购退货");
        }
        if (Objects.equals(disposition, MesNcrDispositionEnum.REPRODUCTION.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())) {
            throw new ServiceException(400, "重新生产仅支持过程或出货检验不合格");
        }
    }
    /**
     * 状态值转名称,解析失败回退为 ID 字符串
     */