Commit a4e1a1a76e22327503ececd580e4f5e2313c89bc

Authored by zichun
1 parent 7e0c436f

fix(quote-create): collectForm shows real entry fields + proposeCreate type/FK-aware real write

Root cause of the reported failure:
- collectForm sourced fields from gdsconfigformslave -> quote master's form config only exposes 3 system/header fields (失效时间/制单人/单据日期); user filled 单据日期(tCreateDate,datetime)='12' -> ERP type mismatch.
- proposeCreate never coerced by column type, never FK-resolved names, used addBusinessData (no billNo).

Fixes:
- FormResolverService: businessFields() curated authoritative label->{col,fk} for quoquotationmaster (客户名称/产品名称/数量/长/宽/高/单价 -> real writable cols sCustomerId/sProductId(FK)/dQty/dLength/dWidth/dHeight/dPrice), field-dict fallback for other tables; columnTypes/coerce/typeDefault/resolveFk/nextBillNo(BJD+YYYYMM+seq); isSystemColumn excludes reserve/money/rate/discount/person/date/bool/tenant/maker; resolveMasterForm excludes param/slave/control/config/color secondary tables.
- collectForm: fields from businessFields (excludes system/header); gdsconfigformslave fallback.
- proposeCreate: label->{col,fk} via businessFields; FK name->id; type-coerce; skip system cols; auto sId/sFormId + generated sBillNo; type-correct required defaults.
- ErpClient.createForm: addUpdateDelBusinessData(handleType=add)+moduleId (was addBusinessData).
- ProposeWriteTool.resolveForm unified to FormResolverService.

