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 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)) {
... ...