云
16 小时以前 d9a5ba1de2fce6bd8c50dace505e4bf7864043ba
feat(mdm): 新增智能体通用业务导入功能

- 添加了MES同步开关验证和控制逻辑
- 实现了多模块导入权限管理和会话创建
- 引入了通用导入命令表结构存储执行状态
- 支持物料、供应商、BOM等多种业务模块导入
- 提供了幂等性保证和逐行回执机制
- 增加了BOM导入适配器和循环引用检测功能
已修改4个文件
已添加18个文件
1821 ■■■■■ 文件已修改
sql/mysql/agent_import_command_20261022.sql 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/system_agent_data_entry_menu_20261022.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapter.java 277 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapterTest.java 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapter.java 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapterTest.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportCommitReqVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportModuleAdapter.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightReqVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightRespVO.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportReceiptRespVO.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportRowReceipt.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentGenericImportBridgeController.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandDO.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandRowDO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandMapper.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandRowMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/agent/imports/SystemAgentGenericImportService.java 454 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/agent_import_command_20261022.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
-- é€šç”¨ä¸šåŠ¡å¯¼å…¥å¹‚ç­‰å‘½ä»¤ä¸Žé€è¡Œå›žæ‰§ï¼ˆä¾›åº”å•†ã€BOM ç­‰ï¼‰ã€‚执行前备份目标库。
CREATE TABLE IF NOT EXISTS `agent_import_command` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `command_id` varchar(64) NOT NULL COMMENT '智能体提交命令 ID(幂等)',
  `idempotency_key` varchar(128) NOT NULL COMMENT '幂等键',
  `request_digest` char(64) NOT NULL COMMENT '请求摘要 SHA-256',
  `module_id` varchar(64) NOT NULL COMMENT '业务模块 ID,如 srm.supplier',
  `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户 ID',
  `owner_user_id` bigint NOT NULL COMMENT '提交用户 ID',
  `session_id` bigint DEFAULT NULL COMMENT '补录会话 ID',
  `attachment_id` bigint NOT NULL COMMENT '附件 ID',
  `source_sha256` char(64) NOT NULL COMMENT '附件内容摘要',
  `preflight_hash` char(64) NOT NULL COMMENT '预检计划摘要',
  `proposal_hash` varchar(128) DEFAULT NULL COMMENT '智能体方案摘要',
  `status` varchar(20) NOT NULL DEFAULT 'RUNNING' COMMENT 'RUNNING/SUCCEEDED/PARTIAL_FAILED/FAILED/UNKNOWN',
  `total_count` int NOT NULL DEFAULT 0 COMMENT '总行数',
  `success_count` int NOT NULL DEFAULT 0 COMMENT '成功行数',
  `failed_count` int NOT NULL DEFAULT 0 COMMENT '失败行数',
  `skipped_count` int NOT NULL DEFAULT 0 COMMENT '跳过行数',
  `error_code` varchar(64) DEFAULT NULL COMMENT '命令级错误码',
  `error_message` varchar(512) DEFAULT NULL COMMENT '命令级错误信息',
  `lease_expires_at` datetime DEFAULT NULL COMMENT '执行租约到期时间',
  `creator` varchar(64) DEFAULT NULL,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT NULL,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_agent_import_generic_command` (`tenant_id`, `owner_user_id`, `module_id`, `command_id`),
  UNIQUE KEY `uk_agent_import_generic_idempotency` (`tenant_id`, `owner_user_id`, `module_id`, `idempotency_key`),
  KEY `idx_agent_import_generic_module` (`tenant_id`, `module_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体通用业务导入命令';
CREATE TABLE IF NOT EXISTS `agent_import_command_row` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `command_id` varchar(64) NOT NULL COMMENT '提交命令 ID',
  `module_id` varchar(64) NOT NULL COMMENT '业务模块 ID',
  `candidate_id` varchar(64) NOT NULL COMMENT '导入行标识',
  `source_row` int DEFAULT NULL COMMENT '源文件行号',
  `code` varchar(64) DEFAULT NULL COMMENT '业务编码',
  `name` varchar(255) DEFAULT NULL COMMENT '业务名称',
  `action` varchar(16) NOT NULL COMMENT 'CREATE/SKIP',
  `status` varchar(16) NOT NULL COMMENT 'SUCCESS/FAILED/SKIPPED/UNKNOWN',
  `target_item_id` bigint DEFAULT NULL COMMENT '写入后的目标主键',
  `target_version` varchar(64) DEFAULT NULL COMMENT '目标版本(预留)',
  `error_code` varchar(64) DEFAULT NULL COMMENT '行级错误码',
  `error_message` varchar(512) DEFAULT NULL COMMENT '行级错误信息',
  `retryable` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否可重试',
  `creator` varchar(64) DEFAULT NULL,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT NULL,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_agent_import_generic_row` (`command_id`, `candidate_id`),
  KEY `idx_agent_import_generic_row` (`command_id`, `source_row`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体通用业务导入逐行回执';
sql/mysql/system_agent_data_entry_menu_20261022.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
-- æ¨¡å—数据补录菜单补丁,可重复执行。
-- é¡¶çº§ç›®å½•“数据补录” + å­èœå•“模块数据补录”,前端组件 agent/dataEntry/index。
-- ä»…超级管理员自动可见;其他角色请在角色管理中勾选这两个菜单。
INSERT INTO system_menu (id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,create_time,updater,update_time,deleted)
SELECT next_id.id,'数据补录','',1,2,0,'/agent','ep:magic-stick','',NULL,0,b'1',b'0',b'1','1',NOW(),'1',NOW(),b'0'
FROM (SELECT COALESCE(MAX(id),0)+1 AS id FROM system_menu) next_id
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE parent_id=0 AND path='/agent' AND deleted=b'0');
INSERT INTO system_menu (id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,create_time,updater,update_time,deleted)
SELECT next_id.id,'模块数据补录','',2,1,parent.id,'data-entry','ep:chat-dot-round','agent/dataEntry/index','AgentDataEntry',0,b'1',b'0',b'1','1',NOW(),'1',NOW(),b'0'
FROM system_menu parent CROSS JOIN (SELECT COALESCE(MAX(id),0)+1 AS id FROM system_menu) next_id
WHERE parent.parent_id=0 AND parent.path='/agent' AND parent.deleted=b'0'
  AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='agent/dataEntry/index' AND deleted=b'0');
UPDATE system_menu SET status=0,visible=b'1',deleted=b'0',updater='1',update_time=NOW()
WHERE (component='agent/dataEntry/index' OR (parent_id=0 AND path='/agent')) AND deleted<>b'0';
INSERT INTO system_role_menu (role_id,menu_id,creator,create_time,updater,update_time,deleted)
SELECT r.id,m.id,'1',NOW(),'1',NOW(),b'0'
FROM system_role r JOIN system_menu m ON m.deleted=b'0' AND (m.component='agent/dataEntry/index' OR (m.parent_id=0 AND m.path='/agent'))
WHERE r.code='super_admin' AND r.deleted=b'0'
  AND NOT EXISTS (SELECT 1 FROM system_role_menu rm WHERE rm.role_id=r.id AND rm.menu_id=m.id);
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java
@@ -69,6 +69,10 @@
            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 tenantId = principal.tenantId();
        Long ownerUserId = principal.userId();
@@ -189,7 +193,7 @@
                                return;
                            }
                            MdmItemSaveReqVO saveReqVO = resolution.saveReqVO();
                            saveReqVO.setSyncMes(false); // æœ¬é˜¶æ®µå›ºå®šä¸åŒæ­¥ MES
                            saveReqVO.setSyncMes(Boolean.TRUE.equals(reqVO.getSyncMes()));
                            Long itemId = itemService.createItem(saveReqVO);
                            commandRowMapper.insert(baseRow(command.getCommandId(), row)
                                    .action("CREATE").status("SUCCESS").targetItemId(itemId)
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java
@@ -71,9 +71,9 @@
        boolean syncMesRequested = Boolean.TRUE.equals(reqVO.getSyncMes());
        List<MdmAgentImportPreflightRespVO.Issue> blockingItems = new ArrayList<>();
        if (syncMesRequested) {
        if (syncMesRequested && !policy.isAllowMesSync()) {
            blockingItems.add(issue("MES_SYNC_DISABLED", "syncMes",
                    "本阶段不开放同步 MES,请关闭 syncMes åŽé‡è¯•", false));
                    "服务未开放同步 MES,请关闭 syncMes åŽé‡è¯•", false));
        }
        // 4. æ–‡ä»¶å†…重复编码:所有重复行一起阻断,避免“首行建、次行跳”的误导
