package com.xly.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * 表单渲染/校验的**共享内核**(rearch3 §1 分层定案):collectForm(收集信息)与 * previewChange(请求核准)在 LLM 层是两个语义不同的工具,实现层共用这里的 * 表单骨架生成、记录定位读取、字段字典映射、FK 名称→id、类型强转、系统列拒改与 create 载荷构建 * ——预览与保存端点用**同一份校验**,杜绝"预览过、保存拒"的漂移。 * *

本服务只读不写:唯一的写入口在 {@link OpService}(ai_op_queue)。 */ @Service public class FormRenderService { private static final int MAX_FIELDS = 40; private final JdbcTemplate jdbc; private final FormResolverService resolver; private final ErpClient erp; private final ObjectMapper mapper; public FormRenderService(JdbcTemplate jdbc, FormResolverService resolver, ErpClient erp, ObjectMapper mapper) { this.jdbc = jdbc; this.resolver = resolver; this.erp = erp; this.mapper = mapper; } // ---------------------------------------------------------------- 表单骨架 /** * 某表单的业务录入字段骨架(label/控件类型/fk 来源/选项/提示)。 * 主来源 = 字段字典的业务录入字段(含报价策展),字典没有映射时退回 * {@code gdsconfigformslave} 的可见可写字段(排除系统列)。 */ public List> formSkeleton(String table, String formId) { List> fields = new ArrayList<>(); List> biz = resolver.businessFields(table, MAX_FIELDS); Map types = resolver.columnTypes(table); for (Map b : biz) { String col = str(b.get("col")); if (col == null || col.isBlank()) { continue; } Map f = new LinkedHashMap<>(); f.put("name", col); f.put("label", firstNonBlank(str(b.get("label")), col)); f.put("required", false); Object target = b.get("table"); if (target != null) { f.put("target", String.valueOf(target)); // 报价策展:master/slave/note/manyqtys } Object fk = b.get("fk"); Object opts = b.get("options"); String type = str(b.get("type")); if (fk != null && !String.valueOf(fk).isBlank()) { 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))); } Object hint = b.get("hint"); if (hint != null && !String.valueOf(hint).isBlank()) { f.put("hint", String.valueOf(hint)); } fields.add(f); } if (!fields.isEmpty() || formId == null) { return fields; } // 兜底:界面元数据的非只读可见字段 try { List> 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 c : cols) { String name = str(c.get("sName")); if (name == null || name.isBlank() || resolver.isSystemColumn(name) || !types.containsKey(name)) { // 界面元数据同样含幻影列 continue; } Map 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 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 ignore) { } return fields; } // ---------------------------------------------------------------- 记录定位 /** 定位结果:error 非空表示失败(用户可读消息);否则携带主表与唯一记录。 */ public static final class Located { public String error; public String formId; public String moduleId; public String table; public JsonNode rec; public String billId; public String recordName; } /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称/单号模糊匹配 → 0/多条/缺主键报错。 */ public Located locateRecord(AgentIdentity identity, String entityKeyword, String recordKeyword, String verb) { Located l = new Located(); Map form = resolver.resolveMasterForm(entityKeyword == null ? null : entityKeyword.trim()); if (form == null) { l.error = "找不到「" + entityKeyword + "」对应的可操作主表。"; return l; } l.formId = str(form.get("sFormId")); l.moduleId = str(form.get("sModuleId")); l.table = str(form.get("sDataSource")); if (!identity.canAccessModule(l.moduleId)) { l.error = "你没有" + verb + "「" + entityKeyword + "」的权限。"; return l; } String nameField = resolver.searchField(l.table, recordKeyword); JsonNode root; try { root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim()); } catch (Exception e) { l.error = "定位记录失败:" + e.getMessage(); return l; } if (root.path("code").asInt(0) < 0) { l.error = "定位记录失败:" + root.path("msg").asText("未知错误"); return l; } JsonNode rows = root.path("dataset").path("rows"); JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; int n = (data != null && data.isArray()) ? data.size() : 0; if (n == 0) { l.error = "没有找到名称含「" + recordKeyword + "」的记录。"; return l; } if (n > 1) { StringBuilder names = new StringBuilder(); for (int i = 0; i < data.size() && i < 5; i++) { if (i > 0) names.append("、"); names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); } l.error = "匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"; return l; } l.rec = data.get(0); l.billId = l.rec.path("sId").asText(null); if (isBlank(l.billId)) { l.error = "定位到的记录缺少主键 sId,无法" + verb + "。"; return l; } l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword); return l; } /** 保存端点用:按主键重读记录(重校验"所见即所写")。 */ public Located locateById(AgentIdentity identity, String formId, String moduleId, String table, String billId) { Located l = new Located(); l.formId = formId; l.moduleId = moduleId; l.table = table; JsonNode root; try { root = erp.readForm(identity.token(), formId, moduleId, 1, 2, "sId", billId); } catch (Exception e) { l.error = "重读记录失败:" + e.getMessage(); return l; } if (root.path("code").asInt(0) < 0) { l.error = "重读记录失败:" + root.path("msg").asText("未知错误"); return l; } JsonNode rows = root.path("dataset").path("rows"); JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; if (data == null || !data.isArray() || data.isEmpty()) { l.error = "该记录已不存在(可能被删除)。"; return l; } l.rec = data.get(0); l.billId = billId; return l; } // ---------------------------------------------------------------- 字段解析与规范化 /** * 字段中文名 → {col, fk}(先精确合并模糊,按使用度排序)。字段字典由表单控件名构建, * 有大量**幻影列**(不存在于物理表)——只返回物理存在的候选,找不到返回 null。 */ public Map resolveColumn(String table, String zh) { try { Map types = resolver.columnTypes(table); List> r = jdbc.queryForList( "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " + "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + "GROUP BY sField ORDER BY MAX(sChinese=?) DESC, SUM(iFormUses) DESC LIMIT 5", table, zh, "%" + zh + "%", zh); for (Map m : r) { String col = String.valueOf(m.get("col")); if (!types.containsKey(col)) { continue; // 幻影列:字典有、物理表没有 } Object fk = m.get("fk"); if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) { m.put("fk", null); } return m; } return null; } catch (Exception e) { return null; } } /** 规范化结果:error 非空 = 值非法(用户可读消息);否则 stored=入库值、shown=展示值。 */ public static final class Normalized { public String error; public Object stored; public String shown; } /** * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、 * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在 * (字段字典由表单控件名构建,含大量幻影列;幻影列会让类型校验静默失效)。 * 预览与保存共用同一份逻辑;FK 的 {@code shown} = **实际解析到的记录名**("所见即所写")。 */ public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) { Normalized n = new Normalized(); Map types = resolver.columnTypes(table); if (!types.containsKey(col)) { n.error = "「" + label + "」不是「" + table + "」的可写字段,请换个字段名。"; return n; } if (fk != null && !fk.isBlank()) { List> cands = resolver.resolveFkCandidates(fk, identity.brandsId(), value, 5); if (cands.isEmpty()) { String ent = label == null ? "" : label.replace("名称", ""); n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"; return n; } String want = value.trim(); Map pick = null; int exact = 0; for (Map c : cands) { if (want.equals(String.valueOf(c.get("name")))) { exact++; if (pick == null) { pick = c; } } } if (exact > 1) { // 完全同名的多条记录:名称无从区分,绝不静默绑其中一条 n.error = "系统里存在多条同名的「" + want + "」记录,无法按名称区分," + "请在 ERP 里处理这条数据、或先把重名记录改名后再试。"; return n; } if (pick == null && cands.size() == 1) { pick = cands.get(0); } if (pick == null) { StringBuilder names = new StringBuilder(); for (int i = 0; i < cands.size(); i++) { if (i > 0) names.append("、"); names.append(cands.get(i).get("name")); } n.error = "「" + value + "」匹配到多条记录(" + names + "),请用完整名称指明其中一条。"; return n; } n.stored = String.valueOf(pick.get("sId")); n.shown = String.valueOf(pick.get("name")); // 卡片/描述显示真正要绑定的记录名,而非用户原话 return n; } try { n.stored = resolver.coerce(types.get(col), value); } catch (IllegalArgumentException ex) { n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。"; return n; } if (n.stored == null) { n.error = "「" + value + "」不是「" + label + "」可接受的值(日期请用 2026-07-29 这种格式)。"; return n; } n.shown = String.valueOf(n.stored); return n; } /** 外键 id → 名称(预览展示用:记录里存 id,用户要看名称)。解析不到返回原 id。 */ public String fkDisplayName(String fkTable, String id) { if (fkTable == null || fkTable.isBlank() || id == null || id.isBlank()) { return id; } try { String nameField = resolver.resolveNameField(fkTable); if (nameField == null) { return id; } List> r = jdbc.queryForList( "SELECT `" + nameField + "` FROM `" + fkTable + "` WHERE sId=? LIMIT 1", id); if (!r.isEmpty()) { Object v = r.get(0).values().iterator().next(); if (v != null && !String.valueOf(v).isBlank()) { return String.valueOf(v); } } } catch (Exception ignore) { } return id; } // ---------------------------------------------------------------- create 构建 /** create 载荷构建结果:error 非空 = 校验失败;否则携带入队所需的全部信息。 */ public static final class CreateBuild { public String error; public String formId; public String moduleId; public String table; public String payload; public String description; } /** * 新增载荷构建(表单保存按钮的后端):label→列映射、FK 名称→id、类型强转、系统列拒填、 * NOT-NULL 无默认列补齐、主键/单号生成。报价走多表构建(主表+印刷从表+多数量)。 * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。 */ public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map fields) { CreateBuild out = new CreateBuild(); if (isBlank(entityKeyword)) { out.error = "缺少单据类型。"; return out; } Map form = resolver.resolveMasterForm(entityKeyword.trim()); if (form == null) { out.error = "找不到「" + entityKeyword + "」对应的可新增主表。"; return out; } out.formId = str(form.get("sFormId")); out.moduleId = str(form.get("sModuleId")); out.table = str(form.get("sDataSource")); if (!identity.canAccessModule(out.moduleId)) { out.error = "你没有新增「" + entityKeyword + "」的权限。"; return out; } if ("quoquotationmaster".equalsIgnoreCase(out.table)) { return buildQuote(identity, entityKeyword, fields, out); } Map types = resolver.columnTypes(out.table); Map> labelMap = new LinkedHashMap<>(); for (Map bfm : resolver.businessFields(out.table, MAX_FIELDS)) { labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); } Map col = new LinkedHashMap<>(); List descParts = new ArrayList<>(); for (Map.Entry e : fields.entrySet()) { String zh = e.getKey(); String v = e.getValue(); if (isBlank(v)) { continue; } Map fm = labelMap.get(normLabel(zh)); if (fm == null) { fm = resolveColumn(out.table, zh); } if (fm == null) { continue; // 该实体没有这个字段,忽略 } String colName = str(fm.get("col")); if (resolver.isSystemColumn(colName)) { continue; // 制单人/单据日期/租户/单号…由 ERP 注入 } String fk = fm.get("fk") == null ? null : str(fm.get("fk")); Normalized n = normalize(out.table, colName, fk, zh, v, identity); if (n.error != null) { out.error = n.error + (fk != null ? "(**不要**因此代建" + zh.replace("名称", "") + ")" : ""); return out; } col.put(colName, n.stored); descParts.add(zh + "=" + n.shown + (n.shown.equals(v) ? "" : "(原话:" + v + ")")); } if (descParts.isEmpty()) { out.error = "请至少提供一个有效字段(如客户名称)。"; return out; } for (String rc : requiredCols(out.table)) { if (col.containsKey(rc) || resolver.isSystemColumn(rc)) { continue; } col.put(rc, resolver.typeDefault(rc, types.get(rc))); } col.put("sId", erp.newUuid(identity.token())); col.put("sFormId", out.formId); String billNo = resolver.nextBillNo(out.table, identity.brandsId()); if (billNo != null) { col.put("sBillNo", billNo); } try { out.payload = mapper.writeValueAsString(col); } catch (Exception e) { out.error = "内部错误:" + e.getMessage(); return out; } out.description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); return out; } /** * 报价多表构建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。 * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。 * 价格由 ERP【核价】计算,只落主-从明细。 */ private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword, Map fields, CreateBuild out) { Map masterTypes = resolver.columnTypes(out.table); Map slaveTypes = resolver.columnTypes("quoquotationslave"); Map> labelMap = new LinkedHashMap<>(); for (Map bfm : resolver.businessFields(out.table, MAX_FIELDS)) { labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); } Map masterCol = new LinkedHashMap<>(); Map slaveCol = new LinkedHashMap<>(); List notes = new ArrayList<>(); List manyQtys = new ArrayList<>(); List descParts = new ArrayList<>(); String custId = null; for (Map.Entry e : fields.entrySet()) { String zh = e.getKey(); String v = e.getValue(); if (isBlank(v)) { continue; } Map fm = labelMap.get(normLabel(zh)); if (fm == null) { continue; } String colName = str(fm.get("col")); String tgt = fm.get("table") == null ? "master" : str(fm.get("table")); String fk = fm.get("fk") == null ? null : str(fm.get("fk")); if ("note".equals(tgt)) { notes.add(zh + "=" + v); descParts.add(zh + "=" + v); continue; } if ("manyqtys".equals(tgt)) { for (String q : v.split("[,,、\\s]+")) { String n = q.trim(); if (n.isEmpty()) { continue; } if (!n.matches("\\d+(\\.\\d+)?")) { out.error = "多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。"; return out; } manyQtys.add(n); } descParts.add(zh + "=" + String.join(",", manyQtys)); continue; } Map tt = "slave".equals(tgt) ? slaveTypes : masterTypes; Normalized n; if (fk != null && !fk.isBlank()) { n = normalize(out.table, colName, fk, zh, v, identity); if (n.error != null) { out.error = n.error + "(请从下拉里选真实存在的记录;**不要**代建,也不要把产品/规格当成客户)"; return out; } if ("sCustomerId".equals(colName)) { custId = String.valueOf(n.stored); } } else { if (!tt.containsKey(colName)) { // 与 normalize 同款幻影列硬闸(保护交给检查,不靠策展清单) out.error = "「" + zh + "」不是可写字段,请从表单里选有效字段。"; return out; } n = new Normalized(); try { n.stored = resolver.coerce(tt.get(colName), v); } catch (IllegalArgumentException ex) { out.error = "「" + zh + "」" + ex.getMessage() + ",请给一个有效值。"; return out; } if (n.stored == null) { out.error = "「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-29 这种格式)。"; return out; } n.shown = String.valueOf(n.stored); } if ("slave".equals(tgt)) { slaveCol.put(colName, n.stored); } else { masterCol.put(colName, n.stored); } // FK 显示真正绑定的记录名(n.shown),数值显示规范化后的数——与实际入库同源 String shown = (fk != null && !fk.isBlank()) ? n.shown : String.valueOf(n.stored); descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")")); } if (descParts.isEmpty()) { out.error = "请至少填写一个字段(如客户名称/产品名称/数量)。"; return out; } for (String rc : requiredCols(out.table)) { if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) { continue; } masterCol.put(rc, resolver.typeDefault(rc, masterTypes.get(rc))); } String masterId = erp.newUuid(identity.token()); masterCol.put("sId", masterId); masterCol.put("sFormId", out.formId); String billNo = resolver.nextBillNo(out.table, identity.brandsId()); if (billNo != null) { masterCol.put("sBillNo", billNo); } List> tables = new ArrayList<>(); tables.add(tableItem("quoquotationmaster", "master", masterCol)); if (!slaveCol.isEmpty() || !notes.isEmpty()) { if (!notes.isEmpty()) { String memo = String.join(";", notes); Object exist = slaveCol.get("sMaterialsMemo"); slaveCol.put("sMaterialsMemo", exist == null ? memo : (exist + ";" + memo)); } slaveCol.put("sId", erp.newUuid(identity.token())); slaveCol.put("sParentId", masterId); slaveCol.put("sCustomerId", custId == null ? "" : custId); tables.add(tableItem("quoquotationslave", "slave", slaveCol)); } for (String q : manyQtys) { Map mq = new LinkedHashMap<>(); mq.put("sId", erp.newUuid(identity.token())); mq.put("sParentId", masterId); mq.put("dManyQty", q); tables.add(tableItem("quoquotationmanyqtys", "slave", mq)); } Map wrap = new LinkedHashMap<>(); wrap.put("__tables__", tables); try { out.payload = mapper.writeValueAsString(wrap); } catch (Exception e) { out.error = "内部错误:" + e.getMessage(); return out; } out.description = "新增【报价】:" + String.join(",", descParts) + "(含印刷/多数量明细;价格请在 ERP 点【核价】计算)"; return out; } private Map tableItem(String sTable, String name, Map column) { Map m = new LinkedHashMap<>(); m.put("sTable", sTable); m.put("name", name); m.put("column", column); return m; } /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ private List requiredCols(String table) { List out = new ArrayList<>(); try { List> rows = jdbc.queryForList( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " + "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table); Set skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson"); for (Map r : rows) { String c = str(r.get("COLUMN_NAME")); if (c != null && !skip.contains(c)) { out.add(c); } } } catch (Exception ignore) { } return out; } // ---------------------------------------------------------------- 通用 /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */ public static String normLabel(String s) { if (s == null) { return ""; } return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); } 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 List simpleOptions(String dropdown) { List out = new ArrayList<>(); if (dropdown == null || dropdown.isBlank()) { return out; } String d = dropdown.trim(); if (d.toLowerCase().contains("select ") || d.length() > 200) { return out; } for (String o : d.split("[、,,|]")) { String t = o.trim(); if (!t.isEmpty() && out.size() < 30) { out.add(t); } } return out; } private static boolean truthy(Object o) { if (o == null) { return false; } if (o instanceof Boolean b) { return b; } if (o instanceof Number nu) { return nu.intValue() != 0; } if (o instanceof byte[] b) { 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(); } private static boolean isBlank(String s) { return s == null || s.isBlank(); } }