From e32a120571b24a931824aa8593525330938670bd Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:40:43 +0800 Subject: [PATCH] feat(A): real ERP data reads (Read tool via form API) --- src/main/java/com/xly/config/AgentConfig.java | 7 +++++-- src/main/java/com/xly/service/ErpClient.java | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/SystemPromptService.java | 10 ++++++---- src/main/java/com/xly/tool/ErpReadTool.java | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/resources/application-saaslocal.yml | 13 +++++++++++++ 5 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/xly/service/ErpClient.java create mode 100644 src/main/java/com/xly/tool/ErpReadTool.java diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index 51479fd..ca6449b 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -2,6 +2,7 @@ package com.xly.config; import com.xly.agent.ReActAgent; import com.xly.service.SystemPromptService; +import com.xly.tool.ErpReadTool; import com.xly.tool.KgQueryTool; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; @@ -41,11 +42,13 @@ public class AgentConfig { } @Bean - public ReActAgent reActAgent(SystemPromptService systemPromptService, KgQueryTool kgQueryTool) { + public ReActAgent reActAgent(SystemPromptService systemPromptService, + KgQueryTool kgQueryTool, + ErpReadTool erpReadTool) { String systemPrompt = systemPromptService.buildSystemPrompt(); return AiServices.builder(ReActAgent.class) .streamingChatModel(agentStreamingModel()) - .tools(kgQueryTool) + .tools(kgQueryTool, erpReadTool) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) .systemMessageProvider(memoryId -> systemPrompt) .build(); diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java new file mode 100644 index 0000000..1fe942f --- /dev/null +++ b/src/main/java/com/xly/service/ErpClient.java @@ -0,0 +1,117 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * ERP 后端(xlyEntry)薄 HTTP 客户端。 + * + *

xlyAi 的读写业务逻辑在 ERP 后端,xlyAi 只是像前端一样发同样的 API 请求。本类负责: + *

+ */ +@Service +public class ErpClient { + + private static final Logger log = LoggerFactory.getLogger(ErpClient.class); + + private final ObjectMapper mapper; + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + @Value("${erp.baseurl}") + private String baseUrl; + @Value("${erp.dev-login.brand:1111111111}") + private String brand; + @Value("${erp.dev-login.subsidiary:1111111111}") + private String subsidiary; + @Value("${erp.dev-login.username:admin}") + private String username; + @Value("${erp.dev-login.password:666666}") + private String password; + + private volatile String cachedToken; + + public ErpClient(ObjectMapper mapper) { + this.mapper = mapper; + } + + /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。 */ + private synchronized String login() { + try { + String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary; + String body = mapper.writeValueAsString(Map.of("username", username, "password", password)); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .timeout(Duration.ofSeconds(20)) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + JsonNode root = mapper.readTree(resp.body()); + if (root.path("code").asInt() != 1) { + throw new IllegalStateException("ERP 登录失败: " + root.path("msg").asText()); + } + String token = root.path("dataset").path("rows").path(0).path("token").asText(null); + if (token == null || token.isBlank()) { + throw new IllegalStateException("ERP 登录未返回 token"); + } + cachedToken = token; + log.info("ERP dev-login ok (user={}), token cached", username); + return token; + } catch (Exception e) { + throw new RuntimeException("ERP 登录异常: " + e.getMessage(), e); + } + } + + private String token() { + String t = cachedToken; + return (t != null && !t.isBlank()) ? t : login(); + } + + /** + * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 + * 会话过期(code=-2)时自动重登一次并重试。 + */ + public JsonNode readForm(String formId, String moduleId, int page, int pageSize) { + JsonNode root = doRead(formId, moduleId, page, pageSize, token()); + if (root.path("code").asInt() == -2) { + login(); + root = doRead(formId, moduleId, page, pageSize, token()); + } + return root; + } + + private JsonNode doRead(String formId, String moduleId, int page, int pageSize, String tok) { + try { + String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + formId + + "?sModelsId=" + moduleId + "&sName="; + String body = mapper.writeValueAsString(Map.of( + "pageNum", page, "pageSize", pageSize, "bFilter", List.of())); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", tok) + .timeout(Duration.ofSeconds(30)) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return mapper.readTree(resp.body()); + } catch (Exception e) { + throw new RuntimeException("ERP 读取异常: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 33091a8..c87bfeb 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -32,14 +32,16 @@ public class SystemPromptService { 可能涉及哪些单据,再决定怎么做: %s 【可用工具】 - - findForms(keyword):按关键词检索业务表单目录,用来把用户口中的「单据 / 报表」定位到具体是哪一张表单\ - (拿到 formId / moduleId)。当你不确定用户指的到底是哪张表单时,先调用它确认,不要自己编表单名。 + - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 + - readFormData(formId, moduleId):读取该表单的真实业务数据(返回前若干行 + 总条数)。 + 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ + (可小结总条数、列出前几条)。绝不自己编表单名或数据。 【行为准则】 - 1. 凡是能用工具确认的事实(表单、单据、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 + 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 - 4. 你**当前只有只读的「表单目录检索」能力**。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ + 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 5. 信息不足时主动向用户提问,不要猜测。 """.formatted(renderDomainMap()); diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java new file mode 100644 index 0000000..eb9cb74 --- /dev/null +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -0,0 +1,160 @@ +package com.xly.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xly.service.ErpClient; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Read 工具(Phase A):读取指定 ERP 表单的真实业务数据。 + * + *

走 ERP 后端通用表单读接口({@link ErpClient})——即架构里"Read = 薄 HTTP 客户端、 + * 业务逻辑在 ERP 后端、得到租户+行级范围"。列名用字段字典({@code viw_kg_field_dict}) + * 渲染成中文,只展示前几列/前几行,避免吐爆上下文。 + * + *

安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。 + */ +@Component +public class ErpReadTool { + + private static final int MAX_ROWS = 10; + private static final int MAX_COLS = 6; + + private final ErpClient erp; + private final JdbcTemplate jdbc; + + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc) { + this.erp = erp; + this.jdbc = jdbc; + } + + @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。" + + "必须先用 findForms 得到目标表单的 formId 与 moduleId,再调用本工具。") + public String readFormData( + @P("表单id(findForms 返回的 formId)") String formId, + @P("菜单id(findForms 返回的 moduleId)") String moduleId) { + + if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { + return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; + } + + JsonNode root; + try { + root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS); + } catch (Exception e) { + return "读取失败:" + e.getMessage(); + } + + int code = root.path("code").asInt(0); + if (code < 0) { + return "读取失败:" + root.path("msg").asText("未知错误"); + } + + JsonNode ds = root.path("dataset"); + int total = ds.path("totalCount").asInt(0); + JsonNode rows = ds.path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + if (data == null || !data.isArray() || data.isEmpty()) { + return "该表单当前没有数据(共 " + total + " 条)。"; + } + + Map labels = loadLabels(formId.trim()); + List cols = pickColumns(data.get(0), labels); + + StringBuilder sb = new StringBuilder(); + sb.append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); + sb.append("| ") + .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) + .append(" |\n"); + sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); + for (JsonNode r : data) { + List vals = new ArrayList<>(cols.size()); + for (String c : cols) { + vals.add(cell(r.path(c))); + } + sb.append("| ").append(String.join(" | ", vals)).append(" |\n"); + } + return sb.toString(); + } + + /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ + private Map loadLabels(String formId) { + Map m = new HashMap<>(); + try { + List> rows = jdbc.queryForList( + "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh " + + "FROM viw_ai_useful_forms af " + + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + + "WHERE af.sFormId = ? GROUP BY fd.sField", formId); + for (Map r : rows) { + Object f = r.get("f"); + Object zh = r.get("zh"); + if (f != null && zh != null) { + m.put(f.toString(), zh.toString()); + } + } + } catch (Exception ignore) { + // 无字典时退化为技术列名 + } + return m; + } + + /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ + private List pickColumns(JsonNode first, Map labels) { + List labeled = new ArrayList<>(); + List others = new ArrayList<>(); + Iterator it = first.fieldNames(); + while (it.hasNext()) { + String c = it.next(); + if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) { + continue; // 跳过 id / token / 布尔字段 + } + JsonNode v = first.path(c); + if (v.isContainerNode()) { + continue; + } + if (labels.containsKey(c)) { + labeled.add(c); + } else { + others.add(c); + } + } + List cols = new ArrayList<>(labeled); + for (String o : others) { + if (cols.size() >= MAX_COLS) { + break; + } + cols.add(o); + } + if (cols.size() > MAX_COLS) { + cols = new ArrayList<>(cols.subList(0, MAX_COLS)); + } + if (cols.isEmpty()) { + Iterator it2 = first.fieldNames(); + while (it2.hasNext() && cols.size() < 4) { + String c = it2.next(); + if (!first.path(c).isContainerNode()) { + cols.add(c); + } + } + } + return cols; + } + + private String cell(JsonNode v) { + if (v == null || v.isNull() || v.isMissingNode()) { + return ""; + } + String s = v.asText("").replace("|", "/").replace("\n", " ").trim(); + return s.length() > 24 ? s.substring(0, 24) + "…" : s; + } +} diff --git a/src/main/resources/application-saaslocal.yml b/src/main/resources/application-saaslocal.yml index 2e0f6ab..b74758b 100644 --- a/src/main/resources/application-saaslocal.yml +++ b/src/main/resources/application-saaslocal.yml @@ -20,3 +20,16 @@ spring: # macOS-friendly temp path (was D:/xlyweberp/ai/ocrtmp) ocr: tmpPath: /Users/reporkey/Desktop/saas-8s+/tempPath/ocrtmp + +# Point ERP form-read API at the LOCAL xlyEntry (:8697, ctx /xlyEntry) which uses the +# same local DB. The committed application.yml erp.baseurl targets the remote deploy. +# dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111); +# production should instead pass through the user's own browser ERP token per request. +erp: + baseurl: http://127.0.0.1:8697/xlyEntry + dev-login: + enabled: true + brand: "1111111111" + subsidiary: "1111111111" + username: admin + password: "666666" -- libgit2 0.22.2