FormCollectTool.java
13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
package com.xly.tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.service.FormResolverService;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
* 而不是逐字段追问。
*
* <p>字段来源优先 {@link FormResolverService#businessFields}(精选映射 + 字段字典,即"真正要填的业务参数"),
* 每个字段带控件类型 {@code fkselect|select|number|date|text};外键字段的下拉选项由前端另调
* {@code GET /api/agent/form/options} 从来源表实时取。只有该表在字段字典里没有映射时,才退回
* ERP 界面元数据 {@code gdsconfigformslave} 的可见可写字段。
*
* <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推
* SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回,
* agent 再据此走 {@code proposeWrite(action=create)}(人在环写入)。适合报价这类字段多的新建场景。
*
* <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。
*/
public class FormCollectTool {
private static final int MAX_FIELDS = 40;
// 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理)
// 标注式:"长和宽50"、"高5cm"、"长:50"
private static final Pattern DIM_LABELED =
Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)");
// 位置式:"50*30*5"、"50x30x5"、"50×30×5"
private static final Pattern DIM_POS =
Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
private final JdbcTemplate jdbc;
private final FormResolverService resolver;
private final AgentIdentity identity;
private final ObjectMapper mapper;
public FormCollectTool(JdbcTemplate jdbc, FormResolverService resolver,
AgentIdentity identity, ObjectMapper mapper) {
this.jdbc = jdbc;
this.resolver = resolver;
this.identity = identity;
this.mapper = mapper;
}
@Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
+ "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填"
+ "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。"
+ "用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。")
public String collectForm(
@P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword,
@P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}",
required = false) String knownFieldsJson) {
if (entityKeyword == null || entityKeyword.isBlank()) {
return err("缺少实体类型。");
}
Map<String, String> known = parseKnown(knownFieldsJson);
expandDimensions(known);
Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
if (form == null) {
return err("找不到「" + entityKeyword + "」对应的可新建表单。");
}
String formId = String.valueOf(form.get("sFormId"));
String moduleId = String.valueOf(form.get("sModuleId"));
if (!identity.canAccessModule(moduleId)) {
return err("你没有新建「" + entityKeyword + "」的权限。");
}
String table = String.valueOf(form.get("sDataSource"));
List<Map<String, Object>> fields = new ArrayList<>();
// 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的
// 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。
List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS);
Map<String, String> types = resolver.columnTypes(table);
for (Map<String, Object> b : biz) {
String col = str(b.get("col"));
if (col == null || col.isBlank()) {
continue;
}
Map<String, Object> f = new LinkedHashMap<>();
f.put("name", col);
f.put("label", firstNonBlank(str(b.get("label")), col));
f.put("required", false);
Object fk = b.get("fk");
Object opts = b.get("options");
String type = str(b.get("type"));
if (fk != null && !String.valueOf(fk).isBlank()) {
// 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id
f.put("type", "fkselect");
f.put("fkTable", String.valueOf(fk));
} else if (opts instanceof List && !((List<?>) opts).isEmpty()) {
f.put("type", "select");
f.put("options", opts);
} else if (type != null && !type.isBlank()) {
f.put("type", type);
} else {
f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text
}
Object hint = b.get("hint");
if (hint != null && !String.valueOf(hint).isBlank()) {
f.put("hint", String.valueOf(hint));
}
String pre = known.get(normLabel(String.valueOf(f.get("label"))));
if (pre != null && !pre.isBlank()) {
f.put("default", pre); // 预填用户已说的值
}
fields.add(f);
}
// 兜底:字段字典没有该表映射时,退回 gdsconfigformslave 的非只读可见字段(排除系统列)。
if (fields.isEmpty()) {
try {
List<Map<String, Object>> cols = jdbc.queryForList(
"SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " +
"FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " +
"AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
for (Map<String, Object> c : cols) {
String name = str(c.get("sName"));
if (name == null || name.isBlank() || resolver.isSystemColumn(name)) {
continue;
}
Map<String, Object> f = new LinkedHashMap<>();
f.put("name", name);
f.put("label", firstNonBlank(str(c.get("sChinese")), name));
f.put("required", truthy(c.get("bNotEmpty")));
String def = str(c.get("sDefault"));
if (def != null && !def.isBlank()) {
f.put("default", def);
}
List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));
if (!opts.isEmpty()) {
f.put("type", "select");
f.put("options", opts);
} else {
f.put("type", "text");
}
fields.add(f);
}
} catch (Exception e) {
return err("读取表单结构失败:" + e.getMessage());
}
}
if (fields.isEmpty()) {
return err("该表单没有可填写的业务字段。");
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("type", "form_collect");
out.put("entity", entityKeyword.trim());
out.put("formId", formId);
out.put("moduleId", moduleId);
out.put("title", "新建" + entityKeyword.trim());
out.put("fields", fields);
out.put("message", "请在下方表单里填写,填完点【提交】。");
return toJson(out);
}
/** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */
private List<String> simpleOptions(String dropdown) {
List<String> out = new ArrayList<>();
if (dropdown == null || dropdown.isBlank()) {
return out;
}
String d = dropdown.trim();
if (d.toLowerCase().contains("select ") || d.length() > 200) {
return out; // SQL 或过长 -> 不当作枚举
}
for (String o : d.split("[、,,|]")) {
String t = o.trim();
if (!t.isEmpty() && out.size() < 30) {
out.add(t);
}
}
return out;
}
/** 按列的 MySQL 类型推断表单控件类型。 */
private static String inferType(String dataType) {
if (dataType == null) {
return "text";
}
String t = dataType.toLowerCase();
if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
return "number";
}
if (t.contains("date") || t.contains("time")) {
return "date";
}
return "text";
}
private static boolean truthy(Object o) {
if (o == null) {
return false;
}
if (o instanceof Boolean) {
return (Boolean) o;
}
if (o instanceof Number) {
return ((Number) o).intValue() != 0;
}
if (o instanceof byte[]) {
byte[] b = (byte[]) o;
return b.length > 0 && b[0] != 0;
}
String s = o.toString().trim();
return s.equals("1") || s.equalsIgnoreCase("true");
}
private static String firstNonBlank(String a, String b) {
return (a != null && !a.isBlank()) ? a : b;
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
/** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */
private Map<String, String> parseKnown(String json) {
Map<String, String> m = new HashMap<>();
if (json == null || json.isBlank()) {
return m;
}
try {
com.fasterxml.jackson.databind.JsonNode n = mapper.readTree(json.trim());
Iterator<Map.Entry<String, com.fasterxml.jackson.databind.JsonNode>> it = n.fields();
while (it.hasNext()) {
Map.Entry<String, com.fasterxml.jackson.databind.JsonNode> e = it.next();
String v = e.getValue().asText("");
if (v != null && !v.isBlank()) {
m.put(normLabel(e.getKey()), v);
}
}
} catch (Exception ignore) {
}
return m;
}
private static String normLabel(String s) {
if (s == null) {
return "";
}
return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();
}
/**
* 预填值里的尺寸表达(「50*30*5」「长和宽50,高5cm」)确定性拆成 长/宽/高 三个键
* (模型拆不对是实测结论——原 SlotFillService 的正则迁于此)。已有的 长/宽/高 预填不覆盖。
*/
static void expandDimensions(Map<String, String> known) {
Map<String, String> dims = new LinkedHashMap<>();
for (String v : known.values()) {
if (v == null || v.isBlank()) {
continue;
}
Matcher m = DIM_LABELED.matcher(v);
boolean any = false;
while (m.find()) {
String labels = m.group(1);
String val = m.group(2);
for (int i = 0; i < labels.length(); i++) {
String key = dimKey(labels.charAt(i));
if (key != null) {
dims.putIfAbsent(key, val);
any = true;
}
}
}
if (!any) {
Matcher p = DIM_POS.matcher(v);
if (p.find()) {
dims.putIfAbsent("长", p.group(1));
dims.putIfAbsent("宽", p.group(2));
if (p.group(3) != null) {
dims.putIfAbsent("高", p.group(3));
}
}
}
}
dims.forEach(known::putIfAbsent);
}
private static String dimKey(char c) {
switch (c) {
case '长': return "长";
case '宽': return "宽";
case '高':
case '厚':
case '深': return "高";
default: return null;
}
}
private String err(String msg) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("error", msg);
return toJson(m);
}
private String toJson(Map<String, Object> m) {
try {
return mapper.writeValueAsString(m);
} catch (Exception e) {
return "{\"error\":\"内部错误\"}";
}
}
}