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<String, Long> templateNameMap;
|
/** 供应商名称 -> 编号 */
|
private Map<String, Long> supplierNameMap;
|
/** 客户名称 -> 编号 */
|
private Map<String, Long> customerNameMap;
|
/** 当前登录用户可见的客户编号;null 表示不限制 */
|
private Set<Long> 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<AdminUserRespDTO> 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<MesQcTemplateDO> 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<SrmSupplierRespDTO> 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<CrmCustomerRespDTO> 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<Long> 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<String> 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 + "后重试");
|
}
|
|
}
|