From d9a5ba1de2fce6bd8c50dace505e4bf7864043ba Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 22 十月 2026 15:01:07 +0800
Subject: [PATCH] feat(mdm): 新增智能体通用业务导入功能
---
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapter.java | 277 ++++++++
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapterTest.java | 174 +++++
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportModuleAdapter.java | 23
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java | 37
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java | 29
sql/mysql/system_agent_data_entry_menu_20261022.sql | 23
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightReqVO.java | 46 +
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/agent/imports/SystemAgentGenericImportService.java | 454 ++++++++++++++
sql/mysql/agent_import_command_20261022.sql | 57 +
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandRowDO.java | 36 +
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportCommitReqVO.java | 24
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportRowReceipt.java | 12
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandMapper.java | 28
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapter.java | 217 +++++++
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandDO.java | 44 +
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightRespVO.java | 65 ++
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandRowMapper.java | 15
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportReceiptRespVO.java | 57 +
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapterTest.java | 121 +++
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentGenericImportBridgeController.java | 66 ++
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java | 6
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java | 10
22 files changed, 1,809 insertions(+), 12 deletions(-)
diff --git a/sql/mysql/agent_import_command_20261022.sql b/sql/mysql/agent_import_command_20261022.sql
new file mode 100644
index 0000000..18b392e
--- /dev/null
+++ b/sql/mysql/agent_import_command_20261022.sql
@@ -0,0 +1,57 @@
+-- 閫氱敤涓氬姟瀵煎叆骞傜瓑鍛戒护涓庨�愯鍥炴墽锛堜緵搴斿晢銆丅OM 绛夛級銆傛墽琛屽墠澶囦唤鐩爣搴撱��
+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='鏅鸿兘浣撻�氱敤涓氬姟瀵煎叆閫愯鍥炴墽';
diff --git a/sql/mysql/system_agent_data_entry_menu_20261022.sql b/sql/mysql/system_agent_data_entry_menu_20261022.sql
new file mode 100644
index 0000000..3b6558c
--- /dev/null
+++ b/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);
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
index 70c8839..f3c144c 100644
--- 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
@@ -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)
diff --git a/yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java b/yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java
index db69105..fa4b977 100644
--- a/yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java
+++ b/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();
diff --git a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapter.java b/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapter.java
new file mode 100644
index 0000000..f8b35d0
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapterTest.java b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/md/item/MesBomImportAdapterTest.java
new file mode 100644
index 0000000..12f8d23
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapter.java b/yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapter.java
new file mode 100644
index 0000000..01b9ded
--- /dev/null
+++ b/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();
+ }
+}
diff --git a/yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapterTest.java b/yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/supplier/SrmSupplierImportAdapterTest.java
new file mode 100644
index 0000000..f7ec076
--- /dev/null
+++ b/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", "渚涘簲鍟咥"),
+ row("r2", "SUP-DUP", "渚涘簲鍟咮")));
+
+ 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;
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportCommitReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportCommitReqVO.java
new file mode 100644
index 0000000..e634843
--- /dev/null
+++ b/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;
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportModuleAdapter.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportModuleAdapter.java
new file mode 100644
index 0000000..d1a67be
--- /dev/null
+++ b/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);
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightReqVO.java
new file mode 100644
index 0000000..bf7ec7b
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportPreflightRespVO.java
new file mode 100644
index 0000000..83cf2ad
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportReceiptRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportReceiptRespVO.java
new file mode 100644
index 0000000..5bcf372
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportRowReceipt.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/agent/imports/AgentImportRowReceipt.java
new file mode 100644
index 0000000..ce74ca9
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentGenericImportBridgeController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentGenericImportBridgeController.java
new file mode 100644
index 0000000..eab9aba
--- /dev/null
+++ b/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));
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java
index bbba5e3..0f8c195 100644
--- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java
+++ b/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 鈫� 涓氬姟瀵煎叆鏉冮檺锛汸ython 浠ユā鍧� 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())
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandDO.java
new file mode 100644
index 0000000..7cd61a9
--- /dev/null
+++ b/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;
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandRowDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/agent/AgentImportCommandRowDO.java
new file mode 100644
index 0000000..50a9053
--- /dev/null
+++ b/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;
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandMapper.java
new file mode 100644
index 0000000..1308c76
--- /dev/null
+++ b/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));
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandRowMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/agent/AgentImportCommandRowMapper.java
new file mode 100644
index 0000000..870e6a9
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java
index f79576d..c5514ee 100644
--- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java
+++ b/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);
+ }
+
+ /** 浼氳瘽涓庨檮浠跺叆鍙f棤娉曢鐭ユā鍧楋紝鍙姹傚叿澶囦换鎰忎竴椤瑰鍏ユ潈闄愩�� */
+ 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");
}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/agent/imports/SystemAgentGenericImportService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/agent/imports/SystemAgentGenericImportService.java
new file mode 100644
index 0000000..3a924ab
--- /dev/null
+++ b/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);
+ }
+ }
+
+ /**
+ * 閫傞厤鍣ㄨ绠楅妫�鎽樿鐨勭粺涓�鍏ュ彛锛岄伩鍏嶅悇妯″潡鎽樿鍙e緞婕傜Щ銆�
+ */
+ 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);
+ }
+}
--
Gitblit v1.9.3