Commit 783cef5174e63fc24fba1d7fc5fd6ca5b6d3d8cb
1 parent
42a40f61
feat: create write op (proposeCreate) + Query self-repair + LLM tracing hook + a…
…i_op_queue staging cols + ai_skill registry - proposeCreate tool -> ai_op_queue draft(payload) -> confirm executes addBusinessData - ai_op_queue: add sPayload/sSourceRef/bAutoExecute/sResultBillId/sErrorMsg/tExecutedDate/tExpireAt (create/examine/auto-flow ready) - ai_skill registry table + seed playbooks (Skills §6) - QueryTool: self-repair retry (feed SQL error back to model, up to 3x) - TracingChatModelListener: per-call LLM latency/token/error trace (Langfuse hook point)
Showing
14 changed files
with
455 additions
and
34 deletions
mvnw
100644 → 100755
sql/ai_op_queue.sql
| 1 | -- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 | 1 | -- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 |
| 2 | -- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 | 2 | -- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 |
| 3 | --- v1 覆盖 update(改现有记录的字段);create/delete/examine 后续扩展。 | 3 | +-- 覆盖 update / create / delete / examine / generate(sOpType 区分)。 |
| 4 | +-- update/delete/examine:用 sTargetBillId 指向目标记录;改字段用 sField/sNewValue。 | ||
| 5 | +-- create/generate:整表 payload 存 sPayload(列->值 JSON);generate 的源引用存 sSourceRef。 | ||
| 6 | +-- bAutoExecute=1(如报价)→ ERP 确认后自动执行并回 sResultBillId;=0 → 预填表单让用户手动提交。 | ||
| 4 | CREATE TABLE IF NOT EXISTS ai_op_queue ( | 7 | CREATE TABLE IF NOT EXISTS ai_op_queue ( |
| 5 | - sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token | 8 | + sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token |
| 6 | sUserId varchar(64), | 9 | sUserId varchar(64), |
| 7 | sConversationId varchar(96), | 10 | sConversationId varchar(96), |
| 8 | sBrandsId varchar(32), | 11 | sBrandsId varchar(32), |
| 9 | sSubsidiaryId varchar(32), | 12 | sSubsidiaryId varchar(32), |
| 10 | - sOpType varchar(16), -- update | create | delete | examine | 13 | + sOpType varchar(16), -- update | create | delete | examine | generate |
| 11 | sTargetFormId varchar(64), | 14 | sTargetFormId varchar(64), |
| 12 | sTargetModuleId varchar(64), | 15 | sTargetModuleId varchar(64), |
| 13 | sTargetTable varchar(64), | 16 | sTargetTable varchar(64), |
| 14 | - sTargetBillId varchar(64), -- 目标记录 sId(update/delete 用) | 17 | + sTargetBillId varchar(64), -- 目标记录 sId(update/delete/examine 用) |
| 15 | sField varchar(64), -- 目标列(技术名) | 18 | sField varchar(64), -- 目标列(技术名) |
| 16 | sFieldLabel varchar(128), -- 目标列中文名 | 19 | sFieldLabel varchar(128), -- 目标列中文名 |
| 17 | sOldValue varchar(500), | 20 | sOldValue varchar(500), |
| 18 | sNewValue varchar(500), | 21 | sNewValue varchar(500), |
| 22 | + sPayload text, -- create/update 的列->值 JSON(整表写入) | ||
| 23 | + sSourceRef text, -- generate:源表单 + 选中明细 sId 集合 JSON | ||
| 24 | + bAutoExecute tinyint NOT NULL DEFAULT 0, -- 1=ERP 确认后自动执行;0=预填表单等用户提交 | ||
| 19 | sDescription varchar(500), -- 人类可读的改动描述 | 25 | sDescription varchar(500), -- 人类可读的改动描述 |
| 20 | - sStatus varchar(16), -- draft | confirmed | executed | failed | cancelled | 26 | + sStatus varchar(16), -- draft | confirmed | executing | executed | failed | cancelled | expired |
| 27 | + sResultBillId varchar(64), -- 执行后生成/影响的单据 id | ||
| 21 | sResultMsg varchar(500), | 28 | sResultMsg varchar(500), |
| 29 | + sErrorMsg varchar(1000), | ||
| 22 | tCreateDate datetime, | 30 | tCreateDate datetime, |
| 23 | tConfirmDate datetime, | 31 | tConfirmDate datetime, |
| 32 | + tExecutedDate datetime, | ||
| 33 | + tExpireAt datetime, | ||
| 24 | KEY idx_conv (sConversationId, sStatus) | 34 | KEY idx_conv (sConversationId, sStatus) |
| 25 | ); | 35 | ); |
sql/ai_skill.sql
0 → 100644
| 1 | +-- ai_skill:Skill 注册表(架构 §6)。 | ||
| 2 | +-- Skill = 针对重复任务的 playbook(如 新建报价 / 月度对账 / 库存盘点),用工具但不是工具。 | ||
| 3 | +-- 渐进披露:name + 何时用(sWhenToUse) 进 system prompt(便宜、常驻);load_skill(name) 工具 | ||
| 4 | +-- 按需注入完整指令(sInstructions)。sSuggested = 建议用到的工具/表单(逗号分隔,仅提示)。 | ||
| 5 | +CREATE TABLE IF NOT EXISTS ai_skill ( | ||
| 6 | + sId varchar(64) NOT NULL PRIMARY KEY, | ||
| 7 | + sName varchar(64) NOT NULL, -- Skill 名(load_skill 的入参) | ||
| 8 | + sWhenToUse varchar(255) NOT NULL, -- 何时用(一句话,进 system prompt) | ||
| 9 | + sInstructions text NOT NULL, -- 详细 playbook(load_skill 时注入) | ||
| 10 | + sSuggested varchar(255) NULL, -- 建议工具/表单(逗号分隔) | ||
| 11 | + bEnabled tinyint NOT NULL DEFAULT 1, | ||
| 12 | + iOrder int NOT NULL DEFAULT 100, | ||
| 13 | + tCreateDate datetime NULL | ||
| 14 | +); | ||
| 15 | + | ||
| 16 | +INSERT INTO ai_skill (sId,sName,sWhenToUse,sInstructions,sSuggested,bEnabled,iOrder,tCreateDate) VALUES | ||
| 17 | +('skill-new-quote','新建报价','用户要为某客户新建/录入一张报价单时', | ||
| 18 | + '新建报价的标准流程:\n1) 先用 findForms("报价") 定位报价主表单,拿到 formId/moduleId。\n2) 报价字段较多(客户、币种、含税、明细行等),用 collectForm(该 formId) 弹出表单让用户逐项填写,而不是逐个追问。\n3) 用户填完后,用 proposeCreate 生成待确认的新增提议;务必让用户在对话内点【确认】后才真正落单。\n4) 报价属于自动执行类写入(bAutoExecute),确认后 ERP 会自动生成正式报价单并回链接。', | ||
| 19 | + 'findForms, collectForm, proposeCreate',1,10,NOW()), | ||
| 20 | +('skill-monthly-ar','月度对账/超期应收催收','用户要做应收对账、查超期未收款、催收时', | ||
| 21 | + '应收对账/催收流程:\n1) 用 queryData 或 findForms("应收") 找到应收台账/收款单据。\n2) 按客户汇总未收金额,用 queryData 做“按客户分组 SUM(未收金额) 并按账龄排序”。\n3) 列出超期(如超过约定账期)客户与金额,提示用户重点催收。\n4) 只读分析,不做任何写操作。', | ||
| 22 | + 'queryData, findForms, readFormData',1,20,NOW()), | ||
| 23 | +('skill-inventory-check','库存查询/盘点','用户要查某物料/产品的库存、库存台账、进出明细时', | ||
| 24 | + '库存查询流程:\n1) 物料库存看 elematerialsstock 相关表单,产品库存看 productstock。\n2) 用 lookupRecord(物料/产品, 名称) 查单个;用 readFormData 看列表;用 queryData 做汇总。\n3) 严禁传 bUpdate 之类写参数(只读)。\n4) 需要出入库明细时找 *_inoutdetail 视图。', | ||
| 25 | + 'lookupRecord, readFormData, queryData',1,30,NOW()); |
sql/viw_ai_useful_forms.sql
0 → 100644
| 1 | +-- ============================================================ | ||
| 2 | +-- 视图:viw_ai_useful_forms —— ERP「有用业务表单」目录 | ||
| 3 | +-- 合并 gdsmodule(菜单窗体) + gdsconfigformmaster(表单配置) | ||
| 4 | +-- 过滤原则:保召回优先,只剔除"必然无用"的项,再用字段让调用方自行收窄 | ||
| 5 | +-- 剔除:失效 / 系统配置源(gds*/sys*) / BI报表 / 首页 / 外链 / 弹窗 / 系统配置窗体 | ||
| 6 | +-- 保留:有业务数据源(表/视图/过程)的表单 | ||
| 7 | +-- 收窄开关(作为字段暴露,不写死在过滤里,避免误删"明细/查询"类窗体): | ||
| 8 | +-- bVisibleMenu = 1 → 主菜单可见的窗体(约370张,精度高) | ||
| 9 | +-- bIsGrid = 1 → 列表/表格型窗体(适合出数据列表) | ||
| 10 | +-- 调接口用法:POST /business/getBusinessDataByFormcustomId/{sFormId}?sModelsId={sModuleId} | ||
| 11 | +-- ============================================================ | ||
| 12 | +CREATE OR REPLACE VIEW viw_ai_useful_forms AS | ||
| 13 | +SELECT | ||
| 14 | + m.sId AS sModuleId, -- 菜单窗体id (= 接口 sModelsId / ai_tool.sSrcFormId) | ||
| 15 | + m.sChinese AS sModuleName, -- 窗体/菜单名称 | ||
| 16 | + m.sModelType AS sModelType, -- 前端路由/业务域(可能为空) | ||
| 17 | + f.sId AS sFormId, -- 表单id (= 接口路径 gdsconfigformmasterId) | ||
| 18 | + f.sChinese AS sFormTitle, -- 表单标题 | ||
| 19 | + f.sTbName AS sDataSource, -- 数据源(表/视图/过程) | ||
| 20 | + f.sType AS sExecType, -- 执行类型 table/view/proc | ||
| 21 | + CAST(f.bGrd AS UNSIGNED) AS bIsGrid, -- 1=列表型窗体, 0=明细型 (收窄开关) | ||
| 22 | + CAST(m.bVisible AS UNSIGNED) AS bVisibleMenu, -- 1=主菜单可见, 0=次级/查询窗体 (收窄开关) | ||
| 23 | + m.iOrder AS iOrder -- 菜单排序 | ||
| 24 | +FROM gdsmodule m | ||
| 25 | +JOIN gdsconfigformmaster f ON f.sParentId = m.sId | ||
| 26 | +WHERE IFNULL(m.bInvalid, 0) = 0 | ||
| 27 | + AND IFNULL(f.sTbName, '') <> '' | ||
| 28 | + AND f.sType IN ('table', 'view', 'proc') | ||
| 29 | + AND f.sTbName NOT LIKE 'gds%' | ||
| 30 | + AND f.sTbName NOT LIKE 'sys%' | ||
| 31 | + AND IFNULL(m.sModelType, '') NOT LIKE 'commonAuto/Bi%' | ||
| 32 | + AND IFNULL(m.sModelType, '') NOT LIKE '%IndexPage' | ||
| 33 | + AND IFNULL(m.sModelType, '') NOT LIKE 'http%' | ||
| 34 | + AND IFNULL(m.sModelType, '') NOT LIKE '%ipAddress%' | ||
| 35 | + AND IFNULL(m.sModelType, '') NOT LIKE 'modal/%' | ||
| 36 | + AND IFNULL(m.sModelType, '') NOT IN ( | ||
| 37 | + 'gdsmodule', 'gdsconfigformmaster', 'gdsconfigtbmaster', | ||
| 38 | + 'gdsformconst', 'gdsjurisdiction', 'gdsconfigcharmaster', 'gdsinterfacemaster' | ||
| 39 | + ); |
src/main/java/com/xly/config/AgentConfig.java
| @@ -30,6 +30,9 @@ public class AgentConfig { | @@ -30,6 +30,9 @@ public class AgentConfig { | ||
| 30 | @Value("${langchain4j.ollama.chat-model-name}") | 30 | @Value("${langchain4j.ollama.chat-model-name}") |
| 31 | private String chatModelName; | 31 | private String chatModelName; |
| 32 | 32 | ||
| 33 | + @org.springframework.beans.factory.annotation.Autowired | ||
| 34 | + private TracingChatModelListener tracingListener; | ||
| 35 | + | ||
| 33 | /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ | 36 | /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ |
| 34 | @Bean("agentStreamingModel") | 37 | @Bean("agentStreamingModel") |
| 35 | public OllamaStreamingChatModel agentStreamingModel() { | 38 | public OllamaStreamingChatModel agentStreamingModel() { |
| @@ -42,6 +45,7 @@ public class AgentConfig { | @@ -42,6 +45,7 @@ public class AgentConfig { | ||
| 42 | // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 | 45 | // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 |
| 43 | .think(false) | 46 | .think(false) |
| 44 | .returnThinking(false) | 47 | .returnThinking(false) |
| 48 | + .listeners(java.util.List.of(tracingListener)) | ||
| 45 | .timeout(Duration.ofSeconds(180)) | 49 | .timeout(Duration.ofSeconds(180)) |
| 46 | .build(); | 50 | .build(); |
| 47 | } | 51 | } |
src/main/java/com/xly/config/TracingChatModelListener.java
0 → 100644
| 1 | +package com.xly.config; | ||
| 2 | + | ||
| 3 | +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; | ||
| 4 | +import dev.langchain4j.model.chat.listener.ChatModelListener; | ||
| 5 | +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; | ||
| 6 | +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; | ||
| 7 | +import dev.langchain4j.model.output.TokenUsage; | ||
| 8 | +import org.slf4j.Logger; | ||
| 9 | +import org.slf4j.LoggerFactory; | ||
| 10 | +import org.springframework.stereotype.Component; | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * LLM 调用观测(轻量 tracing)—— 每次模型调用记录耗时 / token 用量 / 错误到 `com.xly.trace.llm` 日志。 | ||
| 14 | + * | ||
| 15 | + * <p>这是 **Langfuse 的接入点**:Langfuse 本体是需自托管的观测服务(需要实例 + 密钥),此处的 | ||
| 16 | + * onResponse/onError 就是把 span 转发给 Langfuse 的挂钩位;在没有实例的环境下先落到日志,保证 | ||
| 17 | + * 「LLM 可观测」这一能力有实现、可随时对接 Langfuse。业务审计另见 `ai_audit_log`(与 LLM tracing 分离)。 | ||
| 18 | + */ | ||
| 19 | +@Component | ||
| 20 | +public class TracingChatModelListener implements ChatModelListener { | ||
| 21 | + | ||
| 22 | + private static final Logger log = LoggerFactory.getLogger("com.xly.trace.llm"); | ||
| 23 | + | ||
| 24 | + @Override | ||
| 25 | + public void onRequest(ChatModelRequestContext ctx) { | ||
| 26 | + ctx.attributes().put("t0", System.nanoTime()); | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Override | ||
| 30 | + public void onResponse(ChatModelResponseContext ctx) { | ||
| 31 | + long ms = elapsedMs(ctx.attributes().get("t0")); | ||
| 32 | + String tok = "?"; | ||
| 33 | + try { | ||
| 34 | + TokenUsage u = ctx.chatResponse() == null ? null : ctx.chatResponse().tokenUsage(); | ||
| 35 | + if (u != null) { | ||
| 36 | + tok = u.inputTokenCount() + "/" + u.outputTokenCount(); | ||
| 37 | + } | ||
| 38 | + } catch (Exception ignore) { | ||
| 39 | + } | ||
| 40 | + log.info("LLM ok {}ms tokens(in/out)={}", ms, tok); | ||
| 41 | + // Langfuse 接入点:此处可 forward 一个 span(model, prompt, completion, latency, tokens)。 | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @Override | ||
| 45 | + public void onError(ChatModelErrorContext ctx) { | ||
| 46 | + Throwable e = ctx.error(); | ||
| 47 | + log.warn("LLM error: {}", e == null ? "?" : e.getMessage()); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + private long elapsedMs(Object t0) { | ||
| 51 | + if (t0 instanceof Long) { | ||
| 52 | + return (System.nanoTime() - (Long) t0) / 1_000_000L; | ||
| 53 | + } | ||
| 54 | + return -1; | ||
| 55 | + } | ||
| 56 | +} |
src/main/java/com/xly/service/ErpClient.java
| @@ -174,6 +174,57 @@ public class ErpClient { | @@ -174,6 +174,57 @@ public class ErpClient { | ||
| 174 | } | 174 | } |
| 175 | } | 175 | } |
| 176 | 176 | ||
| 177 | + /** 取一个新主键 uuid(ERP `/getUuid`)。 */ | ||
| 178 | + public String newUuid() { | ||
| 179 | + try { | ||
| 180 | + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) | ||
| 181 | + .header("Authorization", token()) | ||
| 182 | + .timeout(Duration.ofSeconds(15)) | ||
| 183 | + .GET() | ||
| 184 | + .build(); | ||
| 185 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | ||
| 186 | + JsonNode root = mapper.readTree(resp.body()); | ||
| 187 | + return root.path("dataset").path("rows").path(0).asText(null); | ||
| 188 | + } catch (Exception e) { | ||
| 189 | + throw new RuntimeException("取 uuid 失败: " + e.getMessage(), e); | ||
| 190 | + } | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ | ||
| 194 | + public JsonNode createForm(String table, Map<String, Object> columns) { | ||
| 195 | + JsonNode root = doCreate(table, columns, token()); | ||
| 196 | + if (root.path("code").asInt() == -2) { | ||
| 197 | + login(); | ||
| 198 | + root = doCreate(table, columns, token()); | ||
| 199 | + } | ||
| 200 | + return root; | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + private JsonNode doCreate(String table, Map<String, Object> columns, String tok) { | ||
| 204 | + try { | ||
| 205 | + String url = baseUrl + "/business/addBusinessData"; | ||
| 206 | + Map<String, Object> dataItem = new LinkedHashMap<>(); | ||
| 207 | + dataItem.put("sTable", table); | ||
| 208 | + dataItem.put("column", columns); | ||
| 209 | + Map<String, Object> body = new LinkedHashMap<>(); | ||
| 210 | + body.put("sMakePerson", username); | ||
| 211 | + body.put("sBrandsId", brand); | ||
| 212 | + body.put("sSubsidiaryId", subsidiary); | ||
| 213 | + body.put("sLanguage", "sChinese"); | ||
| 214 | + body.put("data", List.of(dataItem)); | ||
| 215 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | ||
| 216 | + .header("Content-Type", "application/json;charset=UTF-8") | ||
| 217 | + .header("Authorization", tok) | ||
| 218 | + .timeout(Duration.ofSeconds(30)) | ||
| 219 | + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) | ||
| 220 | + .build(); | ||
| 221 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | ||
| 222 | + return mapper.readTree(resp.body()); | ||
| 223 | + } catch (Exception e) { | ||
| 224 | + throw new RuntimeException("ERP 新增异常: " + e.getMessage(), e); | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + | ||
| 177 | private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { | 228 | private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { |
| 178 | try { | 229 | try { |
| 179 | String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; | 230 | String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; |
src/main/java/com/xly/service/OpService.java
| @@ -35,6 +35,18 @@ public class OpService { | @@ -35,6 +35,18 @@ public class OpService { | ||
| 35 | return sId; | 35 | return sId; |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | + /** 暂存一条 draft(payload 版,用于 create:sPayload=列->值 JSON)。 */ | ||
| 39 | + public String createDraftPayload(String userId, String opType, String formId, String moduleId, | ||
| 40 | + String table, String payload, String description) { | ||
| 41 | + String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); | ||
| 42 | + jdbc.update( | ||
| 43 | + "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + | ||
| 44 | + "sPayload,sDescription,sStatus,tCreateDate) VALUES(?,?,?,?,?,?,?,?, 'draft', NOW())", | ||
| 45 | + sId, userId, opType, formId, moduleId, table, payload, description); | ||
| 46 | + audit.log(userId, null, "propose", table + " (create)", description, true, "draft staged (" + sId + ")"); | ||
| 47 | + return sId; | ||
| 48 | + } | ||
| 49 | + | ||
| 38 | /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ | 50 | /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ |
| 39 | public void attachConversation(String opId, String convId) { | 51 | public void attachConversation(String opId, String convId) { |
| 40 | jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); | 52 | jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); |
src/main/java/com/xly/service/SystemPromptService.java
| @@ -43,6 +43,8 @@ public class SystemPromptService { | @@ -43,6 +43,8 @@ public class SystemPromptService { | ||
| 43 | - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ | 43 | - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ |
| 44 | (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 | 44 | (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 |
| 45 | - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 | 45 | - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 |
| 46 | + - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ | ||
| 47 | + fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 | ||
| 46 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ | 48 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ |
| 47 | (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ | 49 | (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ |
| 48 | (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 | 50 | (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 |
| @@ -51,8 +53,8 @@ public class SystemPromptService { | @@ -51,8 +53,8 @@ public class SystemPromptService { | ||
| 51 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 | 53 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 |
| 52 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 | 54 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 |
| 53 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 | 55 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 |
| 54 | - 4. 写操作:改字段用 proposeUpdate、删除记录用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ | ||
| 55 | - 真正的修改/删除要用户在对话内点【确认】才发生,你**绝不能声称已经改好/删好**。新增单据 / 审核等其它写操作仍在开发中,如实告知。 | 56 | + 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ |
| 57 | + 真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。审核 / 过账等其它写操作仍在开发中,如实告知。 | ||
| 56 | 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ | 58 | 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ |
| 57 | 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 | 59 | 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 |
| 58 | """.formatted(renderDomainMap()); | 60 | """.formatted(renderDomainMap()); |
src/main/java/com/xly/tool/FormCollectTool.java
0 → 100644
| 1 | +package com.xly.tool; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.service.FormResolverService; | ||
| 5 | +import dev.langchain4j.agent.tool.P; | ||
| 6 | +import dev.langchain4j.agent.tool.Tool; | ||
| 7 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 8 | +import org.springframework.stereotype.Component; | ||
| 9 | + | ||
| 10 | +import java.util.ArrayList; | ||
| 11 | +import java.util.HashSet; | ||
| 12 | +import java.util.LinkedHashMap; | ||
| 13 | +import java.util.List; | ||
| 14 | +import java.util.Map; | ||
| 15 | +import java.util.Set; | ||
| 16 | + | ||
| 17 | +/** | ||
| 18 | + * FormCollect(表单呈现)—— 在对话框里渲染一张动态表单,让用户一次填多个字段(如新增记录时)。 | ||
| 19 | + * | ||
| 20 | + * <p>字段来自 ERP 配置的知识图谱({@code viw_kg_field_dict} 里该主表最常用的可填字段), | ||
| 21 | + * 前端把它们渲染成输入框;用户填完提交后,前端把「字段=值」拼成一条消息发回,agent 再走 | ||
| 22 | + * proposeCreate 生成待确认提议。整套 UI 在 xlyAi 自己的聊天前端完成,无需 ERP 前端配合。 | ||
| 23 | + */ | ||
| 24 | +@Component | ||
| 25 | +public class FormCollectTool { | ||
| 26 | + | ||
| 27 | + private final FormResolverService resolver; | ||
| 28 | + private final JdbcTemplate jdbc; | ||
| 29 | + private final ObjectMapper mapper; | ||
| 30 | + | ||
| 31 | + public FormCollectTool(FormResolverService resolver, JdbcTemplate jdbc, ObjectMapper mapper) { | ||
| 32 | + this.resolver = resolver; | ||
| 33 | + this.jdbc = jdbc; | ||
| 34 | + this.mapper = mapper; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + @Tool("在对话框里**渲染一张可填写的表单**,让用户一次性填写某实体的多个字段(用于新增记录时收集信息)。" | ||
| 38 | + + "调用后前端会显示表单;用户填完提交,你再据其内容用 proposeCreate 生成新增提议。") | ||
| 39 | + public String showForm(@P("实体类型,如 客户 / 物料") String entityKeyword) { | ||
| 40 | + if (entityKeyword == null || entityKeyword.isBlank()) { | ||
| 41 | + return "{\"error\":\"缺少实体类型\"}"; | ||
| 42 | + } | ||
| 43 | + Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); | ||
| 44 | + if (form == null) { | ||
| 45 | + return "{\"error\":\"找不到「" + entityKeyword + "」的主表\"}"; | ||
| 46 | + } | ||
| 47 | + String table = String.valueOf(form.get("sDataSource")); | ||
| 48 | + List<String> fields = new ArrayList<>(); | ||
| 49 | + Set<String> seen = new HashSet<>(); | ||
| 50 | + try { | ||
| 51 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 52 | + "SELECT sChinese, sField, MAX(iFormUses) u FROM viw_kg_field_dict WHERE sTable=? " + | ||
| 53 | + "AND sField NOT LIKE '%Id' AND sField NOT LIKE 'b%' AND sField<>'sToken' AND sField<>'sMakePerson' " + | ||
| 54 | + "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sChinese, sField ORDER BY u DESC LIMIT 10", table); | ||
| 55 | + for (Map<String, Object> r : rows) { | ||
| 56 | + Object zh = r.get("sChinese"); | ||
| 57 | + if (zh != null && seen.add(zh.toString()) && fields.size() < 8) { | ||
| 58 | + fields.add(zh.toString()); | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | + } catch (Exception ignore) { | ||
| 62 | + } | ||
| 63 | + if (fields.isEmpty()) { | ||
| 64 | + return "{\"error\":\"该实体没有可填字段\"}"; | ||
| 65 | + } | ||
| 66 | + Map<String, Object> out = new LinkedHashMap<>(); | ||
| 67 | + out.put("formCollect", true); | ||
| 68 | + out.put("entity", entityKeyword.trim()); | ||
| 69 | + out.put("fields", fields); | ||
| 70 | + out.put("message", "已在下方为你打开填写表单,请填好后点提交。"); | ||
| 71 | + try { | ||
| 72 | + return mapper.writeValueAsString(out); | ||
| 73 | + } catch (Exception e) { | ||
| 74 | + return "{\"error\":\"内部错误\"}"; | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | +} |
src/main/java/com/xly/tool/ProposeWriteTool.java
| @@ -10,9 +10,12 @@ import dev.langchain4j.agent.tool.Tool; | @@ -10,9 +10,12 @@ import dev.langchain4j.agent.tool.Tool; | ||
| 10 | import org.springframework.jdbc.core.JdbcTemplate; | 10 | import org.springframework.jdbc.core.JdbcTemplate; |
| 11 | import org.springframework.stereotype.Component; | 11 | import org.springframework.stereotype.Component; |
| 12 | 12 | ||
| 13 | +import java.util.ArrayList; | ||
| 14 | +import java.util.Iterator; | ||
| 13 | import java.util.LinkedHashMap; | 15 | import java.util.LinkedHashMap; |
| 14 | import java.util.List; | 16 | import java.util.List; |
| 15 | import java.util.Map; | 17 | import java.util.Map; |
| 18 | +import java.util.Set; | ||
| 16 | 19 | ||
| 17 | /** | 20 | /** |
| 18 | * ProposeWrite 工具(写操作,人在环)。 | 21 | * ProposeWrite 工具(写操作,人在环)。 |
| @@ -187,6 +190,115 @@ public class ProposeWriteTool { | @@ -187,6 +190,115 @@ public class ProposeWriteTool { | ||
| 187 | return toJson(out); | 190 | return toJson(out); |
| 188 | } | 191 | } |
| 189 | 192 | ||
| 193 | + @Tool("提议**新增**一条记录(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正新增。" | ||
| 194 | + + "给出实体类型 + 已知字段(JSON:字段中文名->值);主键与必填字段会自动补齐。") | ||
| 195 | + public String proposeCreate( | ||
| 196 | + @P("实体类型,如 客户 / 物料") String entityKeyword, | ||
| 197 | + @P("已知字段的 JSON,键=字段中文名、值=字段值,例如 {\"客户名称\":\"常州测试公司\",\"客户简称\":\"常测\"}") String fieldsJson) { | ||
| 198 | + | ||
| 199 | + if (isBlank(entityKeyword)) { | ||
| 200 | + return err("缺少实体类型。"); | ||
| 201 | + } | ||
| 202 | + Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 203 | + if (form == null) { | ||
| 204 | + return err("找不到「" + entityKeyword + "」对应的可新增主表。"); | ||
| 205 | + } | ||
| 206 | + String formId = str(form.get("sFormId")); | ||
| 207 | + String moduleId = str(form.get("sModuleId")); | ||
| 208 | + String table = str(form.get("sDataSource")); | ||
| 209 | + if (!authz.canAccessModule(moduleId)) { | ||
| 210 | + return err("你没有新增「" + entityKeyword + "」的权限。"); | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + Map<String, Object> col = new LinkedHashMap<>(); | ||
| 214 | + List<String> descParts = new ArrayList<>(); | ||
| 215 | + try { | ||
| 216 | + if (!isBlank(fieldsJson)) { | ||
| 217 | + JsonNode fj = mapper.readTree(fieldsJson.trim()); | ||
| 218 | + Iterator<Map.Entry<String, JsonNode>> it = fj.fields(); | ||
| 219 | + while (it.hasNext()) { | ||
| 220 | + Map.Entry<String, JsonNode> e = it.next(); | ||
| 221 | + String zh = e.getKey(); | ||
| 222 | + String v = e.getValue().asText(""); | ||
| 223 | + String colName = queryOne( | ||
| 224 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + | ||
| 225 | + "ORDER BY iFormUses DESC LIMIT 1", table, zh, "%" + zh + "%"); | ||
| 226 | + if (colName != null) { | ||
| 227 | + col.put(colName, v); | ||
| 228 | + descParts.add(zh + "=" + v); | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + } | ||
| 232 | + } catch (Exception ex) { | ||
| 233 | + return err("字段 JSON 解析失败:" + ex.getMessage()); | ||
| 234 | + } | ||
| 235 | + if (descParts.isEmpty()) { | ||
| 236 | + return err("请至少提供一个有效字段(如客户名称)。"); | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + // 自动补齐 NOT-NULL 无默认列(租户/制单人由 ERP 注入,跳过) | ||
| 240 | + for (String rc : requiredCols(table)) { | ||
| 241 | + if (col.containsKey(rc)) { | ||
| 242 | + continue; | ||
| 243 | + } | ||
| 244 | + if ("sId".equals(rc)) { | ||
| 245 | + col.put(rc, erp.newUuid()); | ||
| 246 | + } else if (rc.endsWith("Id")) { | ||
| 247 | + String d = commonValue(table, rc); // 外键:取现有最常见值兜底 | ||
| 248 | + col.put(rc, d == null ? "" : d); | ||
| 249 | + } else if (rc.endsWith("No")) { | ||
| 250 | + col.put(rc, "AI" + (System.currentTimeMillis() % 1000000000L)); | ||
| 251 | + } else { | ||
| 252 | + col.put(rc, ""); | ||
| 253 | + } | ||
| 254 | + } | ||
| 255 | + col.putIfAbsent("sId", erp.newUuid()); | ||
| 256 | + | ||
| 257 | + String payload; | ||
| 258 | + try { | ||
| 259 | + payload = mapper.writeValueAsString(col); | ||
| 260 | + } catch (Exception e) { | ||
| 261 | + return err("内部错误:" + e.getMessage()); | ||
| 262 | + } | ||
| 263 | + String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); | ||
| 264 | + String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description); | ||
| 265 | + | ||
| 266 | + Map<String, Object> out = new LinkedHashMap<>(); | ||
| 267 | + out.put("opId", opId); | ||
| 268 | + out.put("summary", description); | ||
| 269 | + out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。"); | ||
| 270 | + return toJson(out); | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ | ||
| 274 | + private List<String> requiredCols(String table) { | ||
| 275 | + List<String> out = new ArrayList<>(); | ||
| 276 | + try { | ||
| 277 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 278 | + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " + | ||
| 279 | + "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table); | ||
| 280 | + Set<String> skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson"); | ||
| 281 | + for (Map<String, Object> r : rows) { | ||
| 282 | + String c = str(r.get("COLUMN_NAME")); | ||
| 283 | + if (c != null && !skip.contains(c)) { | ||
| 284 | + out.add(c); | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + } catch (Exception ignore) { | ||
| 288 | + } | ||
| 289 | + return out; | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + /** 外键兜底:该列现有的最常见非空值。列名来自 KG/info_schema(可信)。 */ | ||
| 293 | + private String commonValue(String table, String col) { | ||
| 294 | + try { | ||
| 295 | + return queryOne("SELECT `" + col + "` FROM `" + table + "` WHERE `" + col + "` IS NOT NULL AND `" + col + | ||
| 296 | + "`<>'' GROUP BY `" + col + "` ORDER BY COUNT(*) DESC LIMIT 1"); | ||
| 297 | + } catch (Exception e) { | ||
| 298 | + return null; | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + | ||
| 190 | /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ | 302 | /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ |
| 191 | private Map<String, Object> resolveForm(String entityKeyword) { | 303 | private Map<String, Object> resolveForm(String entityKeyword) { |
| 192 | try { | 304 | try { |
src/main/java/com/xly/tool/QueryTool.java
| @@ -42,38 +42,63 @@ public class QueryTool { | @@ -42,38 +42,63 @@ public class QueryTool { | ||
| 42 | return "请描述要查询统计的内容。"; | 42 | return "请描述要查询统计的内容。"; |
| 43 | } | 43 | } |
| 44 | String hint = schemaHint(question); | 44 | String hint = schemaHint(question); |
| 45 | - String prompt = """ | 45 | + String sql = null; |
| 46 | + String lastErr = null; | ||
| 47 | + // 自修复重试:SQL 校验/执行报错就把错误喂回模型重新生成,最多 3 次 | ||
| 48 | + for (int attempt = 0; attempt < 3; attempt++) { | ||
| 49 | + try { | ||
| 50 | + sql = cleanSql(sqlModel.chat(buildPrompt(hint, question, sql, lastErr))); | ||
| 51 | + } catch (Exception e) { | ||
| 52 | + return "生成查询失败:" + e.getMessage(); | ||
| 53 | + } | ||
| 54 | + String reject = validate(sql); | ||
| 55 | + if (reject != null) { | ||
| 56 | + lastErr = reject; | ||
| 57 | + if (attempt < 2) { | ||
| 58 | + continue; | ||
| 59 | + } | ||
| 60 | + audit.log(null, null, "query", "REJECTED", sql, false, reject); | ||
| 61 | + return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; | ||
| 62 | + } | ||
| 63 | + String limited = forceLimit(sql); | ||
| 64 | + try { | ||
| 65 | + List<Map<String, Object>> rows = jdbc.queryForList(limited); | ||
| 66 | + audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); | ||
| 67 | + return formatRows(rows); | ||
| 68 | + } catch (Exception e) { | ||
| 69 | + lastErr = rootMsg(e); | ||
| 70 | + if (attempt < 2) { | ||
| 71 | + continue; // 下一轮把错误喂回模型自修复 | ||
| 72 | + } | ||
| 73 | + audit.log(null, null, "query", "fail", limited, false, lastErr); | ||
| 74 | + return "查询执行失败(已尝试自修复):" + lastErr; | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + return "查询失败。"; | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + private String buildPrompt(String hint, String question, String prevSql, String prevErr) { | ||
| 81 | + String repair = (prevSql == null || prevErr == null) ? "" : | ||
| 82 | + "\n\n上一条 SQL:\n" + prevSql + "\n执行/校验报错:" + prevErr + | ||
| 83 | + "\n请**修正该错误**后重新生成一条正确的 SELECT(注意用对表名列名、别用中文别名)。"; | ||
| 84 | + return """ | ||
| 46 | 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 | 85 | 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 |
| 47 | 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): | 86 | 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): |
| 48 | %s | 87 | %s |
| 49 | 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); | 88 | 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); |
| 50 | **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; | 89 | **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; |
| 51 | 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 | 90 | 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 |
| 52 | - 问题:%s | ||
| 53 | - """.formatted(hint, question); | ||
| 54 | - | ||
| 55 | - String sql; | ||
| 56 | - try { | ||
| 57 | - sql = cleanSql(sqlModel.chat(prompt)); | ||
| 58 | - } catch (Exception e) { | ||
| 59 | - return "生成查询失败:" + e.getMessage(); | ||
| 60 | - } | ||
| 61 | - | ||
| 62 | - String reject = validate(sql); | ||
| 63 | - if (reject != null) { | ||
| 64 | - audit.log(null, null, "query", "REJECTED", sql, false, reject); | ||
| 65 | - return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; | ||
| 66 | - } | ||
| 67 | - sql = forceLimit(sql); | 91 | + 问题:%s%s |
| 92 | + """.formatted(hint, question, repair); | ||
| 93 | + } | ||
| 68 | 94 | ||
| 69 | - try { | ||
| 70 | - List<Map<String, Object>> rows = jdbc.queryForList(sql); | ||
| 71 | - audit.log(null, null, "query", "ok", sql, true, "rows=" + rows.size()); | ||
| 72 | - return formatRows(rows); | ||
| 73 | - } catch (Exception e) { | ||
| 74 | - audit.log(null, null, "query", "fail", sql, false, e.getMessage()); | ||
| 75 | - return "查询执行失败:" + e.getMessage(); | 95 | + private String rootMsg(Throwable e) { |
| 96 | + Throwable r = e; | ||
| 97 | + while (r.getCause() != null && r.getCause() != r) { | ||
| 98 | + r = r.getCause(); | ||
| 76 | } | 99 | } |
| 100 | + String m = r.getMessage(); | ||
| 101 | + return m == null ? e.toString() : (m.length() > 300 ? m.substring(0, 300) : m); | ||
| 77 | } | 102 | } |
| 78 | 103 | ||
| 79 | /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ | 104 | /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ |
src/main/java/com/xly/web/AgentChatController.java
| @@ -94,7 +94,8 @@ public class AgentChatController { | @@ -94,7 +94,8 @@ public class AgentChatController { | ||
| 94 | send(emitter, "reset", ""); | 94 | send(emitter, "reset", ""); |
| 95 | try { | 95 | try { |
| 96 | String toolName = te.request() == null ? "" : te.request().name(); | 96 | String toolName = te.request() == null ? "" : te.request().name(); |
| 97 | - if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)) && te.result() != null) { | 97 | + if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName) |
| 98 | + || "proposeCreate".equals(toolName)) && te.result() != null) { | ||
| 98 | JsonNode r = mapper.readTree(te.result()); | 99 | JsonNode r = mapper.readTree(te.result()); |
| 99 | String opId = r.path("opId").asText(null); | 100 | String opId = r.path("opId").asText(null); |
| 100 | if (opId != null && !opId.isBlank()) { | 101 | if (opId != null && !opId.isBlank()) { |
src/main/java/com/xly/web/OpController.java
| 1 | package com.xly.web; | 1 | package com.xly.web; |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | import com.xly.service.AuditService; | 5 | import com.xly.service.AuditService; |
| 5 | import com.xly.service.ErpClient; | 6 | import com.xly.service.ErpClient; |
| 6 | import com.xly.service.OpService; | 7 | import com.xly.service.OpService; |
| @@ -31,11 +32,13 @@ public class OpController { | @@ -31,11 +32,13 @@ public class OpController { | ||
| 31 | private final OpService ops; | 32 | private final OpService ops; |
| 32 | private final ErpClient erp; | 33 | private final ErpClient erp; |
| 33 | private final AuditService audit; | 34 | private final AuditService audit; |
| 35 | + private final ObjectMapper mapper; | ||
| 34 | 36 | ||
| 35 | - public OpController(OpService ops, ErpClient erp, AuditService audit) { | 37 | + public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper) { |
| 36 | this.ops = ops; | 38 | this.ops = ops; |
| 37 | this.erp = erp; | 39 | this.erp = erp; |
| 38 | this.audit = audit; | 40 | this.audit = audit; |
| 41 | + this.mapper = mapper; | ||
| 39 | } | 42 | } |
| 40 | 43 | ||
| 41 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ | 44 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ |
| @@ -62,7 +65,11 @@ public class OpController { | @@ -62,7 +65,11 @@ public class OpController { | ||
| 62 | try { | 65 | try { |
| 63 | String opType = str(op.get("sOpType")); | 66 | String opType = str(op.get("sOpType")); |
| 64 | JsonNode r; | 67 | JsonNode r; |
| 65 | - if ("delete".equals(opType)) { | 68 | + if ("create".equals(opType)) { |
| 69 | + @SuppressWarnings("unchecked") | ||
| 70 | + Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class); | ||
| 71 | + r = erp.createForm(str(op.get("sTargetTable")), columns); | ||
| 72 | + } else if ("delete".equals(opType)) { | ||
| 66 | r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); | 73 | r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); |
| 67 | } else { | 74 | } else { |
| 68 | r = erp.updateForm( | 75 | r = erp.updateForm( |