云
昨天 ac2e2b5af3726ee64dc37c3ebf26989a0346f22a
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java
@@ -8,6 +8,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
@@ -17,10 +18,12 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateItemDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.feedback.MesProFeedbackMapper;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcDefectLevelEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackStatusEnum;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService;
@@ -34,7 +37,6 @@
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrSaveReqVO;
import cn.iocoder.yudao.module.mes.service.qc.ncr.MesQcNcrService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceLineService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -49,6 +51,7 @@
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcImportRespVO;
@@ -87,6 +90,11 @@
    private MesQcIpqcMapper ipqcMapper;
    @Resource
    private MesWmProductProduceLineMapper produceLineMapper;
    @Resource
    private MesProFeedbackMapper feedbackMapper;
    @Resource
    private MesQcTemplateItemService templateItemService;
    @Resource
    private MesQcIpqcLineService ipqcLineService;
@@ -119,10 +127,6 @@
    private MesQcIndicatorResultService indicatorResultService;
    @Resource
    @Lazy
    private MesWmProductProduceLineService productProduceLineService;
    @Resource
    @Lazy
    private MesQcNcrService ncrService;
    @Resource
@@ -135,23 +139,69 @@
        SaveDataContext ctx = validateIpqcSaveData(createReqVO);
        // 1.2 校验工单存在
        MesProWorkOrderDO workOrder = workOrderService.validateWorkOrderExists(createReqVO.getWorkOrderId());
        // 1.3 确定检验物料:有任务时取任务的物料,无任务时取工单的产品
        Long itemId = (ctx.task() != null) ? ctx.task().getItemId() : workOrder.getProductId();
        // 1.4 根据物料 + 检验类型自动匹配模板
        MesQcTemplateItemDO templateItem = templateItemService.getRequiredTemplateByItemIdAndType(
                itemId, MesQcTypeEnum.IPQC.getType());
        Long templateId = templateItem.getTemplateId();
        // 1.5 获取来源单据编号
        String sourceDocCode = validateAndGetSourceDocCode(
        // 1.3 获取来源单据编号,并确定本次检验的物料与工序
        //     报工来源:一个报工可能产出多个物料,每个产出行各自检验,物料取产出行、工序取报工工序
        //     其它来源:有任务时取任务的物料,无任务时取工单的产品
        SourceDocInfo sourceDoc = validateAndGetSourceDoc(
                createReqVO.getSourceDocType(), createReqVO.getSourceDocId(), createReqVO.getSourceLineId());
        Long itemId;
        Long processId;
        if (Objects.equals(createReqVO.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)) {
            MesProFeedbackDO feedback = feedbackService.validateFeedbackExists(createReqVO.getSourceDocId());
            if (!Objects.equals(feedback.getWorkOrderId(), createReqVO.getWorkOrderId())
                    || !Objects.equals(feedback.getTaskId(), createReqVO.getTaskId())) {
                throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
            }
            if (!ipqcMapper.selectBySourceLineForUpdate(MesBizTypeConstants.PRO_FEEDBACK,
                    createReqVO.getSourceLineId()).isEmpty()) {
                throw exception(QC_IPQC_SOURCE_ALREADY_LINKED);
            }
            if (sourceDoc.line() == null || sourceDoc.line().getItemId() == null) {
                throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
            }
            itemId = sourceDoc.line().getItemId();
            processId = feedback.getProcessId();
        } else {
            itemId = (ctx.task() != null) ? ctx.task().getItemId() : workOrder.getProductId();
            processId = getProcessId(itemId, ctx.workstation().getProcessId());
        }
        // 1.4 根据物料 + 检验类型自动匹配模板(缺失时给出物料维度的明确提示)
        MesQcTemplateItemDO templateItem;
        try {
            templateItem = templateItemService.getRequiredTemplateByItemIdAndType(
                    itemId, MesQcTypeEnum.IPQC.getType());
        } catch (ServiceException e) {
            if (!Objects.equals(e.getCode(), QC_NO_TEMPLATE.getCode())) {
                throw e;
            }
            throw exception(QC_IPQC_NO_TEMPLATE_FOR_ITEM, getItemInfo(itemId));
        }
        Long templateId = templateItem.getTemplateId();
        String sourceDocCode = sourceDoc.code();
        // 2. 插入主表
        MesQcIpqcDO ipqc = BeanUtils.toBean(createReqVO, MesQcIpqcDO.class)
                .setItemId(itemId)
                .setProcessId(getProcessId(itemId, ctx.workstation().getProcessId()))
                .setProcessId(processId)
                .setTemplateId(templateId)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
        // 工废/料废/其他废品、检测人员、检测日期未填写时使用默认值,不影响流程
        if (ipqc.getLaborScrapQuantity() == null) {
            ipqc.setLaborScrapQuantity(BigDecimal.ZERO);
        }
        if (ipqc.getMaterialScrapQuantity() == null) {
            ipqc.setMaterialScrapQuantity(BigDecimal.ZERO);
        }
        if (ipqc.getOtherScrapQuantity() == null) {
            ipqc.setOtherScrapQuantity(BigDecimal.ZERO);
        }
        if (ipqc.getInspectorUserId() == null) {
            ipqc.setInspectorUserId(getLoginUserId());
        }
        if (ipqc.getInspectDate() == null) {
            ipqc.setInspectDate(LocalDateTime.now());
        }
        ipqcMapper.insert(ipqc);
        // 3. 从模板指标自动生成检验行
@@ -160,20 +210,32 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateIpqc(MesQcIpqcSaveReqVO updateReqVO) {
        // 1.1 校验存在 + 草稿状态
        validateIpqcStatusPrepare(updateReqVO.getId());
        validateIpqcStatusPrepareForUpdate(updateReqVO.getId());
        // 1.2 校验数据
        SaveDataContext ctx = validateIpqcSaveData(updateReqVO);
        // 2. 更新主表
        MesQcIpqcDO existIpqc = validateIpqcExists(updateReqVO.getId());
        if (Objects.equals(existIpqc.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                && (!Objects.equals(existIpqc.getTaskId(), updateReqVO.getTaskId())
                    || !Objects.equals(existIpqc.getWorkOrderId(), updateReqVO.getWorkOrderId())
                    || !Objects.equals(existIpqc.getWorkstationId(), updateReqVO.getWorkstationId()))) {
            throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
        }
        MesQcIpqcDO updateObj = BeanUtils.toBean(updateReqVO, MesQcIpqcDO.class);
        updateObj.setSourceDocType(null).setSourceDocId(null).setSourceLineId(null); // 不允许修改来源单据
        updateObj.setTemplateId(null); // 不允许修改模板
        updateObj.setItemId(null); // 不允许修改物料
        updateObj.setWorkOrderId(null); // 不允许修改工单
        updateObj.setProcessId(getProcessId(existIpqc.getItemId(), ctx.workstation().getProcessId()));
        // 报工来源的工序已在创建时按报工工序确定,更新时保持不变,避免被路线推导成空值
        if (Objects.equals(existIpqc.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)) {
            updateObj.setProcessId(existIpqc.getProcessId());
        } else {
            updateObj.setProcessId(getProcessId(existIpqc.getItemId(), ctx.workstation().getProcessId()));
        }
        ipqcMapper.updateById(updateObj);
    }
@@ -182,7 +244,10 @@
        validateIpqcCodeUnique(reqVO.getId(), reqVO.getCode());
        // 校验工位、检测人员存在
        MesMdWorkstationDO workstation = workstationService.validateWorkstationExists(reqVO.getWorkstationId());
        adminUserApi.validateUser(reqVO.getInspectorUserId());
        // 校验检测人员存在(非必填,未填写时由创建逻辑默认当前登录人)
        if (reqVO.getInspectorUserId() != null) {
            adminUserApi.validateUser(reqVO.getInspectorUserId());
        }
        // 校验工单存在
        MesProWorkOrderDO workOrder = workOrderService.validateWorkOrderConfirmed(reqVO.getWorkOrderId());
        if (reqVO.getProcessId() != null && ObjUtil.notEqual(workstation.getProcessId(), reqVO.getProcessId())) {
@@ -194,8 +259,9 @@
            task = taskService.validateTaskNotFinished(reqVO.getTaskId());
            validateTaskRelation(task, workstation, workOrder, reqVO);
        } else {
            // 无任务时,itemId 仍需与工单产品一致
            if (reqVO.getItemId() != null && ObjUtil.notEqual(reqVO.getItemId(), workOrder.getProductId())) {
            // 无任务时,itemId 仍需与工单产品一致(报工来源的联产出物料以产出明细行为准,不在此校验)
            if (!Objects.equals(reqVO.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                    && reqVO.getItemId() != null && ObjUtil.notEqual(reqVO.getItemId(), workOrder.getProductId())) {
                throw exception(PRO_WORK_ORDER_PRODUCT_MISMATCH);
            }
        }
@@ -215,7 +281,9 @@
            throw exception(PRO_TASK_ROUTE_PROCESS_MISMATCH);
        }
        // 校验检验物料与任务物料一致(有任务时,IPQC 检验的物料应与任务加工的物料一致)
        if (reqVO.getItemId() != null && ObjUtil.notEqual(reqVO.getItemId(), task.getItemId())) {
        // 报工来源允许检验联产出物料,物料以产出明细行为准,此处不参与校验
        if (!Objects.equals(reqVO.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                && reqVO.getItemId() != null && ObjUtil.notEqual(reqVO.getItemId(), task.getItemId())) {
            throw exception(PRO_TASK_ITEM_MISMATCH);
        }
    }
@@ -224,13 +292,36 @@
    @Transactional(rollbackFor = Exception.class)
    public void finishIpqc(Long id) {
        // 1.1 校验存在 + 草稿状态
        MesQcIpqcDO ipqc = validateIpqcStatusPrepare(id);
        MesQcIpqcDO ipqc = validateIpqcStatusPrepareForUpdate(id);
        // 1.2 校验检测结论必填
        if (ipqc.getCheckResult() == null) {
            throw exception(QC_IPQC_CHECK_RESULT_EMPTY);
        }
        // 1.3 校验至少存在一条检测结果
        indicatorResultService.validateIndicatorResultExistsByQcIdAndType(id, MesQcTypeEnum.IPQC.getType());
        BigDecimal check = ipqc.getCheckQuantity();
        BigDecimal qualified = ipqc.getQualifiedQuantity();
        BigDecimal unqualified = ipqc.getUnqualifiedQuantity();
        if (check == null || qualified == null || unqualified == null || check.signum() <= 0
                || qualified.signum() < 0 || unqualified.signum() < 0
                || check.compareTo(qualified.add(unqualified)) != 0) {
            throw exception(QC_IPQC_QUANTITY_INVALID);
        }
        BigDecimal labor = ObjectUtil.defaultIfNull(ipqc.getLaborScrapQuantity(), BigDecimal.ZERO);
        BigDecimal material = ObjectUtil.defaultIfNull(ipqc.getMaterialScrapQuantity(), BigDecimal.ZERO);
        BigDecimal other = ObjectUtil.defaultIfNull(ipqc.getOtherScrapQuantity(), BigDecimal.ZERO);
        if (labor.signum() < 0 || material.signum() < 0 || other.signum() < 0
                || labor.add(material).add(other).compareTo(unqualified) > 0) {
            throw exception(QC_IPQC_SCRAP_QUANTITY_INVALID);
        }
        if (Objects.equals(ipqc.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)) {
            if (ipqc.getSourceDocId() == null || ipqc.getSourceLineId() == null) throw exception(QC_IPQC_SOURCE_INCOMPLETE);
            MesWmProductProduceLineDO line = produceLineMapper.selectByIdForUpdate(ipqc.getSourceLineId());
            if (line == null || !Objects.equals(line.getFeedbackId(), ipqc.getSourceDocId())) throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
            if (!Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus())) throw exception(QC_IPQC_SOURCE_LINE_NOT_PENDING);
            if (line.getQuantity() == null || check.compareTo(line.getQuantity()) != 0) throw exception(QC_IPQC_SOURCE_QUANTITY_INVALID);
        }
        // 2. 更新状态为已完成
        ipqcMapper.updateById(new MesQcIpqcDO()
@@ -276,22 +367,11 @@
        Long sourceDocId = ipqc.getSourceDocId();
        Long sourceLineId = ipqc.getSourceLineId();
        // 如果 IPQC 单没有设置来源,尝试通过 taskId 查找关联的待检验报工
        if (sourceDocId == null && ipqc.getTaskId() != null) {
            MesProFeedbackDO feedback = feedbackService.getFeedbackListByTaskId(ipqc.getTaskId()).stream()
                    .filter(f -> MesProFeedbackStatusEnum.UNCHECK.getStatus().equals(f.getStatus()))
                    .findFirst().orElse(null);
            if (feedback != null) {
                sourceDocId = feedback.getId();
                log.info("[writeBackSourceDoc] IPQC({}) 未设置来源单据,通过 taskId({}) 找到待检验报工({})",
                        ipqc.getId(), ipqc.getTaskId(), feedback.getId());
            }
        }
        if (sourceDocId == null) {
            log.warn("[writeBackSourceDoc] IPQC({}) 无法确定来源报工,跳过回写", ipqc.getId());
        if (ipqc.getSourceDocType() == null && sourceDocId == null && sourceLineId == null) {
            return;
        }
        if (!Objects.equals(ipqc.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                || sourceDocId == null || sourceLineId == null) throw exception(QC_IPQC_SOURCE_INCOMPLETE);
        // 2. 回写报工
        feedbackService.updateProFeedbackWhenIpqcFinish(sourceDocId, sourceLineId,
@@ -306,7 +386,7 @@
    @Transactional(rollbackFor = Exception.class)
    public void deleteIpqc(Long id) {
        // 1. 校验存在 + 草稿状态
        validateIpqcStatusPrepare(id);
        validateIpqcStatusPrepareForUpdate(id);
        // 2.1 删除主表
        ipqcMapper.deleteById(id);
@@ -325,18 +405,25 @@
        return ipqc;
    }
    /**
     * 校验过程检验单存在且为草稿状态
     *
     * @param id 过程检验单 ID
     * @return 过程检验单
     */
    private MesQcIpqcDO validateIpqcStatusPrepare(Long id) {
        MesQcIpqcDO ipqc = validateIpqcExists(id);
        if (ObjUtil.notEqual(ipqc.getStatus(), MesQcStatusEnum.DRAFT.getStatus())) {
            throw exception(QC_IPQC_NOT_PREPARE);
    private MesQcIpqcDO validateIpqcStatusPrepareForUpdate(Long id) {
        // 与报工回调一致:报工 → 产出行 → 检验单,避免相互等待。
        MesQcIpqcDO snapshot = validateIpqcExists(id);
        if (Objects.equals(snapshot.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                && snapshot.getSourceDocId() != null && snapshot.getSourceLineId() != null) {
            lockFeedback(snapshot.getSourceDocId());
            produceLineMapper.selectByIdForUpdate(snapshot.getSourceLineId());
        }
        MesQcIpqcDO ipqc = ipqcMapper.selectByIdForUpdate(id);
        if (ipqc == null) throw exception(QC_IPQC_NOT_EXISTS);
        if (ObjUtil.notEqual(ipqc.getStatus(), MesQcStatusEnum.DRAFT.getStatus())) throw exception(QC_IPQC_NOT_PREPARE);
        return ipqc;
    }
    private MesProFeedbackDO lockFeedback(Long id) {
        MesProFeedbackDO feedback = feedbackMapper.selectOne(new LambdaQueryWrapperX<MesProFeedbackDO>()
                .eq(MesProFeedbackDO::getId, id).last("FOR UPDATE"));
        if (feedback == null) throw exception(PRO_FEEDBACK_NOT_EXISTS);
        return feedback;
    }
    private void validateIpqcCodeUnique(Long id, String code) {
@@ -349,19 +436,28 @@
        }
    }
    private String validateAndGetSourceDocCode(Integer sourceDocType, Long sourceDocId, Long sourceLineId) {
        if (sourceDocType == null || sourceDocId == null) {
            return null;
    /**
     * 来源单据校验上下文
     *
     * @param code 来源单据编号
     * @param line 报工来源时对应的产出明细行,其它来源为 null
     */
    private record SourceDocInfo(String code, MesWmProductProduceLineDO line) {}
    private SourceDocInfo validateAndGetSourceDoc(Integer sourceDocType, Long sourceDocId, Long sourceLineId) {
        if (sourceDocType == null && sourceDocId == null && sourceLineId == null) {
            return new SourceDocInfo(null, null);
        }
        if (sourceDocType == null || sourceDocId == null || sourceLineId == null) throw exception(QC_IPQC_SOURCE_INCOMPLETE);
        if (Objects.equals(sourceDocType, MesBizTypeConstants.PRO_FEEDBACK)) {
            MesProFeedbackDO feedback = feedbackService.validateFeedbackExists(sourceDocId);
            MesProFeedbackDO feedback = lockFeedback(sourceDocId);
            // 校验 sourceLineId 必填
            if (sourceLineId == null) {
                throw exception(QC_IPQC_SOURCE_LINE_REQUIRED);
            }
            // 校验 sourceLineId 存在,且属于该报工的产出行
            MesWmProductProduceLineDO targetLine = productProduceLineService
                    .validateProductProduceLineExists(sourceLineId);
            MesWmProductProduceLineDO targetLine = produceLineMapper.selectByIdForUpdate(sourceLineId);
            if (targetLine == null) throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
            if (ObjUtil.notEqual(targetLine.getFeedbackId(), sourceDocId)) {
                throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
            }
@@ -369,7 +465,7 @@
            if (ObjUtil.notEqual(targetLine.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus())) {
                throw exception(QC_IPQC_SOURCE_LINE_NOT_PENDING);
            }
            return feedback.getCode();
            return new SourceDocInfo(feedback.getCode(), targetLine);
        }
        // 未知来源类型应报错,而不是静默忽略
        throw exception(QC_IPQC_SOURCE_DOC_TYPE_UNKNOWN);
@@ -525,4 +621,15 @@
                + String.format("%03d", seq.incrementAndGet());
    }
    private String getItemInfo(Long itemId) {
        if (itemId == null) {
            return "未知物料";
        }
        MesMdItemDO item = itemService.getItem(itemId);
        if (item != null) {
            return item.getCode() + "-" + item.getName();
        }
        return "物料ID:" + itemId;
    }
}