yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java
@@ -1,13 +1,23 @@
package cn.iocoder.yudao.module.mes.service.qc.ipqc;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcImportExcelVO;
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.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.service.qc.imports.MesQcImportHelper;
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;
@@ -15,6 +25,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateDO;
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.enums.qc.MesQcDefectLevelEnum;
@@ -32,6 +43,9 @@
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateService;
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;
@@ -43,7 +57,11 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -52,7 +70,7 @@
/**
 * MES 过程检验单(IPQC) Service 实现类
 *
 * @author 芋道源码
 * @author 超级管理员
 */
@Service
@Validated
@@ -62,16 +80,19 @@
    /**
     * IPQC 保存数据校验上下文
     *
     * @param workstation 工位
     * @param workstation 工位(可选,未指定 workstationId 时为空)
     * @param task        生产任务(可选,指定 taskId 时非空)
     * @param workOrder   生产工单(可选,未指定 workOrderId 时为空)
     */
    private record SaveDataContext(MesMdWorkstationDO workstation, MesProTaskDO task) {}
    private record SaveDataContext(MesMdWorkstationDO workstation, MesProTaskDO task, MesProWorkOrderDO workOrder) {}
    @Resource
    private MesQcIpqcMapper ipqcMapper;
    @Resource
    private MesQcTemplateItemService templateItemService;
    @Resource
    private MesQcTemplateService templateService;
    @Resource
    private MesQcIpqcLineService ipqcLineService;
    @Resource