Verified via agent end-to-end (real write): collectForm shows correct fields; created quoquotationmaster BJD202607082 = Little Antelope / 妇科三醇乳膏 / qty5000 / dims / price, tenant+maker+date auto-injected.
src/main/java/com/xly/config/AgentFactory.java
@@ -103,7 +103,7 @@ public class AgentFactory { @@ -103,7 +103,7 @@ public class AgentFactory {
103 skillTool, 103 skillTool,
104 interactionTool, 104 interactionTool,
105 new ErpReadTool(erp, jdbc, resolver, identity), 105 new ErpReadTool(erp, jdbc, resolver, identity),
106 - new ProposeWriteTool(erp, jdbc, ops, mapper, identity), 106 + new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
107 new QueryTool(sqlModel, jdbc, audit, identity), 107 new QueryTool(sqlModel, jdbc, audit, identity),
108 new FormCollectTool(erp, jdbc, resolver, identity, mapper)) 108 new FormCollectTool(erp, jdbc, resolver, identity, mapper))
109 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() 109 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
src/main/java/com/xly/service/ErpClient.java
@@ -279,31 +279,31 @@ public class ErpClient { @@ -279,31 +279,31 @@ public class ErpClient {
279 } 279 }
280 } 280 }
281 281
282 - /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */  
283 - public JsonNode createForm(String table, Map<String, Object> columns) {  
284 - return createForm(null, table, columns);  
285 - }  
286 -  
287 - public JsonNode createForm(String authToken, String table, Map<String, Object> columns) {  
288 - JsonNode root = doCreate(table, columns, resolveToken(authToken)); 282 + /**
  283 + * 新增一条记录。走 {@code addUpdateDelBusinessData?sModelsId=}(handleType=add)——与 ERP 前端一致,
  284 + * 复用校验/单号/租户;{@code columns} 里已由 ProposeWrite 备好 sId/sFormId/sBillNo 与类型化字段值。
  285 + * 会话过期自动重登重试(仅 dev-login)。
  286 + */
  287 + public JsonNode createForm(String authToken, String moduleId, String table, Map<String, Object> columns) {
  288 + JsonNode root = doCreate(moduleId, table, columns, resolveToken(authToken));
289 if (root.path("code").asInt() == -2 && canRelogin(authToken)) { 289 if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
290 login(); 290 login();
291 - root = doCreate(table, columns, resolveToken(authToken)); 291 + root = doCreate(moduleId, table, columns, resolveToken(authToken));
292 } 292 }
293 return root; 293 return root;
294 } 294 }
295 295
296 - private JsonNode doCreate(String table, Map<String, Object> columns, String tok) { 296 + private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) {
297 try { 297 try {
298 - String url = baseUrl + "/business/addBusinessData"; 298 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  299 + Map<String, Object> col = new LinkedHashMap<>(columns);
  300 + col.put("handleType", "add");
299 Map<String, Object> dataItem = new LinkedHashMap<>(); 301 Map<String, Object> dataItem = new LinkedHashMap<>();
300 dataItem.put("sTable", table); 302 dataItem.put("sTable", table);
301 - dataItem.put("column", columns); 303 + dataItem.put("name", "master");
  304 + dataItem.put("column", List.of(col));
302 Map<String, Object> body = new LinkedHashMap<>(); 305 Map<String, Object> body = new LinkedHashMap<>();
303 - body.put("sMakePerson", username);  
304 - body.put("sBrandsId", brand);  
305 - body.put("sSubsidiaryId", subsidiary);  
306 - body.put("sLanguage", "sChinese"); 306 + body.put("sModelsId", moduleId);
307 body.put("data", List.of(dataItem)); 307 body.put("data", List.of(dataItem));
308 HttpRequest req = HttpRequest.newBuilder(URI.create(url)) 308 HttpRequest req = HttpRequest.newBuilder(URI.create(url))
309 .header("Content-Type", "application/json;charset=UTF-8") 309 .header("Content-Type", "application/json;charset=UTF-8")
src/main/java/com/xly/service/FormResolverService.java
@@ -3,9 +3,16 @@ package com.xly.service; @@ -3,9 +3,16 @@ package com.xly.service;
3 import org.springframework.jdbc.core.JdbcTemplate; 3 import org.springframework.jdbc.core.JdbcTemplate;
4 import org.springframework.stereotype.Service; 4 import org.springframework.stereotype.Service;
5 5
  6 +import java.text.SimpleDateFormat;
  7 +import java.util.ArrayList;
  8 +import java.util.Date;
6 import java.util.HashMap; 9 import java.util.HashMap;
  10 +import java.util.LinkedHashMap;
7 import java.util.List; 11 import java.util.List;
8 import java.util.Map; 12 import java.util.Map;
  13 +import java.util.Set;
  14 +import java.util.regex.Matcher;
  15 +import java.util.regex.Pattern;
9 16
10 /** 17 /**
11 * 表单 / 字段解析(KgSearch 内核)—— 把「实体类型 / 字段中文名」解析到具体的 18 * 表单 / 字段解析(KgSearch 内核)—— 把「实体类型 / 字段中文名」解析到具体的
@@ -36,6 +43,10 @@ public class FormResolverService { @@ -36,6 +43,10 @@ public class FormResolverService {
36 "SELECT af.sFormId, af.sModuleId, af.sDataSource, af.sFormTitle FROM viw_ai_useful_forms af " + 43 "SELECT af.sFormId, af.sModuleId, af.sDataSource, af.sFormTitle FROM viw_ai_useful_forms af " +
37 "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + 44 "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " +
38 "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' AND af.sDataSource NOT LIKE 'viw%' " + 45 "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' AND af.sDataSource NOT LIKE 'viw%' " +
  46 + // 排除明显的从属/参数表(颜色表/控制表/从表/多数量/配置),避免误定位到非主表
  47 + "AND af.sDataSource NOT LIKE '%param' AND af.sDataSource NOT LIKE '%slave' " +
  48 + "AND af.sDataSource NOT LIKE '%control' AND af.sDataSource NOT LIKE '%config' " +
  49 + "AND af.sDataSource NOT LIKE '%manyqtys' AND af.sDataSource NOT LIKE '%color%' " +
39 "ORDER BY COALESCE(f.bAiTool,0) DESC, " + 50 "ORDER BY COALESCE(f.bAiTool,0) DESC, " +
40 "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1", 51 "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1",
41 "%" + entityKeyword.trim() + "%"); 52 "%" + entityKeyword.trim() + "%");
@@ -85,6 +96,199 @@ public class FormResolverService { @@ -85,6 +96,199 @@ public class FormResolverService {
85 return f; 96 return f;
86 } 97 }
87 98
  99 + // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。
  100 + private static final Set<String> SYS_EXACT = Set.of(
  101 + "sId", "sBrandsId", "sSubsidiaryId", "sMakePerson", "sFormId", "sBillNo", "iIncrement", "iOrder",
  102 + "tCreateDate", "tMakeDate", "tUpdateDate", "sModelsId", "sToken", "sParentId");
  103 + private static final Pattern NUM = Pattern.compile("-?\\d+(\\.\\d+)?");
  104 +
  105 + /** 是否为系统/审计/计算列(新增表单不呈现、payload 不让 LLM 直填)。 */
  106 + public boolean isSystemColumn(String col) {
  107 + if (col == null || col.isBlank()) {
  108 + return true;
  109 + }
  110 + if (SYS_EXACT.contains(col)) {
  111 + return true;
  112 + }
  113 + // 布尔标志 bXxx / 日期 tXxx / 金额·折扣·比率计算列 / 各种经办人
  114 + if (col.length() > 1 && (col.charAt(0) == 'b' || col.charAt(0) == 't') && Character.isUpperCase(col.charAt(1))) {
  115 + return true;
  116 + }
  117 + if (col.contains("Money") || col.contains("Rate") || col.contains("Discount") || col.endsWith("Person")
  118 + || col.contains("Reserve") || col.contains("Manual")) {
  119 + return true;
  120 + }
  121 + return false;
  122 + }
  123 +
  124 + /**
  125 + * 复杂旗舰表单的**人工策展录入字段**(label→真实可写列 + 外键)。报价主表字段名与显示别名不一致
  126 + * (表单里 sProductName/dProductQty 并非真实列),且核心列的 iFormUses 很低,纯靠字段字典排不出来——
  127 + * 故对报价主表策展这组真实可写列(与实测能成功写入的一致)。其它表返回 null,走通用字段字典逻辑。
  128 + */
  129 + private List<Map<String, Object>> curatedFields(String table) {
  130 + if (table == null) {
  131 + return null;
  132 + }
  133 + if (table.equalsIgnoreCase("quoquotationmaster")) {
  134 + List<Map<String, Object>> l = new ArrayList<>();
  135 + l.add(bf("sCustomerId", "客户名称", "elecustomer"));
  136 + l.add(bf("sProductId", "产品名称", "eleproduct"));
  137 + l.add(bf("dQty", "数量", null));
  138 + l.add(bf("dLength", "长(L)", null));
  139 + l.add(bf("dWidth", "宽(W)", null));
  140 + l.add(bf("dHeight", "高(D)", null));
  141 + l.add(bf("dPrice", "单价", null));
  142 + return l;
  143 + }
  144 + return null;
  145 + }
  146 +
  147 + private Map<String, Object> bf(String col, String label, String fk) {
  148 + Map<String, Object> m = new LinkedHashMap<>();
  149 + m.put("col", col);
  150 + m.put("label", label);
  151 + if (fk != null) {
  152 + m.put("fk", fk);
  153 + }
  154 + return m;
  155 + }
  156 +
  157 + /** 表列 -> MySQL 数据类型(information_schema)。 */
  158 + public Map<String, String> columnTypes(String table) {
  159 + Map<String, String> m = new HashMap<>();
  160 + try {
  161 + for (Map<String, Object> r : jdbc.queryForList(
  162 + "SELECT COLUMN_NAME c, DATA_TYPE d FROM information_schema.COLUMNS " +
  163 + "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", table)) {
  164 + m.put(String.valueOf(r.get("c")), String.valueOf(r.get("d")).toLowerCase());
  165 + }
  166 + } catch (Exception ignore) {
  167 + }
  168 + return m;
  169 + }
  170 +
  171 + /** 按列类型把用户给的字符串强转成合法值:数值取数字、日期非法则丢弃(交 ERP 默认)、bit→0/1、其余原样。 */
  172 + public Object coerce(String dataType, String v) {
  173 + if (v == null) {
  174 + return null;
  175 + }
  176 + String t = dataType == null ? "varchar" : dataType.toLowerCase();
  177 + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
  178 + Matcher mm = NUM.matcher(v);
  179 + return mm.find() ? mm.group() : "0";
  180 + }
  181 + if (t.contains("date") || t.contains("time")) {
  182 + return v.matches("\\d{4}-\\d{2}-\\d{2}.*") ? v : null; // 非日期 → null,让 ERP 用默认值
  183 + }
  184 + if (t.equals("bit") || t.equals("tinyint")) {
  185 + return (v.equals("1") || v.equalsIgnoreCase("true") || v.contains("是")) ? 1 : 0;
  186 + }
  187 + return v;
  188 + }
  189 +
  190 + /** 类型默认值(自动补 NOT-NULL 无默认列用)。 */
  191 + public Object typeDefault(String col, String dataType) {
  192 + String t = dataType == null ? "varchar" : dataType.toLowerCase();
  193 + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
  194 + return 0;
  195 + }
  196 + return ""; // date/time 交 ERP 默认,不主动补
  197 + }
  198 +
  199 + /**
  200 + * 某实体主表的**业务录入字段**(字段字典里有中文名、且非系统/计算列),按使用度排序,最多 limit 个。
  201 + * 这是 collectForm 呈现给用户填的字段来源——比 gdsconfigformslave 的界面布局字段更贴近"要填的业务参数"。
  202 + * FK 列(有 sFkTable)标记 fk,前端让用户填名称、写入时解析成 id。
  203 + */
  204 + public List<Map<String, Object>> businessFields(String table, int limit) {
  205 + List<Map<String, Object>> curated = curatedFields(table);
  206 + if (curated != null) {
  207 + return curated;
  208 + }
  209 + List<Map<String, Object>> out = new ArrayList<>();
  210 + try {
  211 + List<Map<String, Object>> rows = jdbc.queryForList(
  212 + "SELECT sField, " +
  213 + // 标签取非Id、更短的中文名(客户 优先于 客户Id)
  214 + "SUBSTRING_INDEX(GROUP_CONCAT(sChinese ORDER BY (sChinese LIKE '%Id') ASC, CHAR_LENGTH(sChinese) ASC SEPARATOR 0x1f),0x1f,1) zh, " +
  215 + "MAX(sFkTable) fk, SUM(iFormUses) u FROM viw_kg_field_dict " +
  216 + "WHERE sTable=? AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY u DESC", table);
  217 + for (Map<String, Object> r : rows) {
  218 + String col = String.valueOf(r.get("sField"));
  219 + if (isSystemColumn(col)) {
  220 + continue;
  221 + }
  222 + Map<String, Object> f = new LinkedHashMap<>();
  223 + f.put("col", col);
  224 + f.put("label", r.get("zh"));
  225 + Object fk = r.get("fk");
  226 + if (fk != null && !"null".equalsIgnoreCase(String.valueOf(fk)) && !String.valueOf(fk).isBlank()) {
  227 + f.put("fk", String.valueOf(fk));
  228 + }
  229 + out.add(f);
  230 + if (out.size() >= limit) {
  231 + break;
  232 + }
  233 + }
  234 + } catch (Exception ignore) {
  235 + }
  236 + return out;
  237 + }
  238 +
  239 + /** 外键名称 -> id:在外键表按名称字段模糊匹配(元数据来源可信,非用户拼 SQL)。 */
  240 + public String resolveFk(String fkTable, String name) {
  241 + if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank()) {
  242 + return null;
  243 + }
  244 + String nameField = resolveNameField(fkTable);
  245 + if (nameField == null) {
  246 + return null;
  247 + }
  248 + return queryOne("SELECT sId FROM `" + fkTable + "` WHERE `" + nameField + "` LIKE ? " +
  249 + "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", "%" + name.trim() + "%");
  250 + }
  251 +
  252 + /**
  253 + * 若该表有 NOT-NULL 的 sBillNo 列,按现有单号规律生成下一个单号;否则返回 null。
  254 + * 规律 = 现有最新单号的前缀(字母)+ 年月(YYYYMM) + 递增序号(3位),如 BJD202607082。
  255 + */
  256 + public String nextBillNo(String table, String brand) {
  257 + try {
  258 + Integer has = jdbc.queryForObject(
  259 + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " +
  260 + "AND TABLE_NAME=? AND COLUMN_NAME='sBillNo' AND IS_NULLABLE='NO'", Integer.class, table);
  261 + if (has == null || has == 0) {
  262 + return null;
  263 + }
  264 + String recent = queryOne("SELECT sBillNo FROM `" + table + "` WHERE sBrandsId=? AND IFNULL(sBillNo,'')<>'' " +
  265 + "ORDER BY tCreateDate DESC LIMIT 1", brand);
  266 + String prefix = "AI";
  267 + if (recent != null && !recent.isBlank()) {
  268 + int i = 0;
  269 + while (i < recent.length() && !Character.isDigit(recent.charAt(i))) {
  270 + i++;
  271 + }
  272 + if (i > 0) {
  273 + prefix = recent.substring(0, i);
  274 + }
  275 + }
  276 + String ym = new SimpleDateFormat("yyyyMM").format(new Date());
  277 + Integer seq;
  278 + try {
  279 + seq = jdbc.queryForObject(
  280 + "SELECT IFNULL(MAX(CAST(SUBSTRING(sBillNo, ?) AS UNSIGNED)),0)+1 FROM `" + table + "` " +
  281 + "WHERE sBrandsId=? AND sBillNo LIKE ?",
  282 + Integer.class, prefix.length() + ym.length() + 1, brand, prefix + ym + "%");
  283 + } catch (Exception e) {
  284 + seq = 1;
  285 + }
  286 + return prefix + ym + String.format("%03d", seq == null ? 1 : seq);
  287 + } catch (Exception e) {
  288 + return null;
  289 + }
  290 + }
  291 +
