package cn.iocoder.yudao.module.mes.service.qc.imports; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi; import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO; import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi; import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO; import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateDO; import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService; import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService; import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService; import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateService; import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi; import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.QC_NO_TEMPLATE; /** * MES 质检单导入辅助类 * * 负责把 Excel 中的人类可读值(物料编码、供应商名称、客户名称、质检方案名称、检测人员姓名、工单号、工位名称等) * 解析为系统编号,并统一处理字典、日期、错误文案。 * 各物料/供应商/客户/方案映射在 {@link #startImport()} 后首次使用时一次性加载,避免逐行查询。 */ @Component public class MesQcImportHelper { @Resource private AdminUserApi adminUserApi; @Resource private MdmItemApi mdmItemApi; @Resource private SrmSupplierApi srmSupplierApi; @Resource private CrmCustomerApi crmCustomerApi; @Resource private MesQcTemplateService templateService; @Resource private MesMdAutoCodeRecordService autoCodeRecordService; @Resource private MesProWorkOrderService workOrderService; @Resource private MesMdWorkstationService workstationService; /** 质检方案名称 -> 编号(可能重名,命中多个时取第一个) */ private Map templateNameMap; /** 供应商名称 -> 编号 */ private Map supplierNameMap; /** 客户名称 -> 编号 */ private Map customerNameMap; /** 当前登录用户可见的客户编号;null 表示不限制 */ private Set permittedClientIds; private boolean clientPermissionLoaded; /** * 开始一次导入:清空上一次导入的缓存,保证新建的供应商/客户/方案本次即可命中 */ public void startImport() { templateNameMap = null; supplierNameMap = null; customerNameMap = null; permittedClientIds = null; clientPermissionLoaded = false; } /** * 生成检验单编号,如 IQC20260101000001 */ public String generateCode(String ruleCode) { return autoCodeRecordService.generateAutoCode(ruleCode); } /** * 解析检测人员:留空取当前登录用户;填写时按姓名(昵称)精确匹配,取不到则用模糊匹配的第一条 */ public Long resolveInspectorUserId(String nickname) { if (StrUtil.isBlank(nickname)) { return getLoginUserId(); } List users = adminUserApi.getUserListByNickname(nickname.trim()); if (CollUtil.isEmpty(users)) { throw new IllegalArgumentException("检测人员不存在:" + nickname.trim() + "。请在「检测人员」列填写系统内的用户姓名(可在「系统管理-用户管理」中核对),或留空使用当前登录用户"); } return users.stream().filter(user -> nickname.trim().equals(user.getNickname())) .map(AdminUserRespDTO::getId).findFirst().orElseGet(() -> users.get(0).getId()); } /** * 解析产品物料编号,基于物料编码 */ public Long resolveItemId(String itemCode) { if (StrUtil.isBlank(itemCode)) { throw new IllegalArgumentException("产品物料编码不能为空:请填写模板中该行的「产品物料编码」列"); } MdmItemRespDTO item = mdmItemApi.getItemByCode(itemCode.trim()).getCheckedData(); if (item == null) { throw new IllegalArgumentException("物料不存在:" + itemCode.trim() + "。请先在「物料管理」中维护该物料(编码需与模板完全一致),或修改模板中的产品物料编码后重新导入"); } return item.getId(); } /** * 解析质检方案编号,基于方案名称;留空返回 null(由系统按物料+检验类型自动匹配) */ public Long resolveTemplateId(String templateName) { if (StrUtil.isBlank(templateName)) { return null; } if (templateNameMap == null) { templateNameMap = new HashMap<>(); List templates = templateService.getTemplateList(); if (CollUtil.isNotEmpty(templates)) { templates.forEach(template -> { if (StrUtil.isNotBlank(template.getName())) { templateNameMap.putIfAbsent(template.getName().trim(), template.getId()); } }); } } Long templateId = templateNameMap.get(templateName.trim()); if (templateId == null) { throw new IllegalArgumentException("质检方案不存在:" + templateName.trim() + "。请在「质检管理-质检方案」中核对方案名称,或留空该列由系统按物料自动匹配方案后重新导入"); } return templateId; } /** * 解析供应商编号,基于供应商名称;留空返回 null */ public Long resolveVendorId(String vendorName) { if (StrUtil.isBlank(vendorName)) { return null; } if (supplierNameMap == null) { supplierNameMap = new HashMap<>(); List suppliers = srmSupplierApi.getSupplierSimpleList().getCheckedData(); if (CollUtil.isNotEmpty(suppliers)) { suppliers.forEach(supplier -> { if (StrUtil.isNotBlank(supplier.getName())) { supplierNameMap.putIfAbsent(supplier.getName().trim(), supplier.getId()); } }); } } Long vendorId = supplierNameMap.get(vendorName.trim()); if (vendorId == null) { throw new IllegalArgumentException("供应商不存在或未通过准入审批:" + vendorName.trim() + "。请在「供应商管理」中核对供应商名称(名称需与模板完全一致),或留空该列后重新导入"); } return vendorId; } /** * 解析客户编号,基于客户名称 */ public Long resolveClientId(String clientName) { if (StrUtil.isBlank(clientName)) { throw new IllegalArgumentException("客户名称不能为空:请填写模板中该行的「客户名称」列"); } if (customerNameMap == null) { customerNameMap = new HashMap<>(); List customers = crmCustomerApi.getCustomerSimpleList(); if (CollUtil.isNotEmpty(customers)) { customers.forEach(customer -> { if (StrUtil.isNotBlank(customer.getName())) { customerNameMap.putIfAbsent(customer.getName().trim(), customer.getId()); } }); } } Long clientId = customerNameMap.get(clientName.trim()); if (clientId == null) { throw new IllegalArgumentException("客户不存在:" + clientName.trim() + "。请先在「CRM 客户管理」中创建该客户(名称需与模板完全一致),或修改模板中的客户名称后重新导入"); } return clientId; } /** * 校验客户在当前登录用户的可见范围内 * * 出货检验单列表按客户数据权限过滤,权限外的客户导入后本人在列表中看不到该单据,因此提前拦截。 */ public void validateClientPermission(Long clientId, String clientName) { if (!clientPermissionLoaded) { List permittedIds = crmCustomerApi.getPermittedCustomerIds(); permittedClientIds = permittedIds == null ? null : new HashSet<>(permittedIds); clientPermissionLoaded = true; } if (permittedClientIds == null || permittedClientIds.contains(clientId)) { return; } throw new IllegalArgumentException("客户[" + clientName + "]不在您的数据权限范围内:" + "导入后您将无法在「出货检验」列表中看到该检验单。请改用您负责的客户(可在「CRM 客户管理-我负责的」中查看)," + "或联系管理员为您开通该客户的权限后重新导入"); } /** * 解析生产工单编号,基于工单号 */ public Long resolveWorkOrderId(String workOrderCode) { if (StrUtil.isBlank(workOrderCode)) { throw new IllegalArgumentException("生产工单号不能为空:请填写模板中该行的「生产工单号」列"); } MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderCode.trim()); if (workOrder == null) { throw new IllegalArgumentException("生产工单不存在:" + workOrderCode.trim() + "。请在「生产管理-生产工单」中核对该工单号(需与模板完全一致),或修改模板后重新导入"); } return workOrder.getId(); } /** * 解析工位编号,基于工位名称 */ public Long resolveWorkstationId(String workstationName) { if (StrUtil.isBlank(workstationName)) { throw new IllegalArgumentException("工位名称不能为空:请填写模板中该行的「工位名称」列"); } MesMdWorkstationDO workstation = workstationService.getWorkstationByName(workstationName.trim()); if (workstation == null) { throw new IllegalArgumentException("工位不存在:" + workstationName.trim() + "。请在「基础数据-工位管理」中核对该工位名称(需与模板完全一致),或修改模板后重新导入"); } return workstation.getId(); } /** * 解析字典值:支持填写字典名称(如 校验通过)或字典值(如 1) * * @param text Excel 单元格内容 * @param dictType 字典类型 * @param fieldLabel 列名,用于错误提示 * @param required 是否必填 */ public Integer resolveDictValue(String text, String dictType, String fieldLabel, boolean required) { List labelList = DictFrameworkUtils.getDictDataLabelList(dictType); if (StrUtil.isBlank(text)) { if (required) { throw new IllegalArgumentException(fieldLabel + "不能为空:请填写模板中该行的「" + fieldLabel + "」列,可选值:" + String.join("、", labelList)); } return null; } String value = text.trim(); if (value.matches("\\d+") && DictFrameworkUtils.getDictDataValueList(dictType).contains(value)) { return Integer.valueOf(value); } String parsedValue = DictFrameworkUtils.parseDictDataValue(dictType, value); if (parsedValue == null) { throw new IllegalArgumentException(fieldLabel + "不存在:" + value + "。模板中「" + fieldLabel + "」请填写以下之一:" + String.join("、", labelList)); } return Integer.valueOf(parsedValue); } /** * 解析数量:必填时不能为空,且不能小于 0 */ public BigDecimal resolveQuantity(BigDecimal value, String fieldLabel, boolean required) { if (value == null) { if (required) { throw new IllegalArgumentException(fieldLabel + "不能为空:请填写模板中该行的「" + fieldLabel + "」列"); } return null; } if (value.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException(fieldLabel + "不能小于 0,当前为 " + value.stripTrailingZeros().toPlainString() + "。请修改模板后重新导入"); } return value; } /** * 校验「合计数量 = 合格品数量 + 不合格品数量」 * * @param total 合计数量(合计列留空时传 null,由合格 + 不合格 自动推算) * @param qualified 合格品数量 * @param unqualified 不合格品数量 * @param totalFieldLabel 合计列的列名,如「本次接收数量」 */ public BigDecimal resolveCheckQuantity(BigDecimal total, BigDecimal qualified, BigDecimal unqualified, String totalFieldLabel) { BigDecimal sum = qualified.add(unqualified); if (total == null) { return sum; } if (total.compareTo(sum) != 0) { throw new IllegalArgumentException(totalFieldLabel + "必须等于合格品数量与不合格品数量之和:合格品数量 " + qualified.stripTrailingZeros().toPlainString() + " + 不合格品数量 " + unqualified.stripTrailingZeros().toPlainString() + " = " + sum.stripTrailingZeros().toPlainString() + ",而「" + totalFieldLabel + "」填写的是 " + total.stripTrailingZeros().toPlainString() + "。请修改模板中的数量列,或将该列留空由系统自动合计后重新导入"); } return total; } /** * 解析日期时间:支持 2026-01-01 10:00:00 / 2026-01-01 10:00 / 2026-01-01 / 2026/1/1;留空返回 null */ public LocalDateTime parseDateTime(String text, String fieldLabel) { if (StrUtil.isBlank(text)) { return null; } String value = text.trim(); for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm:ss"}) { try { return LocalDateTime.parse(value, DateTimeFormatter.ofPattern(pattern)); } catch (Exception ignored) { // 继续尝试下一个格式 } } for (String pattern : new String[]{"yyyy-MM-dd", "yyyy/M/d"}) { try { return java.time.LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern)).atStartOfDay(); } catch (Exception ignored) { // 继续尝试下一个格式 } } throw new IllegalArgumentException(fieldLabel + "格式不正确:" + text + ",示例 2026-01-01 10:00:00"); } /** * 解析必填的日期时间:留空报错 */ public LocalDateTime parseRequiredDateTime(String text, String fieldLabel) { LocalDateTime value = parseDateTime(text, fieldLabel); if (value == null) { throw new IllegalArgumentException(fieldLabel + "不能为空:请填写模板中该行的「" + fieldLabel + "」列,示例 2026-01-01 10:00:00"); } return value; } /** * 物料未配置质检方案时,补充物料与检验类型上下文再抛出;其余异常原样抛出 */ public static void rethrowTemplateError(ServiceException ex, String itemCode, String qcTypeLabel) { if (Objects.equals(ex.getCode(), QC_NO_TEMPLATE.getCode())) { throw new IllegalArgumentException("物料[" + StrUtil.blankToDefault(itemCode, "未知") + "]未配置" + qcTypeLabel + "的质检方案。请在「质检管理-质检方案」中为该物料配置" + qcTypeLabel + "方案,或在模板的「质检方案」列填写该物料已配置的方案名称后重新导入"); } throw ex; } /** * 单行导入失败时的提示文案 */ public static String resolveImportErrorMessage(Exception ex) { if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) { return serviceException.getMessage(); } return StrUtil.blankToDefault(ex.getMessage(), "导入失败"); } /** * 重复数据提示中的取值展示:留空显示为「未填写」 */ public static String describeImportValue(String value) { return StrUtil.isBlank(value) ? "未填写" : value.trim(); } /** * 同一份模板内出现重复数据时的提示 */ public static IllegalArgumentException duplicateRowException(int firstRowNo, String keyDesc) { return new IllegalArgumentException("检测到重复导入:本次导入的第 " + firstRowNo + " 行与当前行的数据完全相同(" + keyDesc + ")。请删除模板中重复的行后重新导入"); } /** * 与系统中已有单据重复时的提示 */ public static IllegalArgumentException duplicateExistingException(String keyDesc, String docLabel, String existingCode, String menuPath) { return new IllegalArgumentException("检测到重复导入:" + keyDesc + " 与已有" + docLabel + "「" + existingCode + "」完全相同(可能是本次导入前面的行,也可能是系统中已有单据)。请勿重复导入;如确需重新导入,请先在「" + menuPath + "」中删除该" + docLabel + "后重试"); } }