云
6 天以前 cccbfadcb6e96c23a33c40c0687a27c2f6efa1db
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrServiceImpl.java
@@ -1,17 +1,49 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.MesProWorkOrderSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.miscreceipt.vo.MesWmMiscReceiptSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.miscreceipt.vo.line.MesWmMiscReceiptLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcNcrMapper;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderSourceTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmMiscReceiptTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import cn.iocoder.yudao.module.mes.service.qc.rqc.MesQcRqcService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.miscreceipt.MesWmMiscReceiptLineService;
import cn.iocoder.yudao.module.mes.service.wm.miscreceipt.MesWmMiscReceiptService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
@@ -19,10 +51,12 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
@@ -38,11 +72,31 @@
@Validated
public class MesQcNcrServiceImpl implements MesQcNcrService {
    /**
     * NCR 协同审批流程分类/流程定义 key
     */
    private static final String NCR_APPROVAL_PROCESS_KEY = "mes_ncr_approve";
    /**
     * 最终处置允许值:让步放行、返修、返工、报废
     * <p>
     * 退货(1)、降级使用(5) 仍保留在 {@link MesNcrDispositionEnum} 与字典中,用于历史数据显示,
     * 但不再作为质量部最终处置的可选项。
     */
    private static final Set<Integer> DISPOSITION_ALLOWED_TYPES = Set.of(
            MesNcrDispositionEnum.SCRAP.getType(),
            MesNcrDispositionEnum.REWORK.getType(),
            MesNcrDispositionEnum.CONCESSION.getType(),
            MesNcrDispositionEnum.REPAIR.getType());
    @Resource
    private MesQcNcrMapper ncrMapper;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Resource
    @Lazy
@@ -56,6 +110,42 @@
    @Resource
    @Lazy
    private MesQcRqcService rqcService;
    @Resource
    @Lazy
    private MesWmMiscReceiptService miscReceiptService;
    @Resource
    @Lazy
    private MesWmMiscReceiptLineService miscReceiptLineService;
    @Resource
    @Lazy
    private MesWmWarehouseService warehouseService;
    @Resource
    @Lazy
    private MesWmWarehouseLocationService warehouseLocationService;
    @Resource
    @Lazy
    private MesWmWarehouseAreaService warehouseAreaService;
    @Resource
    @Lazy
    private MesWmBatchService batchService;
    @Resource
    @Lazy
    private MesProWorkOrderService workOrderService;
    @Resource
    @Lazy
    private MesMdmItemService mdmItemService;
    @Resource
    @Lazy
    private MesMdItemService mesItemService;
    @Resource
    @Lazy
    private MesMdAutoCodeRecordService autoCodeRecordService;
    /**
     * 杂项入库单来源单据类型:不合格品处理单
     */
    private static final String MISC_RECEIPT_SOURCE_TYPE_NCR = "QC_NCR";
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -105,6 +195,7 @@
                           Integer defectLevel, String reviewOpinion) {
        // 1. 校验存在 + 待评审状态
        validateNcrStatusPendingReview(id);
        validateDisposition(disposition);
        // 2. 更新评审信息
        MesQcNcrDO updateObj = new MesQcNcrDO()
@@ -123,21 +214,333 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void disposeNcr(Long id, Long handlerUserId, String handleResult) {
        // 1. 校验存在 + 已评审状态
        MesQcNcrDO ncr = validateNcrExists(id);
    public MesQcNcrDisposeRespVO disposeNcr(MesQcNcrDisposeReqVO disposeReqVO) {
        // 1. 校验存在 + 已评审(审批通过)状态
        MesQcNcrDO ncr = validateNcrExists(disposeReqVO.getId());
        if (!MesNcrStatusEnum.REVIEWED.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_NOT_REVIEWED);
            throw exception(QC_NCR_DISPOSE_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        // 2. 处置方式必填,且必须是合法枚举值
        validateDisposition(disposeReqVO.getDisposition());
        // 3. 不合格数量必须大于 0,否则无法生成入库单 / 生产订单
        if (ncr.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().compareTo(BigDecimal.ZERO) <= 0) {
            throw exception(QC_NCR_DISPOSE_QUANTITY_INVALID, ncr.getUnqualifiedQuantity());
        }
        // 2. 更新处置信息
        // TODO: WM 模块集成 — 处置为退货时触发供应商退货,处置为报废时触发杂项出库
        // 4. 按处置方式联动生成业务单据
        MesQcNcrDisposeRespVO respVO;
        if (Objects.equals(MesNcrDispositionEnum.CONCESSION.getType(), disposeReqVO.getDisposition())
                || Objects.equals(MesNcrDispositionEnum.SCRAP.getType(), disposeReqVO.getDisposition())) {
            // 让步放行 / 报废:生成杂项入库单并自动完成入库
            respVO = createMiscReceiptForDispose(ncr, disposeReqVO);
        } else {
            // 返工 / 返修:生成自行生产类型的生产订单
            respVO = createReworkWorkOrderForDispose(ncr, disposeReqVO);
        }
        // 5. 更新处置信息(处置方式以本次处置选择为准并落库)
        ncrMapper.updateById(new MesQcNcrDO()
                .setId(ncr.getId())
                .setDisposition(disposeReqVO.getDisposition())
                .setHandlerUserId(disposeReqVO.getHandlerUserId())
                .setHandleDate(LocalDateTime.now())
                .setHandleResult(disposeReqVO.getHandleResult())
                .setStatus(MesNcrStatusEnum.DISPOSED.getStatus()));
        return respVO;
    }
    /**
     * 让步放行 / 报废:生成杂项入库单,自动提交并完成入库
     */
    private MesQcNcrDisposeRespVO createMiscReceiptForDispose(MesQcNcrDO ncr, MesQcNcrDisposeReqVO reqVO) {
        boolean scrap = Objects.equals(MesNcrDispositionEnum.SCRAP.getType(), reqVO.getDisposition());
        String dispositionName = scrap ? MesNcrDispositionEnum.SCRAP.getName() : MesNcrDispositionEnum.CONCESSION.getName();
        MesWmMiscReceiptTypeEnum receiptType = scrap
                ? MesWmMiscReceiptTypeEnum.SCRAP : MesWmMiscReceiptTypeEnum.CONCESSION;
        // 4.1 校验入库仓库 / 库区 / 库位
        MesMdmItemDO item = mdmItemService.validateItemExists(ncr.getItemId());
        if (reqVO.getWarehouseId() == null || reqVO.getLocationId() == null || reqVO.getAreaId() == null) {
            throw new ServiceException(QC_NCR_DISPOSE_STORAGE_REQUIRED.getCode(),
                    String.format("处置方式[%s]需要入库到指定库位:当前仓库[%s]、库区[%s]、库位[%s]未选全,"
                                    + "请回到处置弹窗补选缺失项后重新提交",
                            dispositionName, resolveWarehouseName(reqVO.getWarehouseId()),
                            resolveLocationName(reqVO.getLocationId()), resolveAreaName(reqVO.getAreaId())));
        }
        try {
            warehouseAreaService.validateWarehouseAreaExists(
                    reqVO.getWarehouseId(), reqVO.getLocationId(), reqVO.getAreaId());
        } catch (ServiceException ex) {
            throw new ServiceException(QC_NCR_DISPOSE_STORAGE_DISABLED.getCode(),
                    String.format("处置方式[%s]所选入库库位校验失败:仓库[%s]、库区[%s]、库位[%s]。原因:%s;"
                                    + "请回到处置弹窗重新选择仓库/库区/库位",
                            dispositionName, resolveWarehouseName(reqVO.getWarehouseId()),
                            resolveLocationName(reqVO.getLocationId()), resolveAreaName(reqVO.getAreaId()),
                            ex.getMessage()));
        }
        // 4.2 批次管理的物料:批次号必填,不存在时自动创建批次
        String batchCode = StrUtil.trimToNull(reqVO.getBatchCode());
        if (Boolean.TRUE.equals(item.getIsBatchManaged())) {
            if (batchCode == null) {
                throw new ServiceException(QC_NCR_DISPOSE_BATCH_REQUIRED.getCode(),
                        String.format("物料[%s]启用了批次管理,处置方式[%s]入库时必须填写批次号;"
                                + "请在处置弹窗的[批次号]中填写后重试", buildItemLabel(item), dispositionName));
            }
            MesWmBatchGenerateReqVO batchReqVO = new MesWmBatchGenerateReqVO();
            batchReqVO.setItemId(ncr.getItemId());
            batchReqVO.setBatchCode(batchCode);
            batchReqVO.setReceiptDate(LocalDateTime.now());
            try {
                batchService.getOrGenerateBatchCode(batchReqVO);
            } catch (ServiceException ex) {
                throw new ServiceException(ex.getCode(),
                        String.format("为物料[%s]准备批次[%s]失败:%s;请补充批次信息后重新处置",
                                buildItemLabel(item), batchCode, ex.getMessage()));
            }
        }
        // 4.3 创建杂项入库单
        String receiptCode = autoCodeRecordService.generateAutoCode(
                MesMdAutoCodeRuleCodeEnum.WM_MISC_RECEIPT_CODE.getCode());
        String receiptName = String.format("%s入库单-%s", dispositionName, ncr.getCode());
        MesWmMiscReceiptSaveReqVO receiptReqVO = new MesWmMiscReceiptSaveReqVO();
        receiptReqVO.setCode(receiptCode);
        receiptReqVO.setName(receiptName);
        receiptReqVO.setType(receiptType.getType());
        receiptReqVO.setSourceDocType(MISC_RECEIPT_SOURCE_TYPE_NCR);
        receiptReqVO.setSourceDocId(ncr.getId());
        receiptReqVO.setSourceDocCode(ncr.getCode());
        receiptReqVO.setReceiptDate(LocalDateTime.now());
        receiptReqVO.setRemark(String.format("由不合格品处理单[%s]处置为[%s]自动生成并完成入库",
                ncr.getCode(), dispositionName));
        Long receiptId = miscReceiptService.createMiscReceipt(receiptReqVO);
        // 4.4 创建入库行(同物料 / 不合格数量 / 目标库位 / 批次号)
        MesWmMiscReceiptLineSaveReqVO lineReqVO = new MesWmMiscReceiptLineSaveReqVO();
        lineReqVO.setReceiptId(receiptId);
        lineReqVO.setItemId(ncr.getItemId());
        lineReqVO.setQuantity(ncr.getUnqualifiedQuantity());
        lineReqVO.setBatchCode(batchCode);
        lineReqVO.setWarehouseId(reqVO.getWarehouseId());
        lineReqVO.setLocationId(reqVO.getLocationId());
        lineReqVO.setAreaId(reqVO.getAreaId());
        lineReqVO.setRemark(String.format("来源不合格品处理单[%s]", ncr.getCode()));
        miscReceiptLineService.createMiscReceiptLine(lineReqVO);
        // 4.5 自动提交并完成入库(增加库存 + 记录流水)
        miscReceiptService.submitMiscReceipt(receiptId);
        miscReceiptService.finishMiscReceipt(receiptId);
        MesQcNcrDisposeRespVO respVO = new MesQcNcrDisposeRespVO();
        respVO.setDisposition(reqVO.getDisposition());
        respVO.setDocType("MISC_RECEIPT");
        respVO.setDocId(receiptId);
        respVO.setDocCode(receiptCode);
        respVO.setDocName(receiptName);
        respVO.setMessage(String.format("已生成%s并完成入库,入库单号[%s]", receiptName, receiptCode));
        return respVO;
    }
    /**
     * 返工 / 返修:生成自行生产类型的生产订单(草稿态,BOM/条码自动生成,工艺路线人工补充)
     */
    private MesQcNcrDisposeRespVO createReworkWorkOrderForDispose(MesQcNcrDO ncr, MesQcNcrDisposeReqVO reqVO) {
        boolean rework = Objects.equals(MesNcrDispositionEnum.REWORK.getType(), reqVO.getDisposition());
        String dispositionName = rework ? MesNcrDispositionEnum.REWORK.getName() : MesNcrDispositionEnum.REPAIR.getName();
        // 生产订单的产品是 MES 产品主数据(mes_md_item),NCR 存的是 MDM 物料(mdm_item),需先映射
        MesMdItemDO product = mesItemService.getItemByMdmItemId(ncr.getItemId());
        if (product == null) {
            // 兼容历史数据:MES 产品与 MDM 物料主键一致时直接按主键取
            product = mesItemService.getItem(ncr.getItemId());
        }
        if (product == null) {
            MesMdmItemDO mdmItem = mdmItemService.getItem(ncr.getItemId());
            throw new ServiceException(QC_NCR_DISPOSE_PRODUCT_NOT_EXISTS.getCode(),
                    String.format("物料[%s]在 MES 产品主数据中不存在(MDM 物料 ID=%s),无法生成返工/返修生产订单;"
                                    + "请先在[物料产品]中维护该产品后再处置",
                            mdmItem != null ? buildItemLabel(mdmItem) : "未知物料", ncr.getItemId()));
        }
        String workOrderCode = autoCodeRecordService.generateAutoCode(
                MesMdAutoCodeRuleCodeEnum.PRO_WORK_ORDER_CODE.getCode());
        String workOrderName = String.format("%s单-%s", dispositionName, ncr.getCode());
        MesProWorkOrderSaveReqVO workOrderReqVO = new MesProWorkOrderSaveReqVO();
        workOrderReqVO.setCode(workOrderCode);
        workOrderReqVO.setName(workOrderName);
        workOrderReqVO.setType(MesProWorkOrderTypeEnum.SELF.getType());
        workOrderReqVO.setOrderSourceType(MesProWorkOrderSourceTypeEnum.STORE.getType());
        workOrderReqVO.setOrderSourceCode(ncr.getCode());
        workOrderReqVO.setProductId(product.getId());
        workOrderReqVO.setQuantity(ncr.getUnqualifiedQuantity());
        workOrderReqVO.setRequestDate(LocalDateTime.now());
        workOrderReqVO.setRemark(String.format("由不合格品处理单[%s]处置为[%s]自动生成,请补充工艺路线后下达",
                ncr.getCode(), dispositionName));
        // 出货检验来源的 NCR:带上客户,满足产品批次配置(clientFlag)对客户必填的要求
        Long clientId = resolveSourceClientId(ncr);
        if (clientId != null) {
            workOrderReqVO.setClientId(clientId);
        }
        Long workOrderId = workOrderService.createWorkOrder(workOrderReqVO);
        MesQcNcrDisposeRespVO respVO = new MesQcNcrDisposeRespVO();
        respVO.setDisposition(reqVO.getDisposition());
        respVO.setDocType("WORK_ORDER");
        respVO.setDocId(workOrderId);
        respVO.setDocCode(workOrderCode);
        respVO.setDocName(workOrderName);
        respVO.setMessage(String.format("已生成%s[%s],请到生产订单中补充工艺路线后下达", workOrderName, workOrderCode));
        return respVO;
    }
    /**
     * 取来源检验单上的客户:仅出货检验(OQC)带有客户信息
     */
    private Long resolveSourceClientId(MesQcNcrDO ncr) {
        if (!Objects.equals(MesQcTypeEnum.OQC.getType(), ncr.getQcType()) || ncr.getQcId() == null) {
            return null;
        }
        MesQcOqcDO oqc = oqcService.getOqc(ncr.getQcId());
        return oqc != null ? oqc.getClientId() : null;
    }
    /**
     * 物料展示:编码 + 名称
     */
    private String buildItemLabel(MesMdmItemDO item) {
        if (item == null) {
            return "未知物料";
        }
        String code = StrUtil.blankToDefault(item.getCode(), "");
        String name = StrUtil.blankToDefault(item.getName(), "");
        String label = (code + " " + name).trim();
        return label.isEmpty() ? "ID=" + item.getId() : label;
    }
    /**
     * 仓库名称,解析失败回退为可读 ID
     */
    private String resolveWarehouseName(Long warehouseId) {
        if (warehouseId == null) {
            return "未选择";
        }
        MesWmWarehouseDO warehouse = warehouseService.getWarehouse(warehouseId);
        return warehouse != null && StrUtil.isNotBlank(warehouse.getName())
                ? warehouse.getName() : "ID=" + warehouseId;
    }
    /**
     * 库区名称,解析失败回退为可读 ID
     */
    private String resolveLocationName(Long locationId) {
        if (locationId == null) {
            return "未选择";
        }
        MesWmWarehouseLocationDO location = warehouseLocationService.getWarehouseLocation(locationId);
        return location != null && StrUtil.isNotBlank(location.getName())
                ? location.getName() : "ID=" + locationId;
    }
    /**
     * 库位名称,解析失败回退为可读 ID
     */
    private String resolveAreaName(Long areaId) {
        if (areaId == null) {
            return "未选择";
        }
        MesWmWarehouseAreaDO area = warehouseAreaService.getWarehouseArea(areaId);
        return area != null && StrUtil.isNotBlank(area.getName())
                ? area.getName() : "ID=" + areaId;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitNcrApproval(Long id) {
        // 说明:BPM 的 createProcessInstance 内部通过 FlowableUtils.executeAuthenticatedUserId 执行,
        // 该方法会把所有异常统一包成 RuntimeException,因此这里不能直接 catch ServiceException,
        // 必须沿 cause 链判断是否为「流程定义不存在」。
        // 1. 校验存在 + 待评审(草稿/审批驳回后)状态
        MesQcNcrDO ncr = validateNcrExists(id);
        if (!MesNcrStatusEnum.PENDING_REVIEW.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_SUBMIT_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        // 2. 全局免审批:业务校验通过后直接视为审批通过(已评审/待处置),无需流程模型
        if (processInstanceApi.isGlobalAutoApprove()) {
            ncrMapper.updateById(new MesQcNcrDO()
                    .setId(id)
                    .setStatus(MesNcrStatusEnum.REVIEWED.getStatus())
                    .setProcessInstanceId(""));
            return;
        }
        // 3. 创建 BPM 流程实例;取不到流程定义时按约定走自动通过兜底
        String processInstanceId;
        try {
            processInstanceId = processInstanceApi.createProcessInstance(getLoginUserId(),
                    new BpmProcessInstanceCreateReqDTO()
                            .setProcessDefinitionKey(NCR_APPROVAL_PROCESS_KEY)
                            .setBusinessKey(String.valueOf(id)));
        } catch (RuntimeException ex) {
            if (isProcessDefinitionNotExists(ex)) {
                ncrMapper.updateById(new MesQcNcrDO()
                        .setId(id)
                        .setStatus(MesNcrStatusEnum.REVIEWED.getStatus())
                        .setProcessInstanceId(""));
                return;
            }
            throw ex;
        }
        // 4. 更新 NCR 状态为审批中,并回写流程实例编号
        ncrMapper.updateById(new MesQcNcrDO()
                .setId(id)
                .setHandlerUserId(handlerUserId)
                .setHandleDate(LocalDateTime.now())
                .setHandleResult(handleResult)
                .setStatus(MesNcrStatusEnum.DISPOSED.getStatus()));
                .setStatus(MesNcrStatusEnum.APPROVING.getStatus())
                .setProcessInstanceId(processInstanceId));
    }
    /**
     * 判断异常链中是否包含「流程定义不存在」(BPM 对外会包一层 RuntimeException)
     */
    private static boolean isProcessDefinitionNotExists(Throwable ex) {
        Throwable current = ex;
        while (current != null) {
            if (current instanceof ServiceException serviceException
                    && cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_DEFINITION_NOT_EXISTS
                            .getCode().equals(serviceException.getCode())) {
                return true;
            }
            current = current.getCause();
        }
        return false;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateNcrAuditStatus(Long id, Integer bpmResult) {
        // 1. 校验存在 + 审批中(避免并发或重复回调覆盖已处置/已关闭状态)
        MesQcNcrDO ncr = validateNcrExists(id);
        if (!MesNcrStatusEnum.APPROVING.getStatus().equals(ncr.getStatus())) {
            return;
        }
        // 2. 审批通过 -> 已评审(待处置);驳回/取消 -> 待评审(可重新修改并提交)
        if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            ncrMapper.updateById(new MesQcNcrDO()
                    .setId(id)
                    .setStatus(MesNcrStatusEnum.REVIEWED.getStatus()));
            return;
        }
        if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(bpmResult)
                || BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            // 保留 processInstanceId:驳回/取消后仍可在「查看审批」中查看审批意见,
            // 重新提交时会用新流程实例编号覆盖。
            ncrMapper.updateById(new MesQcNcrDO()
                    .setId(id)
                    .setStatus(MesNcrStatusEnum.PENDING_REVIEW.getStatus()));
        }
    }
    @Override
@@ -146,7 +549,7 @@
        // 1. 校验存在 + 已处置状态
        MesQcNcrDO ncr = validateNcrExists(id);
        if (!MesNcrStatusEnum.DISPOSED.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_NOT_DISPOSED);
            throw exception(QC_NCR_CLOSE_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        // 2. 更新关闭信息
@@ -164,7 +567,7 @@
    public MesQcNcrDO validateNcrExists(Long id) {
        MesQcNcrDO ncr = ncrMapper.selectById(id);
        if (ncr == null) {
            throw exception(QC_NCR_NOT_EXISTS);
            throw exception(QC_NCR_NOT_EXISTS, id);
        }
        return ncr;
    }
@@ -175,9 +578,33 @@
    private MesQcNcrDO validateNcrStatusPendingReview(Long id) {
        MesQcNcrDO ncr = validateNcrExists(id);
        if (!MesNcrStatusEnum.PENDING_REVIEW.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_NOT_PENDING_REVIEW);
            throw exception(QC_NCR_STATUS_NOT_PENDING, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        return ncr;
    }
    /**
     * 校验处置方式:必填 + 必须是合法枚举值
     */
    private void validateDisposition(Integer disposition) {
        if (disposition == null) {
            throw exception(QC_NCR_DISPOSITION_REQUIRED);
        }
        if (!DISPOSITION_ALLOWED_TYPES.contains(disposition)) {
            throw exception(QC_NCR_DISPOSITION_INVALID, disposition);
        }
    }
    /**
     * 状态值转名称,解析失败回退为 ID 字符串
     */
    private String resolveStatusName(Integer status) {
        for (MesNcrStatusEnum value : MesNcrStatusEnum.values()) {
            if (value.getStatus().equals(status)) {
                return value.getName();
            }
        }
        return "ID=" + status;
    }
    /**
@@ -186,7 +613,7 @@
    private void validateNcrCodeUnique(Long id, String code) {
        MesQcNcrDO existing = ncrMapper.selectByCode(code);
        if (existing != null && !existing.getId().equals(id)) {
            throw exception(QC_NCR_CODE_DUPLICATE);
            throw exception(QC_NCR_CODE_DUPLICATE, code);
        }
    }