Commit a4e1a1a76e22327503ececd580e4f5e2313c89bc
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.
Showing
6 changed files
with
335 additions
and
81 deletions
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)) { |