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 | 103 | skillTool, |
| 104 | 104 | interactionTool, |
| 105 | 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 | 107 | new QueryTool(sqlModel, jdbc, audit, identity), |
| 108 | 108 | new FormCollectTool(erp, jdbc, resolver, identity, mapper)) |
| 109 | 109 | .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() | ... | ... |
src/main/java/com/xly/service/ErpClient.java
| ... | ... | @@ -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 | 289 | if (root.path("code").asInt() == -2 && canRelogin(authToken)) { |
| 290 | 290 | login(); |
| 291 | - root = doCreate(table, columns, resolveToken(authToken)); | |
| 291 | + root = doCreate(moduleId, table, columns, resolveToken(authToken)); | |
| 292 | 292 | } |
| 293 | 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 | 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 | 301 | Map<String, Object> dataItem = new LinkedHashMap<>(); |
| 300 | 302 | dataItem.put("sTable", table); |
| 301 | - dataItem.put("column", columns); | |
| 303 | + dataItem.put("name", "master"); | |
| 304 | + dataItem.put("column", List.of(col)); | |
| 302 | 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 | 307 | body.put("data", List.of(dataItem)); |
| 308 | 308 | HttpRequest req = HttpRequest.newBuilder(URI.create(url)) |
| 309 | 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 | 3 | import org.springframework.jdbc.core.JdbcTemplate; |
| 4 | 4 | import org.springframework.stereotype.Service; |
| 5 | 5 | |
| 6 | +import java.text.SimpleDateFormat; | |
| 7 | +import java.util.ArrayList; | |
| 8 | +import java.util.Date; | |
| 6 | 9 | import java.util.HashMap; |
| 10 | +import java.util.LinkedHashMap; | |
| 7 | 11 | import java.util.List; |
| 8 | 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 | 18 | * 表单 / 字段解析(KgSearch 内核)—— 把「实体类型 / 字段中文名」解析到具体的 |
| ... | ... | @@ -36,6 +43,10 @@ public class FormResolverService { |
| 36 | 43 | "SELECT af.sFormId, af.sModuleId, af.sDataSource, af.sFormTitle FROM viw_ai_useful_forms af " + |
| 37 | 44 | "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + |
| 38 | 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 | 50 | "ORDER BY COALESCE(f.bAiTool,0) DESC, " + |
| 40 | 51 | "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1", |
| 41 | 52 | "%" + entityKeyword.trim() + "%"); |
| ... | ... | @@ -85,6 +96,199 @@ public class FormResolverService { |
| 85 | 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 | 292 | private String queryOne(String sql, Object... args) { |
| 89 | 293 | try { |
| 90 | 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 | 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 | 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 | 70 | continue; |
| 79 | 71 | } |
| 80 | 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 | 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 | 115 | if (fields.isEmpty()) { |
| 96 | - return err("该表单没有可填写的字段。"); | |
| 116 | + return err("该表单没有可填写的业务字段。"); | |
| 97 | 117 | } |
| 98 | 118 | |
| 99 | 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 | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 5 | 5 | import com.xly.agent.AgentIdentity; |
| 6 | 6 | import com.xly.service.ErpClient; |
| 7 | +import com.xly.service.FormResolverService; | |
| 7 | 8 | import com.xly.service.OpService; |
| 8 | 9 | import dev.langchain4j.agent.tool.P; |
| 9 | 10 | import dev.langchain4j.agent.tool.Tool; |
| ... | ... | @@ -30,13 +31,16 @@ public class ProposeWriteTool { |
| 30 | 31 | private final OpService ops; |
| 31 | 32 | private final ObjectMapper mapper; |
| 32 | 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 | 38 | this.erp = erp; |
| 36 | 39 | this.jdbc = jdbc; |
| 37 | 40 | this.ops = ops; |
| 38 | 41 | this.mapper = mapper; |
| 39 | 42 | this.identity = identity; |
| 43 | + this.resolver = resolver; | |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | 46 | @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" |
| ... | ... | @@ -208,6 +212,12 @@ public class ProposeWriteTool { |
| 208 | 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 | 221 | Map<String, Object> col = new LinkedHashMap<>(); |
| 212 | 222 | List<String> descParts = new ArrayList<>(); |
| 213 | 223 | try { |
| ... | ... | @@ -218,39 +228,58 @@ public class ProposeWriteTool { |
| 218 | 228 | Map.Entry<String, JsonNode> e = it.next(); |
| 219 | 229 | String zh = e.getKey(); |
| 220 | 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 | 262 | } catch (Exception ex) { |
| 231 | - return err("字段 JSON 解析失败:" + ex.getMessage()); | |
| 263 | + return err("字段解析失败:" + ex.getMessage()); | |
| 232 | 264 | } |
| 233 | 265 | if (descParts.isEmpty()) { |
| 234 | 266 | return err("请至少提供一个有效字段(如客户名称)。"); |
| 235 | 267 | } |
| 236 | 268 | |
| 237 | - // 自动补齐 NOT-NULL 无默认列(租户/制单人由 ERP 注入,跳过) | |
| 269 | + // 自动补齐 NOT-NULL 无默认列(系统列跳过;其余按类型给默认 0/'') | |
| 238 | 270 | for (String rc : requiredCols(table)) { |
| 239 | - if (col.containsKey(rc)) { | |
| 271 | + if (col.containsKey(rc) || resolver.isSystemColumn(rc)) { | |
| 240 | 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 | 284 | String payload; |
| 256 | 285 | try { |
| ... | ... | @@ -331,6 +360,20 @@ public class ProposeWriteTool { |
| 331 | 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 | 377 | /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ |
| 335 | 378 | private List<String> requiredCols(String table) { |
| 336 | 379 | List<String> out = new ArrayList<>(); |
| ... | ... | @@ -360,22 +403,9 @@ public class ProposeWriteTool { |
| 360 | 403 | } |
| 361 | 404 | } |
| 362 | 405 | |
| 363 | - /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ | |
| 406 | + /** 定位实体的可写主表——统一走 FormResolverService(与 collectForm 同源,含从属/参数表排除)。 */ | |
| 364 | 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 | 411 | private static String str(Object o) { | ... | ... |
src/main/java/com/xly/web/OpController.java
| ... | ... | @@ -83,7 +83,7 @@ public class OpController { |
| 83 | 83 | if ("create".equals(opType)) { |
| 84 | 84 | @SuppressWarnings("unchecked") |
| 85 | 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 | 87 | } else if ("delete".equals(opType)) { |
| 88 | 88 | r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); |
| 89 | 89 | } else if ("examine".equals(opType)) { | ... | ... |