@@ -106,29 +127,33 @@
    private MesWmProductProduceLineService productProduceLineService;
    @Resource
    @Lazy
    private MesQcNcrService ncrService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createIpqc(MesQcIpqcSaveReqVO createReqVO) {
        // 1.1 校验数据
        // 1.1 校验数据(生产订单、工位均非必填)
        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 获取来源单据编号
        // 1.2 确定检验物料:有任务时取任务的物料,有工单时取工单的产品,都没有时取请求指定的产品物料
        Long itemId = resolveItemId(createReqVO, ctx);
        // 1.3 确定质检方案:显式指定时校验方案本身可用,未指定时按产品物料已绑定方案兜底
        Long templateId = resolveTemplateId(itemId, createReqVO.getTemplateId());
        // 1.4 获取来源单据编号
        String sourceDocCode = validateAndGetSourceDocCode(
                createReqVO.getSourceDocType(), createReqVO.getSourceDocId(), createReqVO.getSourceLineId());
        // 2. 插入主表
        MesQcIpqcDO ipqc = BeanUtils.toBean(createReqVO, MesQcIpqcDO.class)
                .setItemId(itemId)
                .setProcessId(getProcessId(itemId, ctx.workstation().getProcessId()))
                .setProcessId(getProcessId(itemId,
                        ctx.workstation() != null ? ctx.workstation().getProcessId() : null))
                .setTemplateId(templateId)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
@@ -153,19 +178,23 @@
        updateObj.setTemplateId(null); // 不允许修改模板
        updateObj.setItemId(null); // 不允许修改物料
        updateObj.setWorkOrderId(null); // 不允许修改工单
        updateObj.setProcessId(getProcessId(existIpqc.getItemId(), ctx.workstation().getProcessId()));
        updateObj.setProcessId(getProcessId(existIpqc.getItemId(),
                ctx.workstation() != null ? ctx.workstation().getProcessId() : null));
        ipqcMapper.updateById(updateObj);
    }
    private SaveDataContext validateIpqcSaveData(MesQcIpqcSaveReqVO reqVO) {
        // 校验编号唯一
        validateIpqcCodeUnique(reqVO.getId(), reqVO.getCode());
        // 校验工位、检测人员存在
        MesMdWorkstationDO workstation = workstationService.validateWorkstationExists(reqVO.getWorkstationId());
        // 校验工位存在(工位非必填,未选择时跳过)
        MesMdWorkstationDO workstation = reqVO.getWorkstationId() != null
                ? workstationService.validateWorkstationExists(reqVO.getWorkstationId()) : null;
        adminUserApi.validateUser(reqVO.getInspectorUserId());
        // 校验工单存在
        MesProWorkOrderDO workOrder = workOrderService.validateWorkOrderConfirmed(reqVO.getWorkOrderId());
        if (reqVO.getProcessId() != null && ObjUtil.notEqual(workstation.getProcessId(), reqVO.getProcessId())) {
        // 校验工单存在且已确认(生产订单非必填,未选择时跳过)
        MesProWorkOrderDO workOrder = reqVO.getWorkOrderId() != null
                ? workOrderService.validateWorkOrderConfirmed(reqVO.getWorkOrderId()) : null;
        if (workstation != null && reqVO.getProcessId() != null
                && ObjUtil.notEqual(workstation.getProcessId(), reqVO.getProcessId())) {
            throw exception(PRO_WORKSTATION_PROCESS_MISMATCH);
        }
        // 校验任务关系(如果指定了任务)
@@ -173,24 +202,103 @@
        if (reqVO.getTaskId() != null) {
            task = taskService.validateTaskNotFinished(reqVO.getTaskId());
            validateTaskRelation(task, workstation, workOrder, reqVO);
        } else {
        } else if (workOrder != null && reqVO.getItemId() != null
                && ObjUtil.notEqual(reqVO.getItemId(), workOrder.getProductId())) {
            // 无任务时,itemId 仍需与工单产品一致
            if (reqVO.getItemId() != null && ObjUtil.notEqual(reqVO.getItemId(), workOrder.getProductId())) {
                throw exception(PRO_WORK_ORDER_PRODUCT_MISMATCH);
            }
            throw exception(PRO_WORK_ORDER_PRODUCT_MISMATCH);
        }
        return new SaveDataContext(workstation, task);
        return new SaveDataContext(workstation, task, workOrder);
    }
    /**
     * 确定检验物料:任务优先,其次工单产品,都没有时取请求指定的产品物料(都未指定则为空)
     */
    private Long resolveItemId(MesQcIpqcSaveReqVO reqVO, SaveDataContext ctx) {
        if (ctx.task() != null) {
            return ctx.task().getItemId();
        }
        if (ctx.workOrder() != null) {
            return ctx.workOrder().getProductId();
        }
        return reqVO.getItemId();
    }
    /**
     * 确定质检方案
     *
     * <p>产品物料已绑定 IPQC 方案时沿用原逻辑:未显式指定取绑定列表首个,显式指定则必须命中绑定列表;
     * <p>产品物料未匹配到任何方案(未选生产订单/生产任务/产品物料)时,显式指定的方案改为校验方案本身:
     * 存在、已启用、检测种类包含过程检验,不再要求与产品物料绑定。
     */
    private Long resolveTemplateId(Long itemId, Long explicitTemplateId) {
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                itemId, MesQcTypeEnum.IPQC.getType(), null, null);
        if (CollUtil.isNotEmpty(templateItems)) {
            if (explicitTemplateId == null) {
                return templateItems.get(0).getTemplateId();
            }
            return templateItems.stream()
                    .filter(available -> Objects.equals(available.getTemplateId(), explicitTemplateId))
                    .findFirst()
                    .orElseThrow(() -> exception(QC_NO_TEMPLATE)) // 指定了方案但不在匹配列表中,拒绝
                    .getTemplateId();
        }
        // 产品物料未匹配到任何 IPQC 方案
        if (explicitTemplateId != null) {
            return validateTemplateUsable(explicitTemplateId);
        }
        throw new ServiceException(QC_IPQC_NO_TEMPLATE.getCode(), buildNoTemplateMessage(itemId));
    }
    /**
     * 校验质检方案本身可用:存在、已启用、检测种类包含过程检验,返回方案编号
     */
    private Long validateTemplateUsable(Long templateId) {
        MesQcTemplateDO template = templateService.getTemplate(templateId);
        if (template == null) {
            throw new ServiceException(QC_TEMPLATE_NOT_EXISTS.getCode(), StrUtil.format(
                    "所选质检方案不存在(ID={}),可能已被删除,请刷新后重新选择质检方案", templateId));
        }
        if (!CommonStatusEnum.isEnable(template.getStatus())) {
            throw exception(QC_IPQC_TEMPLATE_IS_DISABLE, template.getName());
        }
        if (!CollUtil.contains(template.getTypes(), MesQcTypeEnum.IPQC.getType())) {
            throw exception(QC_IPQC_TEMPLATE_TYPE_NOT_MATCH, template.getName());
        }
        return template.getId();
    }
    /**
     * 构建「无可用质检方案」的提示:区分「有产品物料但未绑定方案」与「未选工单/任务/物料」
     */
    private String buildNoTemplateMessage(Long itemId) {
        if (itemId == null) {
            return "未选择生产订单/生产任务/产品物料,无法自动匹配质检方案:"
                    + "请直接选择「质检方案」后重试,或先选择生产订单/生产任务以自动带出该产品已绑定的方案。";
        }
        return StrUtil.format("产品物料[{}]未绑定「过程检验」质检方案:请在【质量管理-质检方案】中为该物料"
                + "配置检测种类包含过程检验的方案,或在本单手动选择质检方案。", describeItem(itemId));
    }
    /**
     * 描述产品物料,用于异常提示:优先给出「编码 名称」,查不到时回退为物料编号
     */
    private String describeItem(Long itemId) {
        MesMdItemDO item = itemService.getItem(itemId);
        return item != null ? item.getCode() + " " + item.getName() : "物料编号 ID=" + itemId;
    }
    private void validateTaskRelation(MesProTaskDO task, MesMdWorkstationDO workstation,
                                      MesProWorkOrderDO workOrder, MesQcIpqcSaveReqVO reqVO) {
        if (ObjUtil.notEqual(task.getWorkOrderId(), workOrder.getId())) {
        // 生产订单、工位均非必填,未选择时跳过对应比对
        if (workOrder != null && ObjUtil.notEqual(task.getWorkOrderId(), workOrder.getId())) {
            throw exception(PRO_TASK_WORK_ORDER_MISMATCH);
        }
        if (ObjUtil.notEqual(task.getWorkstationId(), workstation.getId())) {
        if (workstation != null && ObjUtil.notEqual(task.getWorkstationId(), workstation.getId())) {
            throw exception(PRO_TASK_WORKSTATION_MISMATCH);
        }
        Long expectedProcessId = reqVO.getProcessId() != null ? reqVO.getProcessId() : workstation.getProcessId();
        Long expectedProcessId = reqVO.getProcessId() != null ? reqVO.getProcessId()
                : (workstation != null ? workstation.getProcessId() : null);
        if (expectedProcessId != null && ObjUtil.notEqual(task.getProcessId(), expectedProcessId)) {
            throw exception(PRO_TASK_ROUTE_PROCESS_MISMATCH);
        }
@@ -216,8 +324,35 @@
        ipqcMapper.updateById(new MesQcIpqcDO()
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus()));
        // 3. 回写来源单据
        // 3. 不合格时自动创建 NCR
        generateNcrIfNeeded(ipqc);
        // 4. 回写来源单据
        writeBackSourceDoc(ipqc);
    }
    /**
     * 不合格时自动创建 NCR(不合格品处理单)
     *
     * @param ipqc 过程检验单
     */
    private void generateNcrIfNeeded(MesQcIpqcDO ipqc) {
        BigDecimal unqualifiedQty = ipqc.getUnqualifiedQuantity();
        if (unqualifiedQty == null || unqualifiedQty.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        String ncrCode = "NCR" + java.time.LocalDateTime.now()
                .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
        MesQcNcrSaveReqVO saveReqVO = new MesQcNcrSaveReqVO();
        saveReqVO.setCode(ncrCode);
        saveReqVO.setName("不合格品处理单");
        saveReqVO.setQcType(MesQcTypeEnum.IPQC.getType());
        saveReqVO.setQcId(ipqc.getId());
        saveReqVO.setItemId(ipqc.getItemId());
        saveReqVO.setUnqualifiedQuantity(unqualifiedQty);
        saveReqVO.setDefectDescription("由过程检验(" + ipqc.getCode() + ")自动生成");
        ncrService.createNcr(saveReqVO);
        log.info("[generateNcrIfNeeded] IPQC({}) 不合格,自动创建 NCR", ipqc.getId());
    }
    /**
@@ -410,4 +545,142 @@
        ipqcMapper.updateById(updateIpqc);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesQcImportRespVO importIpqcList(List<MesQcIpqcImportExcelVO> importList) {
        MesQcImportRespVO respVO = MesQcImportRespVO.builder()
                .createList(new ArrayList<>()).failureList(new LinkedHashMap<>()).build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
        }
        qcImportHelper.startImport();
        Map<String, Integer> firstRowNoByKey = new HashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            MesQcIpqcImportExcelVO row = importList.get(i);
            if (isImportRowEmpty(row)) {
                continue; // 跳过空行
            }
            String label = StrUtil.isNotBlank(row.getCode()) ? "检验单编号 " + row.getCode().trim()
                    : "第 " + (i + 2) + " 行";
            String importKey = buildImportKey(row);
            try {
                Integer firstRowNo = firstRowNoByKey.get(importKey);
                if (firstRowNo != null) {
                    throw MesQcImportHelper.duplicateRowException(firstRowNo, buildImportKeyDesc(row));
                }
                respVO.getCreateList().add(createIpqcByImport(row));
                firstRowNoByKey.putIfAbsent(importKey, i + 2);
            } catch (Exception ex) {
                respVO.getFailureList().put(label, MesQcImportHelper.resolveImportErrorMessage(ex));
            }
        }
        return respVO;
    }
    private boolean isImportRowEmpty(MesQcIpqcImportExcelVO row) {
        return row == null || (StrUtil.isBlank(row.getCode()) && StrUtil.isBlank(row.getName())
                && StrUtil.isBlank(row.getType()) && StrUtil.isBlank(row.getWorkOrderCode())
                && StrUtil.isBlank(row.getWorkstationName()) && StrUtil.isBlank(row.getTemplateName())
                && row.getCheckQuantity() == null && row.getQualifiedQuantity() == null
                && row.getUnqualifiedQuantity() == null && row.getLaborScrapQuantity() == null
                && row.getMaterialScrapQuantity() == null && row.getOtherScrapQuantity() == null
                && StrUtil.isBlank(row.getCheckResult()) && StrUtil.isBlank(row.getInspectorName())
                && StrUtil.isBlank(row.getInspectDate()) && StrUtil.isBlank(row.getRemark()));
    }
    /**
     * 依据导入行创建一张过程检验单:物料由工单推导,状态为「待检验」,检验行由质检方案自动生成
     *
     * @return 检验单编号
     */
    private String createIpqcByImport(MesQcIpqcImportExcelVO row) {
        MesQcIpqcSaveReqVO reqVO = new MesQcIpqcSaveReqVO();
        // 1. 检验单编号:填写则使用,留空自动生成
        String code = StrUtil.isNotBlank(row.getCode()) ? row.getCode().trim()
                : qcImportHelper.generateCode(MesMdAutoCodeRuleCodeEnum.QC_IPQC_CODE.getCode());
        if (ipqcMapper.selectByCode(code) != null) {
            throw new IllegalArgumentException("检验单编号已存在:" + code
                    + "。请修改模板中的「检验单编号」,或先在系统中删除该编号的检验单后重新导入");
        }
        reqVO.setCode(code);
        // 2. 名称、检验类型、工单、工位、质检方案(生产工单号、工位名称留空即不关联,不再拦截)
        if (StrUtil.isBlank(row.getName())) {
            throw new IllegalArgumentException("检验单名称不能为空:请填写模板中该行的「检验单名称」列");
        }
        reqVO.setName(row.getName().trim());
        reqVO.setType(qcImportHelper.resolveDictValue(row.getType(),
                DictTypeConstants.MES_IPQC_TYPE, "检验类型", true));
        reqVO.setWorkOrderId(qcImportHelper.resolveWorkOrderId(row.getWorkOrderCode()));
        reqVO.setWorkstationId(qcImportHelper.resolveWorkstationId(row.getWorkstationName()));
        reqVO.setTemplateId(qcImportHelper.resolveTemplateId(row.getTemplateName()));
        // 3. 数量:合格品 + 不合格品 必须等于检测数量(检测数量留空则自动合计);工废/料废/其他废留空按 0
        BigDecimal qualified = qcImportHelper.resolveQuantity(row.getQualifiedQuantity(), "合格品数量", true);
        BigDecimal unqualified = qcImportHelper.resolveQuantity(row.getUnqualifiedQuantity(), "不合格品数量", true);
        reqVO.setQualifiedQuantity(qualified);
        reqVO.setUnqualifiedQuantity(unqualified);
        reqVO.setCheckQuantity(qcImportHelper.resolveCheckQuantity(
                row.getCheckQuantity(), qualified, unqualified, "检测数量"));
        BigDecimal laborScrap = qcImportHelper.resolveQuantity(row.getLaborScrapQuantity(), "工废数量", false);
        BigDecimal materialScrap = qcImportHelper.resolveQuantity(row.getMaterialScrapQuantity(), "料废数量", false);
        BigDecimal otherScrap = qcImportHelper.resolveQuantity(row.getOtherScrapQuantity(), "其他废品数量", false);
        laborScrap = laborScrap != null ? laborScrap : BigDecimal.ZERO;
        materialScrap = materialScrap != null ? materialScrap : BigDecimal.ZERO;
        otherScrap = otherScrap != null ? otherScrap : BigDecimal.ZERO;
        validateScrapQuantity(unqualified, laborScrap, materialScrap, otherScrap);
        reqVO.setLaborScrapQuantity(laborScrap);
        reqVO.setMaterialScrapQuantity(materialScrap);
        reqVO.setOtherScrapQuantity(otherScrap);
        // 4. 检验信息
        reqVO.setCheckResult(qcImportHelper.resolveDictValue(row.getCheckResult(),
                DictTypeConstants.MES_QC_CHECK_RESULT, "检测结果", false));
        reqVO.setInspectorUserId(qcImportHelper.resolveInspectorUserId(row.getInspectorName()));
        reqVO.setInspectDate(qcImportHelper.parseDateTime(row.getInspectDate(), "检测日期"));
        reqVO.setRemark(StrUtil.trimToNull(row.getRemark()));
        // 5. 去重:同一 生产工单 + 检验类型 + 工位 + 检测日期 的过程检验单不可重复导入
        MesQcIpqcDO duplicate = CollUtil.getFirst(ipqcMapper.selectListByImportKey(reqVO.getWorkOrderId(),
                reqVO.getType(), reqVO.getWorkstationId(), reqVO.getInspectDate()));
        if (duplicate != null) {
            throw MesQcImportHelper.duplicateExistingException(buildImportKeyDesc(row), "过程检验单",
                    duplicate.getCode(), "质量管理-过程检验");
        }
        // 6. 复用创建逻辑:校验工单/工位/人员、按物料匹配质检方案、生成检验行
        try {
            createIpqc(reqVO);
        } catch (ServiceException ex) {
            MesQcImportHelper.rethrowTemplateError(ex, row.getWorkOrderCode(), "过程检验(IPQC)");
        }
        return code;
    }
    /**
     * 校验 工废 + 料废 + 其他废品 不超过不合格品数量
     */
    private void validateScrapQuantity(BigDecimal unqualified, BigDecimal laborScrap,
                                       BigDecimal materialScrap, BigDecimal otherScrap) {
        BigDecimal scrapSum = laborScrap.add(materialScrap).add(otherScrap);
        if (scrapSum.compareTo(unqualified) <= 0) {
            return;
        }
        throw new IllegalArgumentException("工废、料废、其他废品数量之和不能超过不合格品数量:不合格品数量为 "
                + unqualified.stripTrailingZeros().toPlainString() + ",而 工废 " + laborScrap.stripTrailingZeros().toPlainString()
                + " + 料废 " + materialScrap.stripTrailingZeros().toPlainString() + " + 其他废品 "
                + otherScrap.stripTrailingZeros().toPlainString() + " = " + scrapSum.stripTrailingZeros().toPlainString()
                + "。请修改模板中的废品数量或不合格品数量后重新导入");
    }
    /**
     * 导入去重键:同一 生产工单 + 检验类型 + 工位 + 检测日期 视为同一批过程检验数据
     */
    private String buildImportKey(MesQcIpqcImportExcelVO row) {
        return String.join("|", StrUtil.trimToEmpty(row.getWorkOrderCode()), StrUtil.trimToEmpty(row.getType()),
                StrUtil.trimToEmpty(row.getWorkstationName()), StrUtil.trimToEmpty(row.getInspectDate()));
    }
    private String buildImportKeyDesc(MesQcIpqcImportExcelVO row) {
        return "生产工单[" + MesQcImportHelper.describeImportValue(row.getWorkOrderCode())
                + "]、检验类型[" + MesQcImportHelper.describeImportValue(row.getType())
                + "]、工位[" + MesQcImportHelper.describeImportValue(row.getWorkstationName())
                + "]、检测日期[" + MesQcImportHelper.describeImportValue(row.getInspectDate()) + "]";
    }
}