Commit 1057c7df3238a0780914183ccc5266b1470354a1

Authored by zichun
1 parent 190e64cd

feat(formcollect): type-aware fields + FK dropdowns from other tables

User: form rendered every field as a plain text box; no type (dropdown/date/number), and
FK fields didn't pull options from their source tables.

- FormCollect schema now carries per-field type: fkselect | select | number | date | text
  (+ options for select, fkTable for fkselect, hint for placeholder).
  Types inferred from column data type for generic forms; curated for quote.
- New GET /api/agent/form/options?table=&q=&brandsid= -> FK options from the source table
  (elecustomer/eleproduct/elematerials...), whitelisted to tables that appear as sFkTable in the
  field dict (rejects arbitrary tables e.g. gdslogininfo), tenant-filtered + LIMIT.
- chat.html renderFormCollect renders by type: fkselect = searchable <input list=datalist> that
  fetches options on focus/typing; select = <select>; number = <input type=number>; date picker; text.
- Fix: tolerant label matching (strip parentheticals) so '多数量' maps whether or not the LLM keeps
  the '(逗号分隔)' hint -> multi-qty rows now reliably created.

Verified via agent: quote form shows 客户/产品/物料=fkselect, 单位/印刷/颜色/单双面=select,
尺寸/数量/单价/系数=number; options endpoint returns real names (客户 q=Little -> Little Antelope);
non-FK table rejected; full write incl 多数量 (BJD202607086 -> 2000/4000/6000).
src/main/java/com/xly/service/FormResolverService.java
@@ -134,29 +134,32 @@ public class FormResolverService { @@ -134,29 +134,32 @@ public class FormResolverService {
134 // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。 134 // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。
135 // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。 135 // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。
136 List<Map<String, Object>> l = new ArrayList<>(); 136 List<Map<String, Object>> l = new ArrayList<>();
137 - l.add(bf("sCustomerId", "客户名称", "elecustomer", "master"));  
138 - l.add(bf("sProductId", "产品名称", "eleproduct", "master"));  
139 - l.add(bf("sMaterialsId", "物料名称", "elematerials", "master"));  
140 - l.add(bf("sProductUnit", "单位", null, "slave"));  
141 - l.add(bf("dLength", "长(L)", null, "master"));  
142 - l.add(bf("dWidth", "宽(W)", null, "master"));  
143 - l.add(bf("dHeight", "高(D)", null, "master"));  
144 - l.add(bf("dQty", "数量", null, "master"));  
145 - l.add(bf("sPrint", "印刷", null, "note"));  
146 - l.add(bf("sColor", "颜色", null, "note"));  
147 - l.add(bf("sSingleDouble", "单双面", null, "note"));  
148 - l.add(bf("dProductLength", "部件长", null, "slave"));  
149 - l.add(bf("dProductWidth", "部件宽", null, "slave"));  
150 - l.add(bf("dPrice", "单价", null, "master"));  
151 - l.add(bf("dCoefficient", "系数", null, "slave"));  
152 - l.add(bf("sMaterialsMemo", "材料备注", null, "slave"));  
153 - l.add(bf("dManyQty", "多数量(逗号分隔)", null, "manyqtys")); 137 + // fk → 下拉从对应表取选项;number → 数字;select → 固定选项;text/date 由控件决定
  138 + l.add(bf("sCustomerId", "客户名称", "elecustomer", "master", "fkselect", null));
  139 + l.add(bf("sProductId", "产品名称", "eleproduct", "master", "fkselect", null));
  140 + l.add(bf("sMaterialsId", "物料名称", "elematerials", "master", "fkselect", null));
  141 + l.add(bf("sProductUnit", "单位", null, "slave", "select", "只,个,套,张,本,件,PCS,KG,米,平方米"));
  142 + l.add(bf("dLength", "长(L)", null, "master", "number", null));
  143 + l.add(bf("dWidth", "宽(W)", null, "master", "number", null));
  144 + l.add(bf("dHeight", "高(D)", null, "master", "number", null));
  145 + l.add(bf("dQty", "数量", null, "master", "number", null));
  146 + l.add(bf("sPrint", "印刷", null, "note", "select", "胶印,柔印,凹印,丝印,数码印刷,不印刷"));
  147 + l.add(bf("sColor", "颜色", null, "note", "select", "彩色,黑白,专色,四色,无"));
  148 + l.add(bf("sSingleDouble", "单双面", null, "note", "select", "单面,双面"));
  149 + l.add(bf("dProductLength", "部件长", null, "slave", "number", null));
  150 + l.add(bf("dProductWidth", "部件宽", null, "slave", "number", null));
  151 + l.add(bf("dPrice", "单价", null, "master", "number", null));
  152 + l.add(bf("dCoefficient", "系数", null, "slave", "number", null));
  153 + l.add(bf("sMaterialsMemo", "材料备注", null, "slave", "text", null));
  154 + Map<String, Object> many = bf("dManyQty", "多数量", null, "manyqtys", "text", null);
  155 + many.put("hint", "多个报价数量用逗号分隔,如 1000,3000,5000");
  156 + l.add(many);
154 return l; 157 return l;
155 } 158 }
156 return null; 159 return null;
157 } 160 }
158 161
159 - private Map<String, Object> bf(String col, String label, String fk, String targetTable) { 162 + private Map<String, Object> bf(String col, String label, String fk, String targetTable, String type, String optionsCsv) {
160 Map<String, Object> m = new LinkedHashMap<>(); 163 Map<String, Object> m = new LinkedHashMap<>();
161 m.put("col", col); 164 m.put("col", col);
162 m.put("label", label); 165 m.put("label", label);
@@ -166,9 +169,58 @@ public class FormResolverService { @@ -166,9 +169,58 @@ public class FormResolverService {
166 if (targetTable != null) { 169 if (targetTable != null) {
167 m.put("table", targetTable); 170 m.put("table", targetTable);
168 } 171 }
  172 + if (type != null) {
  173 + m.put("type", type);
  174 + }
  175 + if (optionsCsv != null && !optionsCsv.isBlank()) {
  176 + List<String> opts = new ArrayList<>();
  177 + for (String o : optionsCsv.split(",")) {
  178 + if (!o.trim().isEmpty()) {
  179 + opts.add(o.trim());
  180 + }
  181 + }
  182 + m.put("options", opts);
  183 + }
169 return m; 184 return m;
170 } 185 }
171 186
  187 + /** FK 名称候选(下拉从对应表取选项):按名称字段模糊匹配,返回名称列表。仅允许字段字典里出现过的外键表。 */
  188 + public List<String> fkOptions(String fkTable, String brand, String q) {
  189 + List<String> out = new ArrayList<>();
  190 + try {
  191 + // 安全:只允许作为外键目标出现过的表(白名单),避免任意读表
  192 + Integer ok = jdbc.queryForObject(
  193 + "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable);
  194 + if (ok == null || ok == 0) {
  195 + return out;
  196 + }
  197 + String nameField = resolveNameField(fkTable);
  198 + if (nameField == null) {
  199 + return out;
  200 + }
  201 + String sql = "SELECT DISTINCT `" + nameField + "` n FROM `" + fkTable + "` " +
  202 + "WHERE IFNULL(`" + nameField + "`,'')<>'' " +
  203 + (brand != null && !brand.isBlank() ? "AND sBrandsId=? " : "") +
  204 + (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : "") +
  205 + "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 50";
  206 + List<Object> args = new ArrayList<>();
  207 + if (brand != null && !brand.isBlank()) {
  208 + args.add(brand);
  209 + }
  210 + if (q != null && !q.isBlank()) {
  211 + args.add("%" + q.trim() + "%");
  212 + }
  213 + for (Map<String, Object> r : jdbc.queryForList(sql, args.toArray())) {
  214 + Object n = r.get("n");
  215 + if (n != null) {
  216 + out.add(n.toString());
  217 + }
  218 + }
  219 + } catch (Exception ignore) {
  220 + }
  221 + return out;
  222 + }
  223 +
172 /** 表列 -> MySQL 数据类型(information_schema)。 */ 224 /** 表列 -> MySQL 数据类型(information_schema)。 */
173 public Map<String, String> columnTypes(String table) { 225 public Map<String, String> columnTypes(String table) {
174 Map<String, String> m = new HashMap<>(); 226 Map<String, String> m = new HashMap<>();
src/main/java/com/xly/tool/FormCollectTool.java
@@ -64,6 +64,7 @@ public class FormCollectTool { @@ -64,6 +64,7 @@ public class FormCollectTool {
64 // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 64 // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的
65 // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 65 // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。
66 List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS); 66 List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS);
  67 + Map<String, String> types = resolver.columnTypes(table);
67 for (Map<String, Object> b : biz) { 68 for (Map<String, Object> b : biz) {
68 String col = str(b.get("col")); 69 String col = str(b.get("col"));
69 if (col == null || col.isBlank()) { 70 if (col == null || col.isBlank()) {
@@ -72,11 +73,25 @@ public class FormCollectTool { @@ -72,11 +73,25 @@ public class FormCollectTool {
72 Map<String, Object> f = new LinkedHashMap<>(); 73 Map<String, Object> f = new LinkedHashMap<>();
73 f.put("name", col); 74 f.put("name", col);
74 f.put("label", firstNonBlank(str(b.get("label")), col)); 75 f.put("label", firstNonBlank(str(b.get("label")), col));
75 - f.put("control", "text");  
76 f.put("required", false); 76 f.put("required", false);
77 Object fk = b.get("fk"); 77 Object fk = b.get("fk");
  78 + Object opts = b.get("options");
  79 + String type = str(b.get("type"));
78 if (fk != null && !String.valueOf(fk).isBlank()) { 80 if (fk != null && !String.valueOf(fk).isBlank()) {
79 - f.put("fk", true); // 外键字段:让用户填名称,写入时解析成 id 81 + // 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id
  82 + f.put("type", "fkselect");
  83 + f.put("fkTable", String.valueOf(fk));
  84 + } else if (opts instanceof List && !((List<?>) opts).isEmpty()) {
  85 + f.put("type", "select");
  86 + f.put("options", opts);
  87 + } else if (type != null && !type.isBlank()) {
  88 + f.put("type", type);
  89 + } else {
  90 + f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text
  91 + }
  92 + Object hint = b.get("hint");
  93 + if (hint != null && !String.valueOf(hint).isBlank()) {
  94 + f.put("hint", String.valueOf(hint));
80 } 95 }
81 fields.add(f); 96 fields.add(f);
82 } 97 }
@@ -96,7 +111,6 @@ public class FormCollectTool { @@ -96,7 +111,6 @@ public class FormCollectTool {
96 Map<String, Object> f = new LinkedHashMap<>(); 111 Map<String, Object> f = new LinkedHashMap<>();
97 f.put("name", name); 112 f.put("name", name);
98 f.put("label", firstNonBlank(str(c.get("sChinese")), name)); 113 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"))); 114 f.put("required", truthy(c.get("bNotEmpty")));
101 String def = str(c.get("sDefault")); 115 String def = str(c.get("sDefault"));
102 if (def != null && !def.isBlank()) { 116 if (def != null && !def.isBlank()) {
@@ -104,7 +118,10 @@ public class FormCollectTool { @@ -104,7 +118,10 @@ public class FormCollectTool {
104 } 118 }
105 List<String> opts = simpleOptions(str(c.get("sChineseDropDown"))); 119 List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));
106 if (!opts.isEmpty()) { 120 if (!opts.isEmpty()) {
  121 + f.put("type", "select");
107 f.put("options", opts); 122 f.put("options", opts);
  123 + } else {
  124 + f.put("type", "text");
108 } 125 }
109 fields.add(f); 126 fields.add(f);
110 } 127 }
@@ -146,6 +163,21 @@ public class FormCollectTool { @@ -146,6 +163,21 @@ public class FormCollectTool {
146 return out; 163 return out;
147 } 164 }
148 165
  166 + /** 按列的 MySQL 类型推断表单控件类型。 */
  167 + private static String inferType(String dataType) {
  168 + if (dataType == null) {
  169 + return "text";
  170 + }
  171 + String t = dataType.toLowerCase();
  172 + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
  173 + return "number";
  174 + }
  175 + if (t.contains("date") || t.contains("time")) {
  176 + return "date";
  177 + }
  178 + return "text";
  179 + }
  180 +
149 private static boolean truthy(Object o) { 181 private static boolean truthy(Object o) {
150 if (o == null) { 182 if (o == null) {
151 return false; 183 return false;
src/main/java/com/xly/tool/ProposeWriteTool.java
@@ -221,7 +221,7 @@ public class ProposeWriteTool { @@ -221,7 +221,7 @@ public class ProposeWriteTool {
221 // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 221 // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射
222 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); 222 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
223 for (Map<String, Object> bfm : resolver.businessFields(table, 40)) { 223 for (Map<String, Object> bfm : resolver.businessFields(table, 40)) {
224 - labelMap.put(String.valueOf(bfm.get("label")), bfm); 224 + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
225 } 225 }
226 Map<String, Object> col = new LinkedHashMap<>(); 226 Map<String, Object> col = new LinkedHashMap<>();
227 List<String> descParts = new ArrayList<>(); 227 List<String> descParts = new ArrayList<>();
@@ -236,7 +236,7 @@ public class ProposeWriteTool { @@ -236,7 +236,7 @@ public class ProposeWriteTool {
236 if (isBlank(v)) { 236 if (isBlank(v)) {
237 continue; 237 continue;
238 } 238 }
239 - Map<String, Object> fm = labelMap.get(zh); 239 + Map<String, Object> fm = labelMap.get(normLabel(zh));
240 if (fm == null) { 240 if (fm == null) {
241 fm = resolveColumn(table, zh); // 回退字段字典 241 fm = resolveColumn(table, zh); // 回退字段字典
242 } 242 }
@@ -375,7 +375,7 @@ public class ProposeWriteTool { @@ -375,7 +375,7 @@ public class ProposeWriteTool {
375 Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave"); 375 Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave");
376 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); 376 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
377 for (Map<String, Object> bfm : resolver.businessFields(table, 40)) { 377 for (Map<String, Object> bfm : resolver.businessFields(table, 40)) {
378 - labelMap.put(String.valueOf(bfm.get("label")), bfm); 378 + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
379 } 379 }
380 380
381 Map<String, Object> masterCol = new LinkedHashMap<>(); 381 Map<String, Object> masterCol = new LinkedHashMap<>();
@@ -395,7 +395,7 @@ public class ProposeWriteTool { @@ -395,7 +395,7 @@ public class ProposeWriteTool {
395 if (isBlank(v)) { 395 if (isBlank(v)) {
396 continue; 396 continue;
397 } 397 }
398 - Map<String, Object> fm = labelMap.get(zh); 398 + Map<String, Object> fm = labelMap.get(normLabel(zh));
399 if (fm == null) { 399 if (fm == null) {
400 continue; 400 continue;
401 } 401 }
@@ -582,6 +582,14 @@ public class ProposeWriteTool { @@ -582,6 +582,14 @@ public class ProposeWriteTool {
582 return s == null || s.isBlank(); 582 return s == null || s.isBlank();
583 } 583 }
584 584
  585 + /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */
  586 + private static String normLabel(String s) {
  587 + if (s == null) {
  588 + return "";
  589 + }
  590 + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();
  591 + }
  592 +
585 private String err(String msg) { 593 private String err(String msg) {
586 Map<String, Object> m = new LinkedHashMap<>(); 594 Map<String, Object> m = new LinkedHashMap<>();
587 m.put("error", msg); 595 m.put("error", msg);
src/main/java/com/xly/web/FormController.java 0 → 100644
  1 +package com.xly.web;
  2 +
  3 +import com.xly.service.FormResolverService;
  4 +import org.springframework.web.bind.annotation.GetMapping;
  5 +import org.springframework.web.bind.annotation.RequestMapping;
  6 +import org.springframework.web.bind.annotation.RequestParam;
  7 +import org.springframework.web.bind.annotation.RestController;
  8 +
  9 +import java.util.List;
  10 +
  11 +/**
  12 + * FormCollect 的**下拉选项来源**端点:外键字段(客户/产品/物料…)的选项从对应表实时取。
  13 + *
  14 + * <p>前端渲染 {@code type=fkselect} 字段时调 {@code GET /api/agent/form/options?table=elecustomer&q=..}
  15 + * 拿候选名称填进下拉。安全:{@link FormResolverService#fkOptions} 只允许「在字段字典里作为外键目标出现过」的表,
  16 + * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。
  17 + */
  18 +@RestController
  19 +@RequestMapping("/api/agent/form")
  20 +public class FormController {
  21 +
  22 + private final FormResolverService resolver;
  23 +
  24 + public FormController(FormResolverService resolver) {
  25 + this.resolver = resolver;
  26 + }
  27 +
  28 + @GetMapping("/options")
  29 + public List<String> options(@RequestParam("table") String table,
  30 + @RequestParam(value = "q", required = false) String q,
  31 + @RequestParam(value = "brandsid", required = false) String brandsid) {
  32 + return resolver.fkOptions(table, brandsid, q);
  33 + }
  34 +}
src/main/resources/templates/chat.html
@@ -806,28 +806,36 @@ @@ -806,28 +806,36 @@
806 card.find('.op-result').text('已取消'); 806 card.find('.op-result').text('已取消');
807 } 807 }
808 808
809 - // ====================== FormCollect:对话内动态表单(字段来自 ERP 表单元数据) ======================  
810 - // fields = [{name,label,control,required,default,options?}](新版对象)或 ["中文名",...](旧版字符串,兼容) 809 + // ====================== FormCollect:type-aware 动态表单 ======================
  810 + // fields = [{name,label,type,required,default,options?,fkTable?}](新版)或 ["中文名",...](旧版兼容)
  811 + // type: text | number | date | select(固定选项) | fkselect(选项从对应表实时取)
811 function renderFormCollect(entity, fields) { 812 function renderFormCollect(entity, fields) {
812 hideTypingIndicator(); 813 hideTypingIndicator();
813 const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); 814 const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
814 - const norm = (fields || []).map(f => (typeof f === 'string')  
815 - ? { name: f, label: f, required: false }  
816 - : f);  
817 - const inputs = norm.map(f => { 815 + const norm = (fields || []).map(f => (typeof f === 'string') ? { name: f, label: f, type: 'text' } : f);
  816 + const st = 'padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;';
  817 + const inputs = norm.map((f, i) => {
818 const label = escapeHtml(f.label || f.name); 818 const label = escapeHtml(f.label || f.name);
819 const req = f.required ? ' <span style="color:#e00;">*</span>' : ''; 819 const req = f.required ? ' <span style="color:#e00;">*</span>' : '';
820 const defv = f.default != null ? escapeHtml(String(f.default)) : ''; 820 const defv = f.default != null ? escapeHtml(String(f.default)) : '';
  821 + const type = f.type || 'text';
  822 + const ph = f.hint ? ` placeholder="${escapeHtml(String(f.hint))}"` : '';
821 let control; 823 let control;
822 - if (Array.isArray(f.options) && f.options.length > 0) { 824 + if (type === 'select' && Array.isArray(f.options)) {
823 const opts = ['<option value=""></option>'] 825 const opts = ['<option value=""></option>']
824 - .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`))  
825 - .join('');  
826 - control = `<select data-label="${label}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">${opts}</select>`; 826 + .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`)).join('');
  827 + control = `<select data-label="${label}" class="fc-input" style="${st}">${opts}</select>`;
  828 + } else if (type === 'fkselect') {
  829 + const dlId = `${fid}-dl-${i}`;
  830 + control = `<input data-label="${label}" class="fc-input fc-fk" list="${dlId}" data-fk="${escapeHtml(f.fkTable||'')}" placeholder="输入以搜索…" autocomplete="off" style="${st}"><datalist id="${dlId}"></datalist>`;
  831 + } else if (type === 'number') {
  832 + control = `<input type="number" step="any" data-label="${label}" value="${defv}" class="fc-input" style="${st}"${ph}>`;
  833 + } else if (type === 'date') {
  834 + control = `<input type="date" data-label="${label}" value="${defv}" class="fc-input" style="${st}">`;
827 } else { 835 } else {
828 - control = `<input data-label="${label}" value="${defv}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">`; 836 + control = `<input type="text" data-label="${label}" value="${defv}" class="fc-input" style="${st}"${ph}>`;
829 } 837 }
830 - return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;">${label}${req}</label>${control}</div>`; 838 + return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;vertical-align:middle;">${label}${req}</label>${control}</div>`;
831 }).join(''); 839 }).join('');
832 const html = ` 840 const html = `
833 <div class="message ai-message" id="${fid}"> 841 <div class="message ai-message" id="${fid}">
@@ -842,6 +850,18 @@ @@ -842,6 +850,18 @@
842 </div> 850 </div>
843 </div>`; 851 </div>`;
844 $('#chatMessages').append(html); 852 $('#chatMessages').append(html);
  853 + // 外键下拉:聚焦拉一批候选,输入按关键词刷新(选项来自对应表)
  854 + $(`#${fid} .fc-fk`).each(function () {
  855 + const el = this, dlId = $(el).attr('list'), fkTable = $(el).data('fk');
  856 + const load = (q) => fetch(`${CONFIG.backendUrl}/api/agent/form/options?table=${encodeURIComponent(fkTable)}&brandsid=${encodeURIComponent(brandsid)}&q=${encodeURIComponent(q||'')}`)
  857 + .then(r => r.json()).then(list => {
  858 + const dl = document.getElementById(dlId);
  859 + if (dl) dl.innerHTML = (list||[]).map(o => `<option value="${escapeHtml(String(o))}"></option>`).join('');
  860 + }).catch(() => {});
  861 + let t = null;
  862 + $(el).on('focus', () => load(''));
  863 + $(el).on('input', function () { clearTimeout(t); const v = this.value; t = setTimeout(() => load(v), 250); });
  864 + });
845 $(`#${fid} .fc-submit`).on('click', function () { 865 $(`#${fid} .fc-submit`).on('click', function () {
846 const parts = []; 866 const parts = [];
847 $(`#${fid} .fc-input`).each(function () { 867 $(`#${fid} .fc-input`).each(function () {