@@ -182,7 +182,11 @@
                        .blocked(blockedCount)
                        .build())
                .canCommit(canCommit)
                .sideEffects(canCommit ? List.of("mdm_item.create:" + createCount) : List.of())
                .sideEffects(canCommit
                        ? (syncMesRequested
                        ? List.of("mdm_item.create:" + createCount, "mes_md_item.sync:" + createCount)
                        : List.of("mdm_item.create:" + createCount))
                        : List.of())
                .rows(rows)
                .blockingItems(blockingItems)
                .build();
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,277 @@
package cn.iocoder.yudao.module.mes.service.md.item;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.controller.admin.md.item.vo.bom.MesMdProductBomSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdProductBomMapper;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportModuleAdapter;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.service.agent.imports.SystemAgentGenericImportService;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.*;
/**
 * BOM å¯¼å…¥ï¼šçˆ¶/子物料必须已同步到 MES;已存在父子组合跳过,只新建不更新。
 */
@Component
public class MesBomImportAdapter implements AgentImportModuleAdapter {
    public static final String MODULE_ID = "mes.bom";
    private static final String PERMISSION = "mes:md-item:create";
    @Resource
    private MesMdItemService itemService;
    @Resource
    private MesMdProductBomService productBomService;
    @Resource
    private MesMdProductBomMapper productBomMapper;
    @Override
    public String moduleId() {
        return MODULE_ID;
    }
    @Override
    public String permission() {
        return PERMISSION;
    }
    @Override
    public AgentImportPreflightRespVO preflight(AgentPrincipal principal, AgentImportPreflightReqVO request) {
        // 1. çŽ°æœ‰ BOM å›¾ä¸Žçˆ¶å­ç»„合索引,用于跳过与环检测
        Map<Long, Set<Long>> graph = new HashMap<>();
        Map<String, Long> pairs = new HashMap<>();
        for (MesMdProductBomDO bom : productBomMapper.selectAll()) {
            graph.computeIfAbsent(bom.getItemId(), key -> new HashSet<>()).add(bom.getBomItemId());
            pairs.put(pairKey(bom.getItemId(), bom.getBomItemId()), bom.getId());
        }
        Map<String, Optional<MesMdItemDO>> itemCache = new HashMap<>();
        Set<String> filePairs = new HashSet<>();
        List<AgentImportPreflightRespVO.Row> rows = new ArrayList<>();
        List<Map<String, Object>> resolvedPayload = new ArrayList<>();
        int create = 0;
        int skip = 0;
        int blocked = 0;
        for (AgentImportPreflightReqVO.Row row : request.getRows()) {
            List<AgentImportPreflightRespVO.Issue> blockers = new ArrayList<>();
            List<AgentImportPreflightRespVO.Issue> warnings = new ArrayList<>();
            String parentCode = value(row, "parentCode");
            String componentCode = value(row, "componentCode");
            if (parentCode.isEmpty()) {
                blockers.add(issue("PARENT_CODE_REQUIRED", "parentCode", "父项物料编码不能为空"));
            }
            if (componentCode.isEmpty()) {
                blockers.add(issue("COMPONENT_CODE_REQUIRED", "componentCode", "子项物料编码不能为空"));
            }
            BigDecimal quantity = parseQuantity(row.getValues().get("quantity"), blockers);
            Integer status = parseStatus(row.getValues().get("status"), blockers);
            MesMdItemDO parent = parentCode.isEmpty() ? null : lookup(itemCache, parentCode);
            MesMdItemDO component = componentCode.isEmpty() ? null : lookup(itemCache, componentCode);
            if (!parentCode.isEmpty() && parent == null) {
                blockers.add(issue("MES_ITEM_NOT_SYNCED", "parentCode",
                        "父项物料编码 " + parentCode + " åœ¨ MES ç‰©æ–™ä¸­ä¸å­˜åœ¨ï¼Œè¯·å…ˆå¯¼å…¥å¹¶åŒæ­¥ç‰©æ–™"));
            }
            if (!componentCode.isEmpty() && component == null) {
                blockers.add(issue("MES_ITEM_NOT_SYNCED", "componentCode",
                        "子项物料编码 " + componentCode + " åœ¨ MES ç‰©æ–™ä¸­ä¸å­˜åœ¨ï¼Œè¯·å…ˆå¯¼å…¥å¹¶åŒæ­¥ç‰©æ–™"));
            }
            String action;
            Long targetId = null;
            if (!blockers.isEmpty()) {
                action = "BLOCKED";
                blocked++;
            } else {
                String key = pairKey(parent.getId(), component.getId());
                if (parent.getId().equals(component.getId())) {
                    blockers.add(issue("BOM_SELF_REFERENCE", "componentCode", "父项与子项不能是同一物料"));
                    action = "BLOCKED";
                    blocked++;
                } else if (pairs.containsKey(key)) {
                    action = "SKIP";
                    targetId = pairs.get(key);
                    skip++;
                    warnings.add(issue("BOM_EXISTS", "componentCode",
                            "该父子物料组合已存在 BOM,已跳过且不会更新用量"));
                } else if (filePairs.contains(key)) {
                    blockers.add(issue("DUPLICATE_IN_FILE", "componentCode",
                            "文件内父项与子项组合重复,请核实用量版本"));
                    action = "BLOCKED";
                    blocked++;
                } else if (reaches(graph, component.getId(), parent.getId())) {
                    blockers.add(issue("BOM_CYCLE", "componentCode",
                            "新增后 BOM å°†å½¢æˆå¾ªçŽ¯å¼•ç”¨ï¼Œè¯·è°ƒæ•´çˆ¶å­å…³ç³»"));
                    action = "BLOCKED";
                    blocked++;
                } else {
                    action = "CREATE";
                    create++;
                    filePairs.add(key);
                    graph.computeIfAbsent(parent.getId(), item -> new HashSet<>()).add(component.getId());
                }
            }
            Map<String, Object> resolved = new LinkedHashMap<>();
            resolved.put("candidate_id", row.getCandidateId());
            resolved.put("parent_item_id", parent == null ? null : parent.getId());
            resolved.put("component_item_id", component == null ? null : component.getId());
            resolved.put("quantity", quantity);
            resolved.put("status", status);
            resolved.put("action", action);
            resolved.put("target_id", targetId);
            resolvedPayload.add(resolved);
            Map<String, Object> resolvedValues = new LinkedHashMap<>();
            resolvedValues.put("parentCode", parentCode);
            resolvedValues.put("componentCode", componentCode);
            resolvedValues.put("quantity", quantity);
            resolvedValues.put("status", status);
            rows.add(AgentImportPreflightRespVO.Row.builder()
                    .candidateId(row.getCandidateId())
                    .rowNo(row.getRowNo())
                    .action(action)
                    .resolved(resolvedValues)
                    .blockers(blockers)
                    .warnings(warnings)
                    .targetId(targetId)
                    .build());
        }
        int total = rows.size();
        return AgentImportPreflightRespVO.builder()
                .moduleId(MODULE_ID)
                .preflightHash(SystemAgentGenericImportService.preflightHash(MODULE_ID, request, resolvedPayload))
                .source(AgentImportPreflightRespVO.Source.builder()
                        .attachmentId(request.getAttachmentId())
                        .sessionId(request.getSessionId())
                        .sourceSha256(request.getSourceSha256())
                        .proposalRevision(request.getProposalRevision())
                        .build())
                .summary(AgentImportPreflightRespVO.Summary.builder()
                        .total(total).create(create).skip(skip).blocked(blocked).build())
                .canCommit(blocked == 0)
                .sideEffects(List.of("创建 BOM çˆ¶å­ç”¨æ–™å…³ç³»", "不更新已存在的 BOM"))
                .rows(rows)
                .blockingItems(List.of())
                .build();
    }
    @Override
    public AgentImportRowReceipt commitRow(AgentPrincipal principal, AgentImportCommitReqVO request,
                                           AgentImportPreflightRespVO.Row planned,
                                           AgentImportPreflightReqVO.Row row) {
        String parentCode = value(row, "parentCode");
        String componentCode = value(row, "componentCode");
        MesMdItemDO parent = itemService.getItemByCode(parentCode);
        MesMdItemDO component = itemService.getItemByCode(componentCode);
        if (parent == null || component == null) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "MES_ITEM_NOT_SYNCED",
                    "物料在 MES ä¸­å·²ä¸å­˜åœ¨ï¼Œè¯·é‡æ–°åŒæ­¥åŽé‡è¯•", true, null);
        }
        BigDecimal quantity = new BigDecimal(value(row, "quantity"));
        if (quantity.signum() <= 0) {
            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_QUANTITY",
                    "用量必须大于 0", false, null);
        }
        MesMdProductBomSaveReqVO saveReqVO = new MesMdProductBomSaveReqVO();
        saveReqVO.setItemId(parent.getId());
        saveReqVO.setBomItemId(component.getId());
        saveReqVO.setQuantity(quantity);
        saveReqVO.setStatus(parseStatus(row.getValues().get("status"), null));
        saveReqVO.setRemark(StrUtil.blankToDefault(row.getValues().get("remark"), null));
        Long id = productBomService.createProductBom(saveReqVO);
        return AgentImportRowReceipt.created(id, parentCode, componentCode);
    }
    private MesMdItemDO lookup(Map<String, Optional<MesMdItemDO>> cache, String code) {
        return cache.computeIfAbsent(code,
                key -> Optional.ofNullable(itemService.getItemByCode(key))).orElse(null);
    }
    /** æ²¿å·²æœ‰ï¼ˆå«æœ¬æ¬¡å·²æŽ¥å—的)父子边从 start å‡ºå‘,判断能否到达 target。 */
    private static boolean reaches(Map<Long, Set<Long>> graph, Long start, Long target) {
        Deque<Long> stack = new ArrayDeque<>();
        Set<Long> visited = new HashSet<>();
        stack.push(start);
        while (!stack.isEmpty()) {
            Long node = stack.pop();
            if (!visited.add(node)) {
                continue;
            }
            if (node.equals(target)) {
                return true;
            }
            for (Long next : graph.getOrDefault(node, Set.of())) {
                stack.push(next);
            }
        }
        return false;
    }
    private static String pairKey(Long parentId, Long componentId) {
        return parentId + ":" + componentId;
    }
    private static AgentImportPreflightRespVO.Issue issue(String code, String field, String message) {
        return AgentImportPreflightRespVO.Issue.builder()
                .code(code).field(field).message(message).retryable(false).build();
    }
    private static String value(AgentImportPreflightReqVO.Row row, String key) {
        String raw = row.getValues() == null ? null : row.getValues().get(key);
        return raw == null ? "" : raw.trim();
    }
    private static BigDecimal parseQuantity(String raw, List<AgentImportPreflightRespVO.Issue> blockers) {
        if (StrUtil.isBlank(raw)) {
            if (blockers != null) {
                blockers.add(issue("INVALID_QUANTITY", "quantity", "用量必须是大于 0 çš„æ•°å­—"));
            }
            return null;
        }
        try {
            BigDecimal quantity = new BigDecimal(raw.trim());
            if (quantity.signum() <= 0) {
                if (blockers != null) {
                    blockers.add(issue("INVALID_QUANTITY", "quantity", "用量必须大于 0"));
                }
                return quantity;
            }
            return quantity;
        } catch (NumberFormatException ex) {
            if (blockers != null) {
                blockers.add(issue("INVALID_QUANTITY", "quantity", "用量必须是大于 0 çš„æ•°å­—"));
            }
            return null;
        }
    }
    private static Integer parseStatus(String raw, List<AgentImportPreflightRespVO.Issue> blockers) {
        if (StrUtil.isBlank(raw)) {
            return 0;
        }
        String value = raw.trim();
        if ("0".equals(value) || "1".equals(value)) {
            return Integer.valueOf(value);
        }
        if (blockers != null) {
            blockers.add(issue("INVALID_STATUS", "status", "状态只能是 0(启用)或 1(禁用)"));
        }
        return 0;
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapterTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
package cn.iocoder.yudao.module.mes.service.md.item;
import cn.iocoder.yudao.module.mes.controller.admin.md.item.vo.bom.MesMdProductBomSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdProductBomMapper;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class MesBomImportAdapterTest {
    private static final AgentPrincipal PRINCIPAL = new AgentPrincipal(1L, 1L, "live");
    private MesBomImportAdapter adapter;
    private MesMdItemService itemService;
    private MesMdProductBomService productBomService;
    private MesMdProductBomMapper productBomMapper;
    @BeforeEach
    void setUp() {
        adapter = new MesBomImportAdapter();
        itemService = mock(MesMdItemService.class);
        productBomService = mock(MesMdProductBomService.class);
        productBomMapper = mock(MesMdProductBomMapper.class);
        ReflectionTestUtils.setField(adapter, "itemService", itemService);
        ReflectionTestUtils.setField(adapter, "productBomService", productBomService);
        ReflectionTestUtils.setField(adapter, "productBomMapper", productBomMapper);
        when(itemService.getItemByCode("P")).thenReturn(item(1L, "P"));
        when(itemService.getItemByCode("C")).thenReturn(item(2L, "C"));
        when(itemService.getItemByCode("A")).thenReturn(item(3L, "A"));
        when(itemService.getItemByCode("X")).thenReturn(null);
        when(productBomMapper.selectAll()).thenReturn(List.of());
    }
    @Test
    void preflightCreatesWhenItemsAreSynced() {
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "C", "2")));
        assertEquals("CREATE", plan.getRows().get(0).getAction());
        assertEquals(1, plan.getSummary().getCreate());
        assertTrue(plan.getCanCommit());
        assertNotNull(plan.getPreflightHash());
    }
    @Test
    void existingCombinationIsSkippedWithWarning() {
        when(productBomMapper.selectAll()).thenReturn(List.of(bom(50L, 1L, 2L)));
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "C", "2")));
        assertEquals("SKIP", plan.getRows().get(0).getAction());
        assertEquals(50L, plan.getRows().get(0).getTargetId());
        assertEquals(1, plan.getSummary().getSkip());
        assertTrue(plan.getRows().get(0).getWarnings().stream()
                .anyMatch(item -> "BOM_EXISTS".equals(item.getCode())));
    }
    @Test
    void unsyncedComponentBlocksRow() {
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "X", "1")));
        assertEquals("BLOCKED", plan.getRows().get(0).getAction());
        assertTrue(plan.getRows().get(0).getBlockers().stream()
                .anyMatch(item -> "MES_ITEM_NOT_SYNCED".equals(item.getCode())));
        assertFalse(plan.getCanCommit());
    }
    @Test
    void selfReferenceBlocksRow() {
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "P", "1")));
        assertEquals("BLOCKED", plan.getRows().get(0).getAction());
        assertTrue(plan.getRows().get(0).getBlockers().stream()
                .anyMatch(item -> "BOM_SELF_REFERENCE".equals(item.getCode())));
    }
    @Test
    void duplicateCombinationInFileBlocksSecondRow() {
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(
                row("r1", "P", "C", "1"),
                row("r2", "P", "C", "2")));
        assertEquals("CREATE", plan.getRows().get(0).getAction());
        assertEquals("BLOCKED", plan.getRows().get(1).getAction());
        assertTrue(plan.getRows().get(1).getBlockers().stream()
                .anyMatch(item -> "DUPLICATE_IN_FILE".equals(item.getCode())));
    }
    @Test
    void cycleAgainstExistingGraphBlocksRow() {
        when(productBomMapper.selectAll()).thenReturn(List.of(bom(60L, 3L, 1L)));
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "A", "1")));
        assertEquals("BLOCKED", plan.getRows().get(0).getAction());
        assertTrue(plan.getRows().get(0).getBlockers().stream()
                .anyMatch(item -> "BOM_CYCLE".equals(item.getCode())));
    }
    @Test
    void invalidQuantityBlocksRow() {
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "C", "0")));
        assertEquals("BLOCKED", plan.getRows().get(0).getAction());
        assertTrue(plan.getRows().get(0).getBlockers().stream()
                .anyMatch(item -> "INVALID_QUANTITY".equals(item.getCode())));
    }
    @Test
    void commitCreatesBomWithResolvedItemIds() {
        when(productBomService.createProductBom(any())).thenReturn(88L);
        AgentImportRowReceipt receipt = adapter.commitRow(PRINCIPAL, new AgentImportCommitReqVO(),
                AgentImportPreflightRespVO.Row.builder().candidateId("r1").action("CREATE").build(),
                row("r1", "P", "C", "2"));
        assertEquals(88L, receipt.targetId());
        assertEquals("P", receipt.code());
        verify(productBomService).createProductBom(any(MesMdProductBomSaveReqVO.class));
    }
    private static MesMdItemDO item(Long id, String code) {
        MesMdItemDO item = new MesMdItemDO();
        item.setId(id);
        item.setCode(code);
        return item;
    }
    private static MesMdProductBomDO bom(Long id, Long itemId, Long bomItemId) {
        MesMdProductBomDO bom = new MesMdProductBomDO();
        bom.setId(id);
        bom.setItemId(itemId);
        bom.setBomItemId(bomItemId);
        bom.setQuantity(BigDecimal.ONE);
        return bom;
    }
    private static AgentImportPreflightReqVO request(AgentImportPreflightReqVO.Row... rows) {
        AgentImportPreflightReqVO request = new AgentImportPreflightReqVO();
        request.setAttachmentId(1L);
        request.setSourceSha256("a".repeat(64));
        request.setProposalRevision("0");
        request.setRows(List.of(rows));
        return request;
    }
    private static AgentImportPreflightReqVO.Row row(String candidateId, String parent, String component,
                                                      String quantity) {
        Map<String, String> values = new LinkedHashMap<>();
        values.put("parentCode", parent);
        values.put("componentCode", component);
        values.put("quantity", quantity);
        AgentImportPreflightReqVO.Row row = new AgentImportPreflightReqVO.Row();
        row.setCandidateId(candidateId);
        row.setRowNo(1);
        row.setValues(values);
        return row;
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,217 @@
package cn.iocoder.yudao.module.srm.service.supplier;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.srm.controller.admin.supplier.vo.SrmSupplierSaveReqVO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierDO;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierMapper;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportModuleAdapter;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.service.agent.imports.SystemAgentGenericImportService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.*;
/**
 * ä¾›åº”商档案导入:已存在编码跳过并给警告,只新建不更新。
 */
@Component
public class SrmSupplierImportAdapter implements AgentImportModuleAdapter {
    public static final String MODULE_ID = "srm.supplier";
    private static final String PERMISSION = "srm:supplier:create";
    @Resource
    private SrmSupplierService supplierService;
    @Resource
    private SrmSupplierMapper supplierMapper;
    @Override
    public String moduleId() {
        return MODULE_ID;
    }
    @Override
    public String permission() {
        return PERMISSION;
    }
    @Override
    public AgentImportPreflightRespVO preflight(AgentPrincipal principal, AgentImportPreflightReqVO request) {
        // 1. å…ˆç»Ÿè®¡æ–‡ä»¶å†…重复编码,全部标为阻断,避免替用户选版本
        Map<String, Integer> codeCounts = new HashMap<>();
        for (AgentImportPreflightReqVO.Row row : request.getRows()) {
            String code = value(row, "code");
            if (!code.isEmpty()) {
                codeCounts.merge(code, 1, Integer::sum);
            }
        }
        List<AgentImportPreflightRespVO.Row> rows = new ArrayList<>();
        List<Map<String, Object>> resolvedPayload = new ArrayList<>();
        int create = 0;
        int skip = 0;
        int blocked = 0;
        for (AgentImportPreflightReqVO.Row row : request.getRows()) {
            List<AgentImportPreflightRespVO.Issue> blockers = new ArrayList<>();
            List<AgentImportPreflightRespVO.Issue> warnings = new ArrayList<>();
            String code = value(row, "code");
            String name = value(row, "name");
            if (code.isEmpty()) {
                blockers.add(issue("CODE_REQUIRED", "code", "供应商编码不能为空"));
            }
            if (name.isEmpty()) {
                blockers.add(issue("NAME_REQUIRED", "name", "供应商名称不能为空"));
            }
            if (!code.isEmpty() && codeCounts.getOrDefault(code, 0) > 1) {
                blockers.add(issue("DUPLICATE_CODE_IN_FILE", "code",
                        "文件内存在重复的供应商编码:" + code));
            }
            Integer status = parseStatus(row.getValues().get("status"), blockers);
            BigDecimal registeredCapital = parseDecimal(row.getValues().get("registeredCapital"), blockers);
            Long targetId = null;
            String action;
            SrmSupplierDO existing = code.isEmpty() ? null : supplierMapper.selectByCode(code);
            if (!blockers.isEmpty()) {
                action = "BLOCKED";
                blocked++;
            } else if (existing != null) {
                action = "SKIP";
                targetId = existing.getId();
                skip++;
                warnings.add(issue("SUPPLIER_EXISTS", "code",
                        "供应商编码已存在,已跳过且不会更新现有档案"));
            } else {
                action = "CREATE";
                create++;
            }
            Map<String, Object> resolved = new LinkedHashMap<>();
            resolved.put("candidate_id", row.getCandidateId());
            resolved.put("code", code);
            resolved.put("name", name);
            resolved.put("status", status);
            resolved.put("action", action);
            resolved.put("target_id", targetId);
            resolvedPayload.add(resolved);
            Map<String, Object> resolvedValues = new LinkedHashMap<>();
            resolvedValues.put("code", code);
            resolvedValues.put("name", name);
            resolvedValues.put("status", status);
            resolvedValues.put("registeredCapital", registeredCapital);
            rows.add(AgentImportPreflightRespVO.Row.builder()
                    .candidateId(row.getCandidateId())
                    .rowNo(row.getRowNo())
                    .action(action)
                    .resolved(resolvedValues)
                    .blockers(blockers)
                    .warnings(warnings)
                    .targetId(targetId)
                    .build());
        }
        int total = rows.size();
        return AgentImportPreflightRespVO.builder()
                .moduleId(MODULE_ID)
                .preflightHash(SystemAgentGenericImportService.preflightHash(MODULE_ID, request, resolvedPayload))
                .source(source(request))
                .summary(AgentImportPreflightRespVO.Summary.builder()
                        .total(total).create(create).skip(skip).blocked(blocked).build())
                .canCommit(blocked == 0)
                .sideEffects(List.of("创建供应商档案", "不更新已存在的供应商"))
                .rows(rows)
                .blockingItems(List.of())
                .build();
    }
    @Override
    public AgentImportRowReceipt commitRow(AgentPrincipal principal, AgentImportCommitReqVO request,
                                           AgentImportPreflightRespVO.Row planned,
                                           AgentImportPreflightReqVO.Row row) {
        Map<String, String> values = row.getValues();
        SrmSupplierSaveReqVO saveReqVO = new SrmSupplierSaveReqVO();
        saveReqVO.setCode(value(row, "code"));
        saveReqVO.setName(value(row, "name"));
        saveReqVO.setShortName(blankToNull(values.get("shortName")));
        saveReqVO.setType(blankToNull(values.get("type")));
        saveReqVO.setCompanyNature(blankToNull(values.get("companyNature")));
        saveReqVO.setRegisteredCapital(decimalOrNull(values.get("registeredCapital")));
        saveReqVO.setBusinessLicenseNo(blankToNull(values.get("businessLicenseNo")));
        saveReqVO.setTaxNo(blankToNull(values.get("taxNo")));
        saveReqVO.setLegalPerson(blankToNull(values.get("legalPerson")));
        saveReqVO.setAddress(blankToNull(values.get("address")));
        saveReqVO.setWebsite(blankToNull(values.get("website")));
        saveReqVO.setContactName(blankToNull(values.get("contactName")));
        saveReqVO.setContactMobile(blankToNull(values.get("contactMobile")));
        saveReqVO.setContactEmail(blankToNull(values.get("contactEmail")));
        saveReqVO.setBankName(blankToNull(values.get("bankName")));
        saveReqVO.setBankAccount(blankToNull(values.get("bankAccount")));
        saveReqVO.setStatus(parseStatus(values.get("status"), null));
        saveReqVO.setRemark(blankToNull(values.get("remark")));
        Long id = supplierService.createSupplier(saveReqVO);
        return AgentImportRowReceipt.created(id, saveReqVO.getCode(), saveReqVO.getName());
    }
    private static AgentImportPreflightRespVO.Source source(AgentImportPreflightReqVO request) {
        return AgentImportPreflightRespVO.Source.builder()
                .attachmentId(request.getAttachmentId())
                .sessionId(request.getSessionId())
                .sourceSha256(request.getSourceSha256())
                .proposalRevision(request.getProposalRevision())
                .build();
    }
    private static AgentImportPreflightRespVO.Issue issue(String code, String field, String message) {
        return AgentImportPreflightRespVO.Issue.builder()
                .code(code).field(field).message(message).retryable(false).build();
    }
    private static String value(AgentImportPreflightReqVO.Row row, String key) {
        String raw = row.getValues() == null ? null : row.getValues().get(key);
        return raw == null ? "" : raw.trim();
    }
    private static Integer parseStatus(String raw, List<AgentImportPreflightRespVO.Issue> blockers) {
        if (StrUtil.isBlank(raw)) {
            return 0;
        }
        String value = raw.trim();
        if ("0".equals(value) || "1".equals(value)) {
            return Integer.valueOf(value);
        }
        if (blockers != null) {
            blockers.add(issue("INVALID_STATUS", "status", "状态只能是 0(启用)或 1(禁用)"));
        }
        return 0;
    }
    private static BigDecimal parseDecimal(String raw, List<AgentImportPreflightRespVO.Issue> blockers) {
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        try {
            return new BigDecimal(raw.trim());
        } catch (NumberFormatException ex) {
            if (blockers != null) {
                blockers.add(issue("INVALID_DECIMAL", "registeredCapital", "注册资金必须是数字"));
            }
            return null;
        }
    }
    private static BigDecimal decimalOrNull(String raw) {
        return StrUtil.isBlank(raw) ? null : new BigDecimal(raw.trim());
    }
    private static String blankToNull(String raw) {
        return StrUtil.isBlank(raw) ? null : raw.trim();
    }
}
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapterTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
package cn.iocoder.yudao.module.srm.service.supplier;
import cn.iocoder.yudao.module.srm.controller.admin.supplier.vo.SrmSupplierSaveReqVO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierDO;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierMapper;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class SrmSupplierImportAdapterTest {
    private static final AgentPrincipal PRINCIPAL = new AgentPrincipal(1L, 1L, "live");
    private SrmSupplierImportAdapter adapter;
    private SrmSupplierService supplierService;
    private SrmSupplierMapper supplierMapper;
    @BeforeEach
    void setUp() {
        adapter = new SrmSupplierImportAdapter();
        supplierService = mock(SrmSupplierService.class);
        supplierMapper = mock(SrmSupplierMapper.class);
        ReflectionTestUtils.setField(adapter, "supplierService", supplierService);
        ReflectionTestUtils.setField(adapter, "supplierMapper", supplierMapper);
    }
    @Test
    void preflightMarksCreateSkipAndBlockedRows() {
        SrmSupplierDO existing = new SrmSupplierDO();
        existing.setId(7L);
        when(supplierMapper.selectByCode("SUP-OLD")).thenReturn(existing);
        when(supplierMapper.selectByCode("SUP-NEW")).thenReturn(null);
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(
                row("r1", "SUP-NEW", "新供应商"),
                row("r2", "SUP-OLD", "旧供应商"),
                row("r3", "", "缺编码")));
        assertEquals("CREATE", plan.getRows().get(0).getAction());
        assertEquals("SKIP", plan.getRows().get(1).getAction());
        assertEquals(7L, plan.getRows().get(1).getTargetId());
        assertEquals("BLOCKED", plan.getRows().get(2).getAction());
        assertEquals(1, plan.getSummary().getCreate());
        assertEquals(1, plan.getSummary().getSkip());
        assertEquals(1, plan.getSummary().getBlocked());
        assertFalse(plan.getCanCommit());
        assertNotNull(plan.getPreflightHash());
        assertTrue(plan.getRows().get(1).getWarnings().stream()
                .anyMatch(item -> "SUPPLIER_EXISTS".equals(item.getCode())));
    }
    @Test
    void duplicateCodeInFileBlocksAllOccurrences() {
        when(supplierMapper.selectByCode(any())).thenReturn(null);
        AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(
                row("r1", "SUP-DUP", "供应商A"),
                row("r2", "SUP-DUP", "供应商B")));
        assertEquals("BLOCKED", plan.getRows().get(0).getAction());
        assertEquals("BLOCKED", plan.getRows().get(1).getAction());
        assertEquals(2, plan.getSummary().getBlocked());
        assertFalse(plan.getCanCommit());
    }
    @Test
    void preflightHashIsStableForSameInput() {
        when(supplierMapper.selectByCode(any())).thenReturn(null);
        AgentImportPreflightReqVO request = request(row("r1", "SUP-NEW", "新供应商"));
        assertEquals(adapter.preflight(PRINCIPAL, request).getPreflightHash(),
                adapter.preflight(PRINCIPAL, request).getPreflightHash());
    }
    @Test
    void commitCreatesSupplierAndReturnsReceipt() {
        when(supplierService.createSupplier(any())).thenReturn(99L);
        AgentImportPreflightReqVO.Row row = row("r1", "SUP-NEW", "新供应商");
        AgentImportRowReceipt receipt = adapter.commitRow(PRINCIPAL, new AgentImportCommitReqVO(),
                AgentImportPreflightRespVO.Row.builder().candidateId("r1").action("CREATE").build(), row);
        assertEquals("CREATE", receipt.action());
        assertEquals(99L, receipt.targetId());
        assertEquals("SUP-NEW", receipt.code());
        verify(supplierService).createSupplier(any(SrmSupplierSaveReqVO.class));
    }
    private static AgentImportPreflightReqVO request(AgentImportPreflightReqVO.Row... rows) {
        AgentImportPreflightReqVO request = new AgentImportPreflightReqVO();
        request.setAttachmentId(1L);
        request.setSourceSha256("a".repeat(64));
        request.setProposalRevision("0");
        request.setRows(List.of(rows));
        return request;
    }
    private static AgentImportPreflightReqVO.Row row(String candidateId, String code, String name) {
        Map<String, String> values = new LinkedHashMap<>();
        values.put("code", code);
        values.put("name", name);
        AgentImportPreflightReqVO.Row row = new AgentImportPreflightReqVO.Row();
        row.setCandidateId(candidateId);
        row.setRowNo(1);
        row.setValues(values);
        return row;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportCommitReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.system.api.agent.imports;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥æäº¤è¯·æ±‚ï¼šåœ¨é¢„æ£€è¯·æ±‚ä¸Šè¿½åŠ å¹‚ç­‰ä¸Žè®¡åˆ’æ‘˜è¦ã€‚
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class AgentImportCommitReqVO extends AgentImportPreflightReqVO {
    @NotBlank(message = "命令编号不能为空")
    private String commandId;
    @NotBlank(message = "幂等键不能为空")
    private String idempotencyKey;
    @NotBlank(message = "预检摘要不能为空")
    private String preflightHash;
    private String proposalHash;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportModuleAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.system.api.agent.imports;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
/**
 * ä¸šåŠ¡æ¨¡å—å¯¼å…¥é€‚é…å™¨ï¼šsystem æ¨¡å—负责幂等、逐行事务与回执,适配器只负责单行校验和写入。
 */
public interface AgentImportModuleAdapter {
    /** ä¸Ž Python ä¾§ ModuleSpec.module_id å¯¹é½ï¼Œå¦‚ srm.supplier。 */
    String moduleId();
    /** æäº¤è¯¥æ¨¡å—所需的业务权限。 */
    String permission();
    /** åªè¯»é¢„检;必须返回稳定的 preflightHash ä¾›æäº¤æ—¶æ¯”对。 */
    AgentImportPreflightRespVO preflight(AgentPrincipal principal, AgentImportPreflightReqVO request);
    /** å•行写入;失败时抛出异常,由编排层写入 FAILED å›žæ‰§ã€‚ */
    AgentImportRowReceipt commitRow(AgentPrincipal principal, AgentImportCommitReqVO request,
                                    AgentImportPreflightRespVO.Row planned,
                                    AgentImportPreflightReqVO.Row row);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.system.api.agent.imports;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥é¢„æ£€è¯·æ±‚ã€‚åªè¯»ï¼Œä¸å†™ä¸šåŠ¡è¡¨ã€‚
 */
@Data
public class AgentImportPreflightReqVO {
    @NotNull(message = "附件编号不能为空")
    private Long attachmentId;
    private Long sessionId;
    @NotBlank(message = "附件摘要不能为空")
    private String sourceSha256;
    private String proposalRevision;
    /** æ¨¡å—级选项,如 syncMes;未识别的键由适配器忽略。 */
    private Map<String, Object> options;
    @NotEmpty(message = "导入行不能为空")
    @Valid
    private List<Row> rows;
    @Data
    public static class Row {
        @NotBlank(message = "行标识不能为空")
        private String candidateId;
        private Integer rowNo;
        @NotEmpty(message = "行数据不能为空")
        private Map<String, String> values;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package cn.iocoder.yudao.module.system.api.agent.imports;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥é¢„æ£€å“åº”ï¼Œå­—æ®µä¸Ž Python ç«¯ GenericPreflightResponse å¯¹é½ã€‚
 */
@Data
@Builder
public class AgentImportPreflightRespVO {
    private String moduleId;
    private String preflightHash;
    private Source source;
    private Map<String, Object> policy;
    private Summary summary;
    private Boolean canCommit;
    private List<String> sideEffects;
    private List<Row> rows;
    private List<Issue> blockingItems;
    @Data
    @Builder
    public static class Source {
        private Long attachmentId;
        private Long sessionId;
        private String sourceSha256;
        private String proposalRevision;
    }
    @Data
    @Builder
    public static class Summary {
        private Integer total;
        private Integer create;
        private Integer skip;
        private Integer blocked;
    }
    @Data
    @Builder
    public static class Row {
        private String candidateId;
        private Integer rowNo;
        /** CREATE / SKIP / BLOCKED */
        private String action;
        private Map<String, Object> resolved;
        private List<Issue> blockers;
        private List<Issue> warnings;
        private Long targetId;
    }
    @Data
    @Builder
    public static class Issue {
        private String code;
        private String field;
        private String message;
        private Boolean retryable;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportReceiptRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.system.api.agent.imports;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥å›žæ‰§ï¼Œå­—æ®µä¸Ž Python ç«¯ CommitReceipt å¯¹é½ã€‚
 */
@Data
@Builder
public class AgentImportReceiptRespVO {
    private String commandId;
    private String idempotencyKey;
    private String status;
    private Long attachmentId;
    private Long sessionId;
    private String sourceSha256;
    private String preflightHash;
    private String proposalHash;
    private Summary summary;
    private String errorCode;
    private String errorMessage;
    private List<Row> rows;
    private LocalDateTime createTime;
    private LocalDateTime completedTime;
    @Data
    @Builder
    public static class Summary {
        private Integer total;
        private Integer success;
        private Integer failed;
        private Integer skipped;
        private Integer unknown;
    }
    @Data
    @Builder
    public static class Row {
        private String candidateId;
        private Integer rowNo;
        private String code;
        private String name;
        private String action;
        /** SUCCESS / FAILED / SKIPPED / UNKNOWN */
        private String status;
        private Long targetItemId;
        private String targetVersion;
        private String errorCode;
        private String errorMessage;
        private Boolean retryable;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportRowReceipt.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.system.api.agent.imports;
/**
 * å•行写入成功后的最小回执。
 */
public record AgentImportRowReceipt(String action, Long targetId, String targetVersion,
                                    String code, String name) {
    public static AgentImportRowReceipt created(Long targetId, String code, String name) {
        return new AgentImportRowReceipt("CREATE", targetId, null, code, name);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentGenericImportBridgeController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
package cn.iocoder.yudao.module.system.controller.admin.agent;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO;
import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportReceiptRespVO;
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.agent.imports.SystemAgentGenericImportService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥æ¡¥æŽ¥ï¼šé¢„æ£€ã€æäº¤ä¸Žå›žæ‰§ã€‚
 * å…·ä½“业务规则由各模块的 {@code AgentImportModuleAdapter} å®žçŽ°ï¼Œæœ¬ç«¯åªåšé‰´æƒä¸Žç¼–æŽ’å…¥å£ã€‚
 */
@RestController
@RequestMapping("/agent/bridge/imports")
public class SystemAgentGenericImportBridgeController {
    @Resource
    private SystemAgentGenericImportService genericImportService;
    @Resource
    private SystemAgentBridgeGuard bridgeGuard;
    @GetMapping
    public CommonResult<List<Map<String, String>>> modules(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken) {
        bridgeGuard.requireAny(serviceToken);
        return success(genericImportService.modules());
    }
    @PostMapping("/{moduleId}/preflight")
    public CommonResult<AgentImportPreflightRespVO> preflight(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @PathVariable String moduleId,
            @Valid @RequestBody AgentImportPreflightReqVO request) {
        AgentPrincipal principal = bridgeGuard.requireAny(serviceToken);
        return success(genericImportService.preflight(moduleId, request, principal));
    }
    @PostMapping("/{moduleId}/commit")
    public CommonResult<AgentImportReceiptRespVO> commit(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @PathVariable String moduleId,
            @Valid @RequestBody AgentImportCommitReqVO request) {
        AgentPrincipal principal = bridgeGuard.requireAny(serviceToken);
        return success(genericImportService.commit(moduleId, request, principal));
    }
    @GetMapping("/{moduleId}/commands/{commandId}")
    public CommonResult<AgentImportReceiptRespVO> getCommand(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @PathVariable String moduleId,
            @PathVariable String commandId) {
        AgentPrincipal principal = bridgeGuard.requireAny(serviceToken);
        return success(genericImportService.getCommand(moduleId, commandId, principal));
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java
@@ -31,33 +31,56 @@
    private static final Set<String> ACTIONS = Set.of("material_import.prepare", "material_import.read",
            "material_import.review", "material_import.preflight", "material_import.commit",
            "material_import.read_receipt");
            "material_import.read_receipt",
            "conversation.create", "conversation.read", "conversation.message",
            "conversation.action", "conversation.report");
    /** module_id â†’ ä¸šåŠ¡å¯¼å…¥æƒé™ï¼›Python ä»¥æ¨¡å— ID ä½œä¸ºæŽˆæƒ resource。 */
    private static final Map<String, String> MODULE_PERMISSIONS = Map.of(
            "mdm.item", SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT,
            "srm.supplier", "srm:supplier:create",
            "mes.bom", "mes:md-item:create",
            "mes.qc-template", "mes:qc-template:create",
            "mes.mps", "mes:pro-mps:create",
            "mes.route", "mes:pro-route:create");
    @Resource
    private SystemAgentImportBridgeService bridgeService;
    @Resource
    private SystemAgentBridgeGuard bridgeGuard;
    private static final String IMPORT_PERMISSIONS_EXPR =
            "@ss.hasAnyPermissions('mdm:item:import','srm:supplier:create','mes:md-item:create',"
                    + "'mes:qc-template:create','mes:pro-mps:create','mes:pro-route:create')";
    @PostMapping("/import-sessions")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    @PreAuthorize(IMPORT_PERMISSIONS_EXPR)
    public CommonResult<Long> createSession() {
        return success(bridgeService.createSession());
    }
    @PostMapping("/import-sessions/{sessionId}/attachments")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    @PreAuthorize(IMPORT_PERMISSIONS_EXPR)
    public CommonResult<Long> bindAttachment(@PathVariable Long sessionId, @Valid @RequestBody BindRequest request) {
        return success(bridgeService.bindAttachment(sessionId, request.getBlobId()));
    }
    @PostMapping("/authorize")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    @PreAuthorize(IMPORT_PERMISSIONS_EXPR)
    public CommonResult<Map<String, String>> authorize(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @Valid @RequestBody AuthorizeRequest request) {
        AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        if (!ACTIONS.contains(request.getAction())) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ACTION_FORBIDDEN", "不支持的智能体操作");
        }
        AgentPrincipal principal;
        if (request.getAction().startsWith("conversation.")) {
            String permission = MODULE_PERMISSIONS.get(request.getResource());
            principal = permission == null
                    ? bridgeGuard.requireAny(serviceToken)
                    : bridgeGuard.require(serviceToken, permission);
        } else {
            principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        }
        if (requiresAttachmentOwnership(request.getAction())) {
            bridgeService.requireOwnedAttachmentRef(parseId(request.getResource()));
@@ -69,11 +92,11 @@
    }
    @GetMapping("/attachments/{attachmentId}")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    @PreAuthorize(IMPORT_PERMISSIONS_EXPR)
    public ResponseEntity<FileSystemResource> downloadAttachment(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @PathVariable Long attachmentId) {
        bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        bridgeGuard.requireAny(serviceToken);
        SystemAgentAttachmentRef ref = bridgeService.requireOwnedAttachmentRef(attachmentId);
        return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM)
                .contentLength(ref.byteSize())
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.system.dal.dataobject.agent;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥å‘½ä»¤ã€‚åŒä¸€ (tenant, owner, module, commandId/idempotencyKey) åªå…è®¸ä¸€æ¡ã€‚
 */
@TableName("agent_import_command")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgentImportCommandDO extends BaseDO {
    @TableId
    private Long id;
    private String commandId;
    private String idempotencyKey;
    private String requestDigest;
    private String moduleId;
    private Long tenantId;
    private Long ownerUserId;
    private Long sessionId;
    private Long attachmentId;
    private String sourceSha256;
    private String preflightHash;
    private String proposalHash;
    private String status;
    private Integer totalCount;
    private Integer successCount;
    private Integer failedCount;
    private Integer skippedCount;
    private String errorCode;
    private String errorMessage;
    private LocalDateTime leaseExpiresAt;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandRowDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.system.dal.dataobject.agent;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥é€è¡Œå›žæ‰§ã€‚
 */
@TableName("agent_import_command_row")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgentImportCommandRowDO extends BaseDO {
    @TableId
    private Long id;
    private String commandId;
    private String moduleId;
    private String candidateId;
    private Integer sourceRow;
    private String code;
    private String name;
    private String action;
    private String status;
    private Long targetItemId;
    private String targetVersion;
    private String errorCode;
    private String errorMessage;
    private Boolean retryable;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.system.dal.mysql.agent;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentImportCommandMapper extends BaseMapperX<AgentImportCommandDO> {
    default AgentImportCommandDO selectByCommandId(Long tenantId, Long ownerUserId,
                                                   String moduleId, String commandId) {
        return selectOne(new LambdaQueryWrapperX<AgentImportCommandDO>()
                .eq(AgentImportCommandDO::getTenantId, tenantId)
                .eq(AgentImportCommandDO::getOwnerUserId, ownerUserId)
                .eq(AgentImportCommandDO::getModuleId, moduleId)
                .eq(AgentImportCommandDO::getCommandId, commandId));
    }
    default AgentImportCommandDO selectByIdempotencyKey(Long tenantId, Long ownerUserId,
                                                        String moduleId, String idempotencyKey) {
        return selectOne(new LambdaQueryWrapperX<AgentImportCommandDO>()
                .eq(AgentImportCommandDO::getTenantId, tenantId)
                .eq(AgentImportCommandDO::getOwnerUserId, ownerUserId)
                .eq(AgentImportCommandDO::getModuleId, moduleId)
                .eq(AgentImportCommandDO::getIdempotencyKey, idempotencyKey));
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandRowMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.system.dal.mysql.agent;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandRowDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface AgentImportCommandRowMapper extends BaseMapperX<AgentImportCommandRowDO> {
    default List<AgentImportCommandRowDO> selectListByCommandId(String commandId) {
        return selectList(AgentImportCommandRowDO::getCommandId, commandId);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java
@@ -10,12 +10,22 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** æ¡¥æŽ¥ç»Ÿä¸€é‰´æƒï¼šæœåŠ¡ä»¤ç‰Œ + å½“前登录用户 + ä¸šåŠ¡æƒé™ï¼Œç¼ºä¸€ä¸å¯ã€‚ */
@Component
public class SystemAgentBridgeGuard {
    public static final String PERMISSION_ITEM_IMPORT = "mdm:item:import";
    /** å„业务模块的导入权限;会话创建与附件读取只需命中其中任意一项。 */
    public static final List<String> IMPORT_PERMISSIONS = List.of(
            PERMISSION_ITEM_IMPORT,
            "srm:supplier:create",
            "mes:md-item:create",
            "mes:qc-template:create",
            "mes:pro-mps:create",
            "mes:pro-route:create");
    @Value("${yudao.agent.bridge-service-token:}")
    private String bridgeServiceToken;
@@ -40,8 +50,25 @@
        }
        if (!securityFrameworkService.hasPermission(permission)) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN",
                    "当前用户缺少物料导入权限(" + permission + ")");
                    "当前用户缺少数据导入权限(" + permission + ")");
        }
        return principalOf(userId);
    }
    /** ä¼šè¯ä¸Žé™„件入口无法预知模块,只要求具备任意一项导入权限。 */
    public AgentPrincipal requireAny(String providedServiceToken) {
        verifyServiceToken(providedServiceToken);
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        if (userId == null) {
            throw new AgentBridgeException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "未登录或登录已过期");
        }
        if (!securityFrameworkService.hasAnyPermissions(IMPORT_PERMISSIONS.toArray(String[]::new))) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN", "当前用户缺少数据导入权限");
        }
        return principalOf(userId);
    }
    private AgentPrincipal principalOf(Long userId) {
        Long tenantId = TenantContextHolder.getTenantId();
        return new AgentPrincipal(userId, tenantId == null ? 0L : tenantId, "live");
    }
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/agent/imports/SystemAgentGenericImportService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,454 @@
package cn.iocoder.yudao.module.system.service.agent.imports;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.module.system.api.agent.imports.*;
import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandDO;
import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandRowDO;
import cn.iocoder.yudao.module.system.dal.mysql.agent.AgentImportCommandMapper;
import cn.iocoder.yudao.module.system.dal.mysql.agent.AgentImportCommandRowMapper;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
/**
 * é€šç”¨ä¸šåŠ¡å¯¼å…¥ç¼–æŽ’ï¼šé€‚é…å™¨åªè´Ÿè´£å•è¡Œæ ¡éªŒ/写入,本类统一处理权限、幂等、逐行事务与回执。
 */
@Service
@Validated
public class SystemAgentGenericImportService {
    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 ObjectProvider<AgentImportModuleAdapter> adapterProvider;
    @Resource
    private SecurityFrameworkService securityFrameworkService;
    @Resource
    private SystemAgentImportBridgeService attachmentService;
    @Resource
    private AgentImportCommandMapper commandMapper;
    @Resource
    private AgentImportCommandRowMapper commandRowMapper;
    @Resource
    private PlatformTransactionManager transactionManager;
    @Value("${yudao.agent.bridge.write-enabled:false}")
    private boolean writeEnabled;
    @Value("${yudao.agent.bridge.max-rows:5000}")
    private int maxRows;
    public List<Map<String, String>> modules() {
        return adapters().values().stream()
                .map(adapter -> Map.of("moduleId", adapter.moduleId(), "permission", adapter.permission()))
                .toList();
    }
    public AgentImportPreflightRespVO preflight(String moduleId, AgentImportPreflightReqVO request,
                                                AgentPrincipal principal) {
        AgentImportModuleAdapter adapter = requireAdapter(moduleId);
        requirePermission(adapter);
        validateAttachment(request.getAttachmentId(), request.getSourceSha256());
        validateRows(request);
        AgentImportPreflightRespVO result = adapter.preflight(principal, request);
        validatePlan(result, request);
        return result;
    }
    public AgentImportReceiptRespVO commit(String moduleId, AgentImportCommitReqVO request,
                                           AgentPrincipal principal) {
        if (!writeEnabled) {
            throw new AgentBridgeException(HttpStatus.SERVICE_UNAVAILABLE, "WRITE_DISABLED",
                    "智能体写入开关未打开,提交已拒绝");
        }
        AgentImportModuleAdapter adapter = requireAdapter(moduleId);
        requirePermission(adapter);
        validateAttachment(request.getAttachmentId(), request.getSourceSha256());
        validateRows(request);
        Long tenantId = principal.tenantId();
        Long ownerUserId = principal.userId();
        String digest = requestDigest(moduleId, request);
        // 1. å¹‚等命中:命令 ID ä¼˜å…ˆï¼Œå…¶æ¬¡å¹‚等键
        AgentImportCommandDO existing = commandMapper.selectByCommandId(
                tenantId, ownerUserId, moduleId, request.getCommandId());
        if (existing != null) {
            requireSameDigest(existing, digest);
            return toReceipt(existing);
        }
        existing = commandMapper.selectByIdempotencyKey(
                tenantId, ownerUserId, moduleId, request.getIdempotencyKey());
        if (existing != null) {
            requireSameDigest(existing, digest);
            return toReceipt(existing);
        }
        // 2. é‡æ–°é¢„检并比对计划摘要,防止确认后主数据变化
        AgentImportPreflightRespVO plan = adapter.preflight(principal, request);
        validatePlan(plan, request);
        if (!StrUtil.equalsIgnoreCase(request.getPreflightHash(), plan.getPreflightHash())) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "PLAN_STALE",
                    "预检计划已变化,请重新预检并确认", false, Map.of("preflight", plan));
        }
        if (!Boolean.TRUE.equals(plan.getCanCommit())) {
            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "PREFLIGHT_BLOCKED",
                    "预检存在阻断项,不能提交", false, Map.of("preflight", plan));
        }
        // 3. ç™»è®°å‘½ä»¤ï¼ˆå”¯ä¸€é”®å…œä½å¹¶å‘重复提交)
        AgentImportCommandDO command = AgentImportCommandDO.builder()
                .commandId(request.getCommandId())
                .idempotencyKey(request.getIdempotencyKey())
                .requestDigest(digest)
                .moduleId(moduleId)
                .tenantId(tenantId)
                .ownerUserId(ownerUserId)
                .sessionId(request.getSessionId())
                .attachmentId(request.getAttachmentId())
                .sourceSha256(request.getSourceSha256().toLowerCase(Locale.ROOT))
                .preflightHash(request.getPreflightHash().toLowerCase(Locale.ROOT))
                .proposalHash(request.getProposalHash())
                .status(STATUS_RUNNING)
                .totalCount(request.getRows().size())
                .successCount(0).failedCount(0).skippedCount(0)
                .leaseExpiresAt(LocalDateTime.now().plus(LEASE_DURATION))
                .build();
        try {
            commandMapper.insert(command);
        } catch (DuplicateKeyException ex) {
            AgentImportCommandDO raced = commandMapper.selectByCommandId(
                    tenantId, ownerUserId, moduleId, request.getCommandId());
            if (raced == null) {
                raced = commandMapper.selectByIdempotencyKey(
                        tenantId, ownerUserId, moduleId, request.getIdempotencyKey());
            }
            if (raced == null) {
                throw ex;
            }
            requireSameDigest(raced, digest);
            return toReceipt(raced);
        }
        // 4. é€è¡Œç‹¬ç«‹äº‹åŠ¡æ‰§è¡Œï¼›å•è¡Œå¤±è´¥ä¸å½±å“å…¶ä»–è¡Œ
        int success = 0;
        int failed = 0;
        int skipped = 0;
        TransactionTemplate rowTx = new TransactionTemplate(transactionManager);
        try {
            for (int index = 0; index < request.getRows().size(); index++) {
                AgentImportPreflightReqVO.Row row = request.getRows().get(index);
                AgentImportPreflightRespVO.Row planned = plan.getRows().get(index);
                if ("SKIP".equals(planned.getAction())) {
                    commandRowMapper.insert(baseRow(moduleId, request.getCommandId(), row)
                            .action("SKIP").status("SKIPPED")
                            .targetItemId(planned.getTargetId()).retryable(false).build());
                    skipped++;
                    continue;
                }
                if (!"CREATE".equals(planned.getAction())) {
                    commandRowMapper.insert(baseRow(moduleId, request.getCommandId(), row)
                            .action("CREATE").status("FAILED")
                            .errorCode("PREFLIGHT_BLOCKED").errorMessage("该行预检未通过").retryable(false).build());
                    failed++;
                    continue;
                }
                try {
                    AgentImportRowReceipt receipt = rowTx.execute(status ->
                            adapter.commitRow(principal, request, planned, row));
                    if (receipt == null) {
                        throw new IllegalStateException("适配器未返回回执");
                    }
                    commandRowMapper.insert(baseRow(moduleId, request.getCommandId(), row)
                            .action(StrUtil.blankToDefault(receipt.action(), "CREATE"))
                            .status("SUCCESS")
                            .targetItemId(receipt.targetId())
                            .targetVersion(receipt.targetVersion())
                            .code(StrUtil.blankToDefault(receipt.code(), row.getValues().get("code")))
                            .name(StrUtil.blankToDefault(receipt.name(), row.getValues().get("name")))
                            .retryable(false).build());
                    success++;
                } catch (Exception ex) {
                    recordFailure(moduleId, request.getCommandId(), row, ex);
                    failed++;
                }
            }
        } catch (Exception ex) {
            command.setStatus(STATUS_UNKNOWN);
            command.setErrorCode("COMMIT_INTERRUPTED");
            command.setErrorMessage(trim(ex.getMessage(), 512));
            command.setSuccessCount(success);
            command.setFailedCount(failed);
            command.setSkippedCount(skipped);
            command.setUpdateTime(LocalDateTime.now());
            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());
        command.setUpdateTime(LocalDateTime.now());
        commandMapper.updateById(command);
        return toReceipt(command);
    }
    public AgentImportReceiptRespVO getCommand(String moduleId, String commandId, AgentPrincipal principal) {
        requireAdapter(moduleId);
        AgentImportCommandDO command = commandMapper.selectByCommandId(
                principal.tenantId(), principal.userId(), moduleId, commandId);
        if (command == null) {
            throw new AgentBridgeException(HttpStatus.NOT_FOUND, "COMMAND_NOT_FOUND", "导入命令不存在");
        }
        return toReceipt(command);
    }
    private void recordFailure(String moduleId, String commandId, AgentImportPreflightReqVO.Row row,
                               Exception ex) {
        String errorCode = "ROW_WRITE_FAILED";
        boolean retryable = true;
        String message = ex.getMessage();
        if (ex instanceof AgentBridgeException bridge) {
            errorCode = bridge.getErrorCode();
            retryable = bridge.isRetryable();
            message = bridge.getReason();
        } else if (ex instanceof ServiceException service) {
            errorCode = "ROW_WRITE_REJECTED";
            retryable = false;
            message = service.getMessage();
        }
        commandRowMapper.insert(baseRow(moduleId, commandId, row)
                .action("CREATE").status("FAILED")
                .errorCode(errorCode).errorMessage(trim(message, 512)).retryable(retryable).build());
    }
    private AgentImportPreflightRespVO.Row requirePlanRow(AgentImportPreflightRespVO plan, int index) {
        if (plan.getRows() == null || index >= plan.getRows().size()) {
            throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID",
                    "适配器返回的预检行数与请求不一致");
        }
        return plan.getRows().get(index);
    }
    private void validatePlan(AgentImportPreflightRespVO plan, AgentImportPreflightReqVO request) {
        if (plan == null || StrUtil.isBlank(plan.getPreflightHash())) {
            throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID",
                    "适配器未返回预检摘要");
        }
        if (plan.getRows() == null || plan.getRows().size() != request.getRows().size()) {
            throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID",
                    "适配器返回的预检行数与请求不一致");
        }
        for (int index = 0; index < plan.getRows().size(); index++) {
            if (!request.getRows().get(index).getCandidateId()
                    .equals(plan.getRows().get(index).getCandidateId())) {
                throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID",
                        "适配器返回的预检行顺序与请求不一致");
            }
        }
    }
    private void validateAttachment(Long attachmentId, String sourceSha256) {
        String actual = attachmentService.sha256OfAttachment(attachmentId);
        if (!StrUtil.equalsIgnoreCase(actual, sourceSha256)) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "SOURCE_CHANGED",
                    "附件内容已变化,请重新上传后再试", true, null);
        }
    }
    private void validateRows(AgentImportPreflightReqVO request) {
        if (request.getRows() == null || request.getRows().isEmpty()) {
            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "ROWS_EMPTY", "没有可导入的数据行");
        }
        if (request.getRows().size() > maxRows) {
            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "ROWS_TOO_MANY",
                    "单次导入超过 " + maxRows + " è¡Œï¼Œè¯·æ‹†åˆ†æ–‡ä»¶");
        }
    }
    private void requirePermission(AgentImportModuleAdapter adapter) {
        if (!securityFrameworkService.hasPermission(adapter.permission())) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN",
                    "当前用户缺少该模块的导入权限(" + adapter.permission() + ")");
        }
    }
    private AgentImportModuleAdapter requireAdapter(String moduleId) {
        AgentImportModuleAdapter adapter = adapters().get(moduleId);
        if (adapter == null) {
            throw new AgentBridgeException(HttpStatus.NOT_FOUND, "MODULE_NOT_SUPPORTED",
                    "不支持的导入模块:" + moduleId);
        }
        return adapter;
    }
    private Map<String, AgentImportModuleAdapter> adapters() {
        Map<String, AgentImportModuleAdapter> result = new LinkedHashMap<>();
        for (AgentImportModuleAdapter adapter : adapterProvider) {
            result.put(adapter.moduleId(), adapter);
        }
        return result;
    }
    private static AgentImportCommandRowDO.AgentImportCommandRowDOBuilder baseRow(
            String moduleId, String commandId, AgentImportPreflightReqVO.Row row) {
        return AgentImportCommandRowDO.builder()
                .commandId(commandId)
                .moduleId(moduleId)
                .candidateId(row.getCandidateId())
                .sourceRow(row.getRowNo())
                .code(row.getValues().get("code"))
                .name(row.getValues().get("name"));
    }
    private static void requireSameDigest(AgentImportCommandDO command, String digest) {
        if (!command.getRequestDigest().equals(digest)) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT",
                    "同一命令或幂等键携带了不同的请求内容,请检查任务状态后重试");
        }
    }
    private static String requestDigest(String moduleId, AgentImportCommitReqVO request) {
        Map<String, Object> canonical = new LinkedHashMap<>();
        canonical.put("module_id", moduleId);
        canonical.put("attachment_id", request.getAttachmentId());
        canonical.put("session_id", request.getSessionId());
        canonical.put("source_sha256", StrUtil.nullToEmpty(request.getSourceSha256()).toLowerCase(Locale.ROOT));
        canonical.put("preflight_hash", StrUtil.nullToEmpty(request.getPreflightHash()).toLowerCase(Locale.ROOT));
        canonical.put("proposal_hash", StrUtil.nullToEmpty(request.getProposalHash()));
        canonical.put("proposal_revision", StrUtil.nullToEmpty(request.getProposalRevision()));
        canonical.put("options", request.getOptions() == null ? Map.of() : new TreeMap<>(request.getOptions()));
        List<Map<String, Object>> rows = new ArrayList<>();
        for (AgentImportPreflightReqVO.Row row : request.getRows()) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("candidate_id", row.getCandidateId());
            item.put("row_no", row.getRowNo());
            item.put("values", row.getValues() == null ? Map.of() : new TreeMap<>(row.getValues()));
            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);
        }
    }
    /**
     * é€‚配器计算预检摘要的统一入口,避免各模块摘要口径漂移。
     */
    public static String preflightHash(String moduleId, AgentImportPreflightReqVO request,
                                       Object resolvedPayload) {
        Map<String, Object> canonical = new LinkedHashMap<>();
        canonical.put("module_id", moduleId);
        canonical.put("attachment_id", request.getAttachmentId());
        canonical.put("source_sha256", StrUtil.nullToEmpty(request.getSourceSha256()).toLowerCase(Locale.ROOT));
        canonical.put("proposal_revision", StrUtil.nullToEmpty(request.getProposalRevision()));
        canonical.put("options", request.getOptions() == null ? Map.of() : new TreeMap<>(request.getOptions()));
        List<Map<String, Object>> rows = new ArrayList<>();
        for (AgentImportPreflightReqVO.Row row : request.getRows()) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("candidate_id", row.getCandidateId());
            item.put("row_no", row.getRowNo());
            item.put("values", row.getValues() == null ? Map.of() : new TreeMap<>(row.getValues()));
            rows.add(item);
        }
        canonical.put("rows", rows);
        canonical.put("resolved", resolvedPayload);
        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 AgentImportReceiptRespVO toReceipt(AgentImportCommandDO command) {
        String status = command.getStatus();
        if (STATUS_RUNNING.equals(status) && command.getLeaseExpiresAt() != null
                && command.getLeaseExpiresAt().isBefore(LocalDateTime.now())) {
            status = STATUS_UNKNOWN;
        }
        List<AgentImportCommandRowDO> rowDOs = commandRowMapper.selectListByCommandId(command.getCommandId());
        List<AgentImportReceiptRespVO.Row> rows = rowDOs.stream()
                .map(row -> AgentImportReceiptRespVO.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 AgentImportReceiptRespVO.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(AgentImportReceiptRespVO.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 String trim(String value, int maxLength) {
        if (value == null) {
            return null;
        }
        return value.length() <= maxLength ? value : value.substring(0, maxLength);
    }
}