88 private String queryOne(String sql, Object... args) { 292 private String queryOne(String sql, Object... args) {
89 try { 293 try {
90 List<Map<String, Object>> r = jdbc.queryForList(sql, args); 294 List<Map<String, Object>> r = jdbc.queryForList(sql, args);
src/main/java/com/xly/tool/FormCollectTool.java
@@ -58,42 +58,62 @@ public class FormCollectTool { @@ -58,42 +58,62 @@ public class FormCollectTool {
58 return err("你没有新建「" + entityKeyword + "」的权限。"); 58 return err("你没有新建「" + entityKeyword + "」的权限。");
59 } 59 }
60 60
61 - List<Map<String, Object>> cols;  
62 - try {  
63 - cols = jdbc.queryForList(  
64 - "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " +  
65 - "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " +  
66 - "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);  
67 - } catch (Exception e) {  
68 - return err("读取表单结构失败:" + e.getMessage());  
69 - }  
70 - if (cols.isEmpty()) {  
71 - return err("该表单没有可填写的字段配置。");  
72 - }  
73 - 61 + String table = String.valueOf(form.get("sDataSource"));
74 List<Map<String, Object>> fields = new ArrayList<>(); 62 List<Map<String, Object>> fields = new ArrayList<>();
75 - for (Map<String, Object> c : cols) {  
76 - String name = str(c.get("sName"));  
77 - if (name == null || name.isBlank()) { 63 +
  64 + // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的
  65 + // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。
  66 + List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS);
  67 + for (Map<String, Object> b : biz) {
  68 + String col = str(b.get("col"));
  69 + if (col == null || col.isBlank()) {
78 continue; 70 continue;
79 } 71 }
80 Map<String, Object> f = new LinkedHashMap<>(); 72 Map<String, Object> f = new LinkedHashMap<>();
81 - f.put("name", name);  
82 - f.put("label", firstNonBlank(str(c.get("sChinese")), name));  
83 - f.put("control", firstNonBlank(str(c.get("sControlName")), "text"));  
84 - f.put("required", truthy(c.get("bNotEmpty")));  
85 - String def = str(c.get("sDefault"));  
86 - if (def != null && !def.isBlank()) {  
87 - f.put("default", def);  
88 - }  
89 - List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));  
90 - if (!opts.isEmpty()) {  
91 - f.put("options", opts); 73 + f.put("name", col);
  74 + f.put("label", firstNonBlank(str(b.get("label")), col));
  75 + f.put("control", "text");
  76 + f.put("required", false);
  77 + Object fk = b.get("fk");
  78 + if (fk != null && !String.valueOf(fk).isBlank()) {
  79 + f.put("fk", true); // 外键字段:让用户填名称,写入时解析成 id
92 } 80 }
93 fields.add(f); 81 fields.add(f);
94 } 82 }
  83 +
  84 + // 兜底:字段字典没有该表映射时,退回 gdsconfigformslave 的非只读可见字段(排除系统列)。
  85 + if (fields.isEmpty()) {
  86 + try {
  87 + List<Map<String, Object>> cols = jdbc.queryForList(
  88 + "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " +
  89 + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " +
  90 + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
  91 + for (Map<String, Object> c : cols) {
  92 + String name = str(c.get("sName"));
  93 + if (name == null || name.isBlank() || resolver.isSystemColumn(name)) {
  94 + continue;
  95 + }
  96 + Map<String, Object> f = new LinkedHashMap<>();
  97 + f.put("name", name);
  98 + f.put("label", firstNonBlank(str(c.get("sChinese")), name));
  99 + f.put("control", firstNonBlank(str(c.get("sControlName")), "text"));
  100 + f.put("required", truthy(c.get("bNotEmpty")));
  101 + String def = str(c.get("sDefault"));
  102 + if (def != null && !def.isBlank()) {
  103 + f.put("default", def);
  104 + }
  105 + List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));
  106 + if (!opts.isEmpty()) {
  107 + f.put("options", opts);
  108 + }
  109 + fields.add(f);
  110 + }
  111 + } catch (Exception e) {
  112 + return err("读取表单结构失败:" + e.getMessage());
  113 + }
  114 + }
95 if (fields.isEmpty()) { 115 if (fields.isEmpty()) {
96 - return err("该表单没有可填写的字段。"); 116 + return err("该表单没有可填写的业务字段。");
97 } 117 }
98 118
99 Map<String, Object> out = new LinkedHashMap<>(); 119 Map<String, Object> out = new LinkedHashMap<>();
src/main/java/com/xly/tool/ProposeWriteTool.java
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
5 import com.xly.agent.AgentIdentity; 5 import com.xly.agent.AgentIdentity;
6 import com.xly.service.ErpClient; 6 import com.xly.service.ErpClient;
  7 +import com.xly.service.FormResolverService;
7 import com.xly.service.OpService; 8 import com.xly.service.OpService;
8 import dev.langchain4j.agent.tool.P; 9 import dev.langchain4j.agent.tool.P;
9 import dev.langchain4j.agent.tool.Tool; 10 import dev.langchain4j.agent.tool.Tool;
@@ -30,13 +31,16 @@ public class ProposeWriteTool { @@ -30,13 +31,16 @@ public class ProposeWriteTool {
30 private final OpService ops; 31 private final OpService ops;
31 private final ObjectMapper mapper; 32 private final ObjectMapper mapper;
32 private final AgentIdentity identity; 33 private final AgentIdentity identity;
  34 + private final FormResolverService resolver;
33 35
34 - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AgentIdentity identity) { 36 + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper,
  37 + AgentIdentity identity, FormResolverService resolver) {
35 this.erp = erp; 38 this.erp = erp;
36 this.jdbc = jdbc; 39 this.jdbc = jdbc;
37 this.ops = ops; 40 this.ops = ops;
38 this.mapper = mapper; 41 this.mapper = mapper;
39 this.identity = identity; 42 this.identity = identity;
  43 + this.resolver = resolver;
40 } 44 }
41 45
42 @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" 46 @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——"
@@ -208,6 +212,12 @@ public class ProposeWriteTool { @@ -208,6 +212,12 @@ public class ProposeWriteTool {
208 return err("你没有新增「" + entityKeyword + "」的权限。"); 212 return err("你没有新增「" + entityKeyword + "」的权限。");
209 } 213 }
210 214
  215 + Map<String, String> types = resolver.columnTypes(table);
  216 + // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射
  217 + Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
  218 + for (Map<String, Object> bfm : resolver.businessFields(table, 40)) {
  219 + labelMap.put(String.valueOf(bfm.get("label")), bfm);
  220 + }
211 Map<String, Object> col = new LinkedHashMap<>(); 221 Map<String, Object> col = new LinkedHashMap<>();
212 List<String> descParts = new ArrayList<>(); 222 List<String> descParts = new ArrayList<>();
213 try { 223 try {
@@ -218,39 +228,58 @@ public class ProposeWriteTool { @@ -218,39 +228,58 @@ public class ProposeWriteTool {
218 Map.Entry<String, JsonNode> e = it.next(); 228 Map.Entry<String, JsonNode> e = it.next();
219 String zh = e.getKey(); 229 String zh = e.getKey();
220 String v = e.getValue().asText(""); 230 String v = e.getValue().asText("");
221 - String colName = queryOne(  
222 - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " +  
223 - "ORDER BY iFormUses DESC LIMIT 1", table, zh, "%" + zh + "%");  
224 - if (colName != null) {  
225 - col.put(colName, v);  
226 - descParts.add(zh + "=" + v); 231 + if (isBlank(v)) {
  232 + continue;
  233 + }
  234 + Map<String, Object> fm = labelMap.get(zh);
  235 + if (fm == null) {
  236 + fm = resolveColumn(table, zh); // 回退字段字典
  237 + }
  238 + if (fm == null) {
  239 + continue; // 该实体没有这个字段,忽略
227 } 240 }
  241 + String colName = str(fm.get("col"));
  242 + // 别让 LLM/用户直填系统列(制单人/单据日期/租户/单号…),交给 ERP 与下方系统列处理
  243 + if (resolver.isSystemColumn(colName)) {
  244 + continue;
  245 + }
  246 + String fk = str(fm.get("fk"));
  247 + if (fk != null && !fk.isBlank()) {
  248 + String id = resolver.resolveFk(fk, v); // 外键:名称 -> id
  249 + if (id == null) {
  250 + return err("找不到名为「" + v + "」的" + zh + ",请确认该" + zh + "是否已存在。");
  251 + }
  252 + col.put(colName, id);
  253 + } else {
  254 + Object cv = resolver.coerce(types.get(colName), v); // 按列类型强转
  255 + if (cv != null) {
  256 + col.put(colName, cv);
  257 + }
  258 + }
  259 + descParts.add(zh + "=" + v);
228 } 260 }
229 } 261 }
230 } catch (Exception ex) { 262 } catch (Exception ex) {
231 - return err("字段 JSON 解析失败:" + ex.getMessage()); 263 + return err("字段解析失败:" + ex.getMessage());
232 } 264 }
233 if (descParts.isEmpty()) { 265 if (descParts.isEmpty()) {
234 return err("请至少提供一个有效字段(如客户名称)。"); 266 return err("请至少提供一个有效字段(如客户名称)。");
235 } 267 }
236 268
237 - // 自动补齐 NOT-NULL 无默认列(租户/制单人由 ERP 注入,跳过 269 + // 自动补齐 NOT-NULL 无默认列(系统列跳过;其余按类型给默认 0/''
238 for (String rc : requiredCols(table)) { 270 for (String rc : requiredCols(table)) {
239 - if (col.containsKey(rc)) { 271 + if (col.containsKey(rc) || resolver.isSystemColumn(rc)) {
240 continue; 272 continue;
241 } 273 }
242 - if ("sId".equals(rc)) {  
243 - col.put(rc, erp.newUuid(identity.token()));  
244 - } else if (rc.endsWith("Id")) {  
245 - String d = commonValue(table, rc); // 外键:取现有最常见值兜底  
246 - col.put(rc, d == null ? "" : d);  
247 - } else if (rc.endsWith("No")) {  
248 - col.put(rc, "AI" + (System.currentTimeMillis() % 1000000000L));  
249 - } else {  
250 - col.put(rc, "");  
251 - } 274 + col.put(rc, resolver.typeDefault(rc, types.get(rc)));
  275 + }
  276 + // 系统列:主键 + 表单id + 单号(租户/制单人/日期由 ERP 注入,不填)
  277 + col.put("sId", erp.newUuid(identity.token()));
  278 + col.put("sFormId", formId);
  279 + String billNo = resolver.nextBillNo(table, identity.brandsId());
  280 + if (billNo != null) {
  281 + col.put("sBillNo", billNo);
252 } 282 }
253 - col.putIfAbsent("sId", erp.newUuid(identity.token()));  
254 283
255 String payload; 284 String payload;
256 try { 285 try {
@@ -331,6 +360,20 @@ public class ProposeWriteTool { @@ -331,6 +360,20 @@ public class ProposeWriteTool {
331 return toJson(out); 360 return toJson(out);
332 } 361 }
333 362
  363 + /** 中文名 -> {col, fk}(字段字典,先精确合并模糊,取使用度最高的一列)。 */
  364 + private Map<String, Object> resolveColumn(String table, String zh) {
  365 + try {
  366 + List<Map<String, Object>> r = jdbc.queryForList(
  367 + "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " +
  368 + "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " +
  369 + "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1",
  370 + table, zh, "%" + zh + "%");
  371 + return r.isEmpty() ? null : r.get(0);
  372 + } catch (Exception e) {
  373 + return null;
  374 + }
  375 + }
  376 +
334 /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ 377 /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */
335 private List<String> requiredCols(String table) { 378 private List<String> requiredCols(String table) {
336 List<String> out = new ArrayList<>(); 379 List<String> out = new ArrayList<>();
@@ -360,22 +403,9 @@ public class ProposeWriteTool { @@ -360,22 +403,9 @@ public class ProposeWriteTool {
360 } 403 }
361 } 404 }
362 405
363 - /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ 406 + /** 定位实体的可写主表——统一走 FormResolverService(与 collectForm 同源,含从属/参数表排除)。 */
364 private Map<String, Object> resolveForm(String entityKeyword) { 407 private Map<String, Object> resolveForm(String entityKeyword) {
365 - try {  
366 - List<Map<String, Object>> r = jdbc.queryForList(  
367 - "SELECT af.sFormId, af.sModuleId, af.sDataSource FROM viw_ai_useful_forms af " +  
368 - "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " +  
369 - "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' " +  
370 - // 排除报表视图(viw_*),只取可直接改的基础主表  
371 - "AND af.sDataSource NOT LIKE 'viw%' " +  
372 - "ORDER BY COALESCE(f.bAiTool,0) DESC, " +  
373 - "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1",  
374 - "%" + entityKeyword + "%");  
375 - return r.isEmpty() ? null : r.get(0);  
376 - } catch (Exception e) {  
377 - return null;  
378 - } 408 + return resolver.resolveMasterForm(entityKeyword);
379 } 409 }
380 410
381 private static String str(Object o) { 411 private static String str(Object o) {
src/main/java/com/xly/web/OpController.java
@@ -83,7 +83,7 @@ public class OpController { @@ -83,7 +83,7 @@ public class OpController {
83 if ("create".equals(opType)) { 83 if ("create".equals(opType)) {
84 @SuppressWarnings("unchecked") 84 @SuppressWarnings("unchecked")
85 Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class); 85 Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class);
86 - r = erp.createForm(authToken, str(op.get("sTargetTable")), columns); 86 + r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns);
87 } else if ("delete".equals(opType)) { 87 } else if ("delete".equals(opType)) {
88 r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); 88 r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")));
89 } else if ("examine".equals(opType)) { 89 } else if ("examine".equals(opType)) {