From 43aab58c9ecb1203770ba382efbdbae299ca87ec Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期一, 26 十月 2026 15:12:31 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_business' into dev_business

---
 yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java |  372 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 372 insertions(+), 0 deletions(-)

diff --git a/yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java b/yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java
new file mode 100644
index 0000000..173b562
--- /dev/null
+++ b/yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java
@@ -0,0 +1,372 @@
+package cn.iocoder.yudao.module.mdm.service.agent;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.exception.ServiceException;
+import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO;
+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.agent.vo.MdmAgentImportReceiptRespVO;
+import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO;
+import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO;
+import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandDO;
+import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandRowDO;
+import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO;
+import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandMapper;
+import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandRowMapper;
+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 com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+import org.springframework.validation.annotation.Validated;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * 鐗╂枡瀵煎叆骞傜瓑鎻愪氦锛氬悓涓� commandId/idempotencyKey 閲嶅鎻愪氦杩斿洖鍘熷洖鎵э紝缁濅笉閲嶅寤虹墿鏂欍��
+ */
+@Service
+@Validated
+public class MdmAgentImportCommandService {
+
+    public static final String STATUS_RUNNING = "RUNNING";
+    public static final String STATUS_SUCCEEDED = "SUCCEEDED";
+    public static final String STATUS_PARTIAL_FAILED = "PARTIAL_FAILED";
+    public static final String STATUS_FAILED = "FAILED";
+    public static final String STATUS_UNKNOWN = "UNKNOWN";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Duration LEASE_DURATION = Duration.ofMinutes(5);
+
+    @Resource
+    private MdmAgentImportPreflightService preflightService;
+    @Resource
+    private MdmItemService itemService;
+    @Resource
+    private MdmAgentImportPolicy policy;
+    @Resource
+    private MdmAgentImportCommandMapper commandMapper;
+    @Resource
+    private MdmAgentImportCommandRowMapper commandRowMapper;
+    @Resource
+    private PlatformTransactionManager transactionManager;
+
+    public MdmAgentImportReceiptRespVO commit(MdmAgentImportCommitReqVO reqVO, AgentPrincipal principal) {
+        if (!policy.isWriteEnabled()) {
+            throw new AgentBridgeException(HttpStatus.SERVICE_UNAVAILABLE, "WRITE_DISABLED",
+                    "鏅鸿兘浣撳啓鍏ュ紑鍏虫湭鎵撳紑锛屾彁浜ゅ凡鎷掔粷");
+        }
+        if (Boolean.TRUE.equals(reqVO.getSyncMes()) && !policy.isAllowMesSync()) {
+            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "MES_SYNC_DISABLED",
+                    "鏈嶅姟鏈紑鏀惧悓姝� MES锛岃鍏抽棴 syncMes 鍚庨噸璇�");
+        }
+        String requestDigest = buildRequestDigest(reqVO);
+        Long ownerUserId = principal.userId();
+
+        // 1. 骞傜瓑鍛戒腑锛氬懡浠� ID 浼樺厛锛屽叾娆″箓绛夐敭
+        MdmAgentImportCommandDO existing = commandMapper.selectByCommandId(ownerUserId, reqVO.getCommandId());
+        if (existing != null) {
+            if (!existing.getRequestDigest().equals(requestDigest)) {
+                throw new AgentBridgeException(HttpStatus.CONFLICT, "COMMAND_ID_CONFLICT",
+                        "鍚屼竴鍛戒护 ID 鎼哄甫浜嗕笉鍚岀殑璇锋眰鍐呭锛岃妫�鏌ヤ换鍔$姸鎬佸悗閲嶈瘯");
+            }
+            return toReceipt(existing);
+        }
+        existing = commandMapper.selectByIdempotencyKey(ownerUserId, reqVO.getIdempotencyKey());
+        if (existing != null) {
+            if (!existing.getRequestDigest().equals(requestDigest)) {
+                throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT",
+                        "鍚屼竴骞傜瓑閿惡甯︿簡涓嶅悓鐨勮姹傚唴瀹癸紝璇锋鏌ヤ换鍔$姸鎬佸悗閲嶈瘯");
+            }
+            return toReceipt(existing);
+        }
+
+        // 2. 閲嶆柊棰勬骞舵瘮瀵硅鍒掓憳瑕侊紝闃叉纭鍚庢暟鎹凡鍙樺寲
+        MdmAgentImportPreflightRespVO preflight = preflightService.preflight(toPreflightRequest(reqVO), principal);
+        if (!reqVO.getPreflightHash().equalsIgnoreCase(preflight.getPreflightHash())) {
+            throw new AgentBridgeException(HttpStatus.CONFLICT, "PLAN_STALE",
+                    "棰勬璁″垝宸插彉鍖栵紙缂栫爜鐘舵�佹垨涓绘暟鎹鏀瑰姩锛夛紝璇烽噸鏂伴妫�骞剁‘璁�", false,
+                    Map.of("preflight", preflight));
+        }
+        if (!Boolean.TRUE.equals(preflight.getCanCommit())) {
+            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "PREFLIGHT_BLOCKED",
+                    "棰勬瀛樺湪闃绘柇椤癸紝涓嶈兘鎻愪氦", false, Map.of("preflight", preflight));
+        }
+
+        // 3. 鐧昏鍛戒护锛堝敮涓�閿厹浣忓苟鍙戦噸澶嶆彁浜わ級
+        MdmAgentImportCommandDO command = MdmAgentImportCommandDO.builder()
+                .commandId(reqVO.getCommandId())
+                .idempotencyKey(reqVO.getIdempotencyKey())
+                .requestDigest(requestDigest)
+                .ownerUserId(ownerUserId)
+                .sessionId(reqVO.getSessionId())
+                .attachmentId(reqVO.getAttachmentId())
+                .sourceSha256(reqVO.getSourceSha256().toLowerCase(Locale.ROOT))
+                .preflightHash(reqVO.getPreflightHash().toLowerCase(Locale.ROOT))
+                .proposalHash(reqVO.getProposalHash())
+                .syncMes(Boolean.TRUE.equals(reqVO.getSyncMes()))
+                .status(STATUS_RUNNING)
+                .totalCount(reqVO.getRows().size())
+                .successCount(0).failedCount(0).skippedCount(0)
+                .leaseExpiresAt(LocalDateTime.now().plus(LEASE_DURATION))
+                .build();
+        try {
+            commandMapper.insert(command);
+        } catch (DuplicateKeyException ex) {
+            MdmAgentImportCommandDO raced = commandMapper.selectByCommandId(ownerUserId, reqVO.getCommandId());
+            if (raced == null) {
+                raced = commandMapper.selectByIdempotencyKey(ownerUserId, reqVO.getIdempotencyKey());
+            }
+            if (raced == null) {
+                throw ex;
+            }
+            if (!raced.getRequestDigest().equals(requestDigest)) {
+                throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT",
+                        "鍚屼竴鍛戒护/骞傜瓑閿惡甯︿簡涓嶅悓鐨勮姹傚唴瀹癸紝璇锋鏌ヤ换鍔$姸鎬佸悗閲嶈瘯");
+            }
+            return toReceipt(raced);
+        }
+
+        // 4. 閫愯鐙珛浜嬪姟鎵ц锛涗换涓�琛屽け璐ヤ笉褰卞搷鍏朵粬琛�
+        int success = 0;
+        int failed = 0;
+        int skipped = 0;
+        TransactionTemplate rowTxTemplate = new TransactionTemplate(transactionManager);
+        List<MdmItemImportExcelVO> excelRows = reqVO.getRows().stream()
+                .map(MdmAgentImportCommandService::toExcelVO).toList();
+        MdmItemImportContext context = itemService.buildImportContext(excelRows);
+        try {
+            for (int index = 0; index < reqVO.getRows().size(); index++) {
+                MdmAgentImportPreflightReqVO.Row row = reqVO.getRows().get(index);
+                MdmAgentImportPreflightRespVO.Row planned = preflight.getRows().get(index);
+                if ("SKIP".equals(planned.getAction())) {
+                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
+                            .action("SKIP").status("SKIPPED")
+                            .targetItemId(planned.getTargetItemId())
+                            .retryable(false).build());
+                    skipped++;
+                    continue;
+                }
+                if (!"CREATE".equals(planned.getAction())) {
+                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
+                            .action("CREATE").status("FAILED")
+                            .errorCode("PREFLIGHT_BLOCKED").errorMessage("璇ヨ棰勬鏈�氳繃").retryable(false).build());
+                    failed++;
+                    continue;
+                }
+                MdmItemRowResolution resolution = itemService.resolveImportRow(excelRows.get(index), context);
+                if (!resolution.isSuccess()) {
+                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
+                            .action("CREATE").status("FAILED")
+                            .errorCode("ROW_INVALID").errorMessage(trim(resolution.failureMessage(), 512))
+                            .retryable(false).build());
+                    failed++;
+                    continue;
+                }
+                AtomicReference<Throwable> rowError = new AtomicReference<>();
+                boolean[] concurrentSkip = {false};
+                try {
+                    rowTxTemplate.executeWithoutResult(status -> {
+                        try {
+                            // 鎻愪氦鍓嶉噸鏌ワ紝缂╁皬骞跺彂閲嶅寤虹墿鏂欑殑绐楀彛
+                            List<MdmItemDO> current = itemService.getItemListByCode(row.getCode().trim());
+                            if (!current.isEmpty()) {
+                                concurrentSkip[0] = true;
+                                commandRowMapper.insert(baseRow(command.getCommandId(), row)
+                                        .action("SKIP").status("SKIPPED")
+                                        .targetItemId(current.get(0).getId()).retryable(false).build());
+                                return;
+                            }
+                            MdmItemSaveReqVO saveReqVO = resolution.saveReqVO();
+                            saveReqVO.setSyncMes(Boolean.TRUE.equals(reqVO.getSyncMes()));
+                            Long itemId = itemService.createItem(saveReqVO);
+                            commandRowMapper.insert(baseRow(command.getCommandId(), row)
+                                    .action("CREATE").status("SUCCESS").targetItemId(itemId)
+                                    .retryable(false).build());
+                        } catch (Throwable t) {
+                            rowError.set(t);
+                            throw t;
+                        }
+                    });
+                    if (concurrentSkip[0]) {
+                        skipped++;
+                    } else {
+                        success++;
+                    }
+                } catch (Exception ex) {
+                    Throwable cause = rowError.get() != null ? rowError.get() : ex;
+                    String message = cause instanceof ServiceException
+                            ? ((ServiceException) cause).getMessage() : cause.getMessage();
+                    String errorCode = cause instanceof ServiceException ? "ITEM_CREATE_REJECTED" : "ITEM_CREATE_FAILED";
+                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
+                            .action("CREATE").status("FAILED")
+                            .errorCode(errorCode).errorMessage(trim(message, 512))
+                            .retryable(!(cause instanceof ServiceException)).build());
+                    failed++;
+                }
+            }
+        } catch (Exception ex) {
+            // 鍩虹璁炬柦寮傚父涓柇锛氫繚鐣欏凡瀹屾垚琛屽洖鎵э紝鍛戒护鏍囪 UNKNOWN 渚涘璐�
+            command.setStatus(STATUS_UNKNOWN);
+            command.setErrorCode("COMMIT_INTERRUPTED");
+            command.setErrorMessage(trim(ex.getMessage(), 512));
+            command.setSuccessCount(success);
+            command.setFailedCount(failed);
+            command.setSkippedCount(skipped);
+            commandMapper.updateById(command);
+            return toReceipt(command);
+        }
+
+        command.setSuccessCount(success);
+        command.setFailedCount(failed);
+        command.setSkippedCount(skipped);
+        command.setStatus(failed == 0 ? STATUS_SUCCEEDED
+                : (success + skipped == 0 ? STATUS_FAILED : STATUS_PARTIAL_FAILED));
+        command.setLeaseExpiresAt(LocalDateTime.now());
+        commandMapper.updateById(command);
+        return toReceipt(command);
+    }
+
+    public MdmAgentImportReceiptRespVO getCommand(String commandId, AgentPrincipal principal) {
+        MdmAgentImportCommandDO command = commandMapper.selectByCommandId(
+                principal.userId(), commandId);
+        if (command == null) {
+            throw new AgentBridgeException(HttpStatus.NOT_FOUND, "COMMAND_NOT_FOUND", "瀵煎叆鍛戒护涓嶅瓨鍦�");
+        }
+        return toReceipt(command);
+    }
+
+    private MdmAgentImportReceiptRespVO toReceipt(MdmAgentImportCommandDO command) {
+        String status = command.getStatus();
+        if (STATUS_RUNNING.equals(status) && command.getLeaseExpiresAt() != null
+                && command.getLeaseExpiresAt().isBefore(LocalDateTime.now())) {
+            // 绉熺害杩囨湡璇存槑鎵ц杩涚▼鍙兘宸蹭腑鏂紱涓嶆敼搴擄紝浠呭瀹炲憡鐭ヨ皟鐢ㄦ柟缁撴灉鏈煡
+            status = STATUS_UNKNOWN;
+        }
+        List<MdmAgentImportCommandRowDO> rowDOs = commandRowMapper.selectListByCommandId(command.getCommandId());
+        List<MdmAgentImportReceiptRespVO.Row> rows = rowDOs.stream()
+                .map(row -> MdmAgentImportReceiptRespVO.Row.builder()
+                        .candidateId(row.getCandidateId())
+                        .rowNo(row.getSourceRow())
+                        .code(row.getCode())
+                        .name(row.getName())
+                        .action(row.getAction())
+                        .status(row.getStatus())
+                        .targetItemId(row.getTargetItemId())
+                        .targetVersion(row.getTargetVersion())
+                        .errorCode(row.getErrorCode())
+                        .errorMessage(row.getErrorMessage())
+                        .retryable(row.getRetryable())
+                        .build())
+                .toList();
+        int success = (int) rowDOs.stream().filter(row -> "SUCCESS".equals(row.getStatus())).count();
+        int failed = (int) rowDOs.stream().filter(row -> "FAILED".equals(row.getStatus())).count();
+        int skipped = (int) rowDOs.stream().filter(row -> "SKIPPED".equals(row.getStatus())).count();
+        int unknown = (int) rowDOs.stream().filter(row -> "UNKNOWN".equals(row.getStatus())).count();
+        return MdmAgentImportReceiptRespVO.builder()
+                .commandId(command.getCommandId())
+                .idempotencyKey(command.getIdempotencyKey())
+                .status(status)
+                .attachmentId(command.getAttachmentId())
+                .sessionId(command.getSessionId())
+                .sourceSha256(command.getSourceSha256())
+                .preflightHash(command.getPreflightHash())
+                .proposalHash(command.getProposalHash())
+                .summary(MdmAgentImportReceiptRespVO.Summary.builder()
+                        .total(command.getTotalCount())
+                        .success(success).failed(failed).skipped(skipped).unknown(unknown)
+                        .build())
+                .errorCode(command.getErrorCode())
+                .errorMessage(command.getErrorMessage())
+                .rows(rows)
+                .createTime(command.getCreateTime())
+                .completedTime(STATUS_RUNNING.equals(command.getStatus()) ? null : command.getUpdateTime())
+                .build();
+    }
+
+    private static MdmAgentImportCommandRowDO.MdmAgentImportCommandRowDOBuilder baseRow(
+            String commandId, MdmAgentImportPreflightReqVO.Row row) {
+        return MdmAgentImportCommandRowDO.builder()
+                .commandId(commandId)
+                .candidateId(row.getCandidateId())
+                .sourceRow(row.getRowNo())
+                .code(row.getCode())
+                .name(row.getName());
+    }
+
+    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 MdmAgentImportPreflightReqVO toPreflightRequest(MdmAgentImportCommitReqVO reqVO) {
+        MdmAgentImportPreflightReqVO preflightReqVO = new MdmAgentImportPreflightReqVO();
+        preflightReqVO.setAttachmentId(reqVO.getAttachmentId());
+        preflightReqVO.setSessionId(reqVO.getSessionId());
+        preflightReqVO.setSourceSha256(reqVO.getSourceSha256());
+        preflightReqVO.setProposalRevision(reqVO.getProposalRevision());
+        preflightReqVO.setSyncMes(reqVO.getSyncMes());
+        preflightReqVO.setRows(reqVO.getRows());
+        return preflightReqVO;
+    }
+
+    /**
+     * 璇锋眰鎽樿鍙寘鍚笟鍔″唴瀹癸紝涓嶅惈 commandId/idempotencyKey锛屼究浜庤瘑鍒�滃悓閿笉鍚屽唴瀹光�濄��
+     */
+    private static String buildRequestDigest(MdmAgentImportCommitReqVO reqVO) {
+        Map<String, Object> canonical = new LinkedHashMap<>();
+        canonical.put("attachment_id", reqVO.getAttachmentId());
+        canonical.put("session_id", reqVO.getSessionId());
+        canonical.put("source_sha256", StrUtil.nullToEmpty(reqVO.getSourceSha256()).toLowerCase(Locale.ROOT));
+        canonical.put("preflight_hash", StrUtil.nullToEmpty(reqVO.getPreflightHash()).toLowerCase(Locale.ROOT));
+        canonical.put("proposal_hash", StrUtil.nullToEmpty(reqVO.getProposalHash()));
+        canonical.put("proposal_revision", StrUtil.nullToEmpty(reqVO.getProposalRevision()));
+        canonical.put("sync_mes", Boolean.TRUE.equals(reqVO.getSyncMes()));
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (MdmAgentImportPreflightReqVO.Row row : reqVO.getRows()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("candidate_id", row.getCandidateId());
+            item.put("row_no", row.getRowNo());
+            item.put("code", StrUtil.nullToEmpty(row.getCode()));
+            item.put("name", StrUtil.nullToEmpty(row.getName()));
+            item.put("specification", StrUtil.nullToEmpty(row.getSpecification()));
+            item.put("category_code", StrUtil.nullToEmpty(row.getCategoryCode()));
+            item.put("unit_measure_code", StrUtil.nullToEmpty(row.getUnitMeasureCode()));
+            rows.add(item);
+        }
+        canonical.put("rows", rows);
+        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 (JsonProcessingException ex) {
+            throw new IllegalStateException("璇锋眰鎽樿鐢熸垚澶辫触", ex);
+        }
+    }
+
+    private static String trim(String value, int maxLength) {
+        if (value == null) {
+            return null;
+        }
+        return value.length() <= maxLength ? value : value.substring(0, maxLength);
+    }
+}

--
Gitblit v1.9.3