package cn.iocoder.yudao.module.mdm.service.agent; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO; import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO; import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO; import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO; import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext; import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution; import cn.iocoder.yudao.module.mdm.service.item.MdmItemService; import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; import cn.iocoder.yudao.module.system.framework.agent.SystemAgentBridgeGuard; import cn.iocoder.yudao.module.system.service.storage.SystemAgentAttachmentRef; import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.*; /** * 物料导入只读预检:校验附件、解析分类/单位、判断编码是否已存在,不写任何业务表。 */ @Service @Validated public class MdmAgentImportPreflightService { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @Resource private SystemAgentImportBridgeService bridgeService; @Resource private MdmItemService itemService; @Resource private MdmAgentImportPolicy policy; public MdmAgentImportPreflightRespVO preflight(MdmAgentImportPreflightReqVO reqVO, AgentPrincipal principal) { if (reqVO.getRows().size() > policy.getMaxRows()) { throw new AgentBridgeException(HttpStatus.PAYLOAD_TOO_LARGE, "ROW_LIMIT_EXCEEDED", "导入行数超过上限:" + policy.getMaxRows()); } // 1. 附件与摘要必须与本次会话绑定的一致 SystemAgentAttachmentRef attachment = bridgeService.requireOwnedAttachmentRef(reqVO.getAttachmentId()); if (reqVO.getSessionId() != null && !reqVO.getSessionId().equals(attachment.sessionId())) { throw new AgentBridgeException(HttpStatus.CONFLICT, "SESSION_MISMATCH", "附件不属于指定补录会话,请重新上传后再试", true, null); } String sourceSha256 = bridgeService.sha256OfAttachment(reqVO.getAttachmentId()); if (!sourceSha256.equalsIgnoreCase(reqVO.getSourceSha256().trim())) { throw new AgentBridgeException(HttpStatus.CONFLICT, "SOURCE_DIGEST_MISMATCH", "附件内容已变化(摘要不一致),请重新上传后再试", true, null); } // 2. candidate_id 必须唯一,否则回执无法逐行对应 Set candidateIds = new HashSet<>(); for (MdmAgentImportPreflightReqVO.Row row : reqVO.getRows()) { if (!candidateIds.add(row.getCandidateId())) { throw new AgentBridgeException(HttpStatus.BAD_REQUEST, "CANDIDATE_ID_DUPLICATED", "导入行标识重复:" + row.getCandidateId()); } } // 3. 预热分类/单位映射 List excelRows = reqVO.getRows().stream() .map(MdmAgentImportPreflightService::toExcelVO).toList(); MdmItemImportContext context = itemService.buildImportContext(excelRows); boolean syncMesRequested = Boolean.TRUE.equals(reqVO.getSyncMes()); List blockingItems = new ArrayList<>(); if (syncMesRequested) { blockingItems.add(issue("MES_SYNC_DISABLED", "syncMes", "本阶段不开放同步 MES,请关闭 syncMes 后重试", false)); } // 4. 文件内重复编码:所有重复行一起阻断,避免“首行建、次行跳”的误导 Map codeCounts = reqVO.getRows().stream() .map(MdmAgentImportPreflightReqVO.Row::getCode).filter(StrUtil::isNotBlank) .map(String::trim).collect(java.util.stream.Collectors.groupingBy(code -> code, java.util.stream.Collectors.counting())); Set duplicateCodes = codeCounts.entrySet().stream() .filter(entry -> entry.getValue() > 1).map(Map.Entry::getKey) .collect(java.util.stream.Collectors.toSet()); // 5. 逐行判定 CREATE / SKIP / BLOCKED List rows = new ArrayList<>(); int createCount = 0; int skipCount = 0; int blockedCount = 0; for (int index = 0; index < reqVO.getRows().size(); index++) { MdmAgentImportPreflightReqVO.Row row = reqVO.getRows().get(index); MdmItemImportExcelVO excelVO = excelRows.get(index); List blockers = new ArrayList<>(); List warnings = new ArrayList<>(); if (StrUtil.isBlank(row.getCode())) { blockers.add(issue("ROW_CODE_REQUIRED", "code", "物料编码不能为空", false)); } else if (duplicateCodes.contains(row.getCode().trim())) { blockers.add(issue("DUPLICATE_CODE_IN_FILE", "code", "文件内物料编码重复:" + row.getCode().trim(), false)); } if (StrUtil.isBlank(row.getName())) { blockers.add(issue("ROW_NAME_REQUIRED", "name", "物料名称不能为空", false)); } if (StrUtil.isNotBlank(row.getCategoryCode()) && !context.categoryCodeIdMap().containsKey(row.getCategoryCode())) { blockers.add(issue("CATEGORY_NOT_FOUND", "categoryCode", "物料分类编码不存在:" + row.getCategoryCode(), false)); } if (StrUtil.isNotBlank(row.getUnitMeasureCode()) && !context.unitCodeIdMap().containsKey(row.getUnitMeasureCode())) { blockers.add(issue("UNIT_NOT_FOUND", "unitMeasureCode", "计量单位编码不存在:" + row.getUnitMeasureCode(), false)); } MdmItemRowResolution resolution = itemService.resolveImportRow(excelVO, context); if (!resolution.isSuccess() && blockers.isEmpty()) { // 理论上上面的逐项校验已覆盖;兜底保持与 Excel 导入一致的文案 blockers.add(issue("ROW_INVALID", null, resolution.failureMessage(), false)); } String action; Long targetItemId = null; if (!blockers.isEmpty()) { action = "BLOCKED"; blockedCount++; } else { List existing = itemService.getItemListByCode(row.getCode().trim()); if (!existing.isEmpty()) { action = "SKIP"; targetItemId = existing.get(0).getId(); skipCount++; warnings.add(issue("ITEM_CODE_EXISTS", "code", "物料编码已存在,本阶段只新建不更新,将跳过该行", false)); } else { action = "CREATE"; createCount++; } } rows.add(MdmAgentImportPreflightRespVO.Row.builder() .candidateId(row.getCandidateId()) .rowNo(row.getRowNo()) .code(row.getCode()) .name(row.getName()) .action(action) .resolved(resolve(row, context)) .blockers(blockers) .warnings(warnings) .targetItemId(targetItemId) .build()); } boolean canCommit = blockingItems.isEmpty() && blockedCount == 0; String preflightHash = buildPreflightHash(reqVO, principal, attachment, sourceSha256, syncMesRequested, rows); return MdmAgentImportPreflightRespVO.builder() .preflightHash(preflightHash) .source(MdmAgentImportPreflightRespVO.Source.builder() .attachmentId(attachment.attachmentId()) .sessionId(attachment.sessionId()) .sourceSha256(sourceSha256) .proposalRevision(reqVO.getProposalRevision()) .build()) .authorization(MdmAgentImportPreflightRespVO.Authorization.builder() .userId(principal.userId()) .tenantId(principal.tenantId()) .permission(SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT) .policyRevision(MdmAgentImportPolicy.POLICY_REVISION) .build()) .policy(MdmAgentImportPreflightRespVO.Policy.builder() .updateAllowed(false) .mesSyncAllowed(policy.isAllowMesSync()) .maxRows(policy.getMaxRows()) .build()) .summary(MdmAgentImportPreflightRespVO.Summary.builder() .total(rows.size()) .create(createCount) .skip(skipCount) .blocked(blockedCount) .build()) .canCommit(canCommit) .sideEffects(canCommit ? List.of("mdm_item.create:" + createCount) : List.of()) .rows(rows) .blockingItems(blockingItems) .build(); } private static MdmItemImportExcelVO toExcelVO(MdmAgentImportPreflightReqVO.Row row) { return MdmItemImportExcelVO.builder() .code(row.getCode()) .name(row.getName()) .specification(row.getSpecification()) .categoryCode(row.getCategoryCode()) .unitMeasureCode(row.getUnitMeasureCode()) .build(); } private static MdmAgentImportPreflightRespVO.Resolved resolve(MdmAgentImportPreflightReqVO.Row row, MdmItemImportContext context) { Long categoryId = StrUtil.isBlank(row.getCategoryCode()) ? null : context.categoryCodeIdMap().get(row.getCategoryCode()); String categoryName = StrUtil.isBlank(row.getCategoryCode()) ? null : context.categoryCodeNameMap().get(row.getCategoryCode()); Long unitMeasureId = StrUtil.isBlank(row.getUnitMeasureCode()) ? null : context.unitCodeIdMap().get(row.getUnitMeasureCode()); String unitMeasureName = StrUtil.isBlank(row.getUnitMeasureCode()) ? null : context.unitCodeNameMap().get(row.getUnitMeasureCode()); return MdmAgentImportPreflightRespVO.Resolved.builder() .categoryId(categoryId).categoryName(categoryName) .unitMeasureId(unitMeasureId).unitMeasureName(unitMeasureName) .build(); } private static MdmAgentImportPreflightRespVO.Issue issue(String code, String field, String message, boolean retryable) { return MdmAgentImportPreflightRespVO.Issue.builder() .code(code).field(field).message(message).retryable(retryable).build(); } /** * 按固定字段顺序拼装规范 JSON 后取 SHA-256:同一份计划必须得到同一个 hash。 */ private static String buildPreflightHash(MdmAgentImportPreflightReqVO reqVO, AgentPrincipal principal, SystemAgentAttachmentRef attachment, String sourceSha256, boolean syncMesRequested, List rows) { Map canonical = new LinkedHashMap<>(); canonical.put("policy_revision", MdmAgentImportPolicy.POLICY_REVISION); canonical.put("user_id", principal.userId()); canonical.put("tenant_id", principal.tenantId()); canonical.put("attachment_id", attachment.attachmentId()); canonical.put("session_id", attachment.sessionId()); canonical.put("source_sha256", sourceSha256.toLowerCase(Locale.ROOT)); canonical.put("proposal_revision", StrUtil.blankToDefault(reqVO.getProposalRevision(), "")); canonical.put("sync_mes", syncMesRequested); List> canonicalRows = new ArrayList<>(); for (MdmAgentImportPreflightRespVO.Row row : rows) { Map item = new LinkedHashMap<>(); item.put("candidate_id", row.getCandidateId()); item.put("row_no", row.getRowNo()); item.put("code", row.getCode()); item.put("name", row.getName()); canonicalRows.add(item); } canonical.put("rows", canonicalRows); try { byte[] payload = OBJECT_MAPPER.writeValueAsBytes(canonical); return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 不可用", ex); } catch (com.fasterxml.jackson.core.JsonProcessingException ex) { throw new IllegalStateException("预检摘要生成失败", ex); } } }