FormCollectTool.java 16 KB
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));
            }
            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("该表单没有可填写的业务字段。");
        }
        applyKnownDefaults(known, fields);

        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();
    }

    /**
     * 把用户已说的值预填进表单字段。键与字段标签先按归一化全等匹配;对不上再做**宽松对齐**
     * (「客户」→「客户名称」这类互含关系),多个候选字段时用确定性决胜:唯一的外键下拉字段
     * 或唯一以「名称」结尾的字段,仍二义则放弃预填(宁缺勿错,用户在表单里自己填)。
     */
    static void applyKnownDefaults(Map<String, String> known, List<Map<String, Object>> fields) {
        List<String> normLabels = new ArrayList<>();
        for (Map<String, Object> f : fields) {
            normLabels.add(normLabel(String.valueOf(f.get("label"))));
        }
        Map<String, String> aligned = new HashMap<>();
        for (Map.Entry<String, String> e : known.entrySet()) {
            String key = e.getKey();
            if (key.isEmpty() || e.getValue() == null || e.getValue().isBlank()) {
                continue;
            }
            if (normLabels.contains(key)) {
                aligned.put(key, e.getValue());
                continue;
            }
            List<Integer> hits = new ArrayList<>();
            for (int i = 0; i < normLabels.size(); i++) {
                String l = normLabels.get(i);
                if (!l.isEmpty() && (l.contains(key) || key.contains(l))) {
                    hits.add(i);
                }
            }
            Integer pick = null;
            if (hits.size() == 1) {
                pick = hits.get(0);
            } else if (hits.size() > 1) {
                List<Integer> fk = hits.stream()
                        .filter(i -> "fkselect".equals(fields.get(i).get("type"))).toList();
                List<Integer> named = hits.stream()
                        .filter(i -> normLabels.get(i).endsWith("名称")).toList();
                if (fk.size() == 1) {
                    pick = fk.get(0);
                } else if (named.size() == 1) {
                    pick = named.get(0);
                }
            }
            if (pick != null) {
                aligned.putIfAbsent(normLabels.get(pick), e.getValue());
            }
        }
        for (int i = 0; i < fields.size(); i++) {
            String pre = aligned.get(normLabels.get(i));
            if (pre != null && !pre.isBlank()) {
                fields.get(i).put("default", pre); // 用户说过的值优先于表单默认值
            }
        }
    }

    /**
     * 预填值里的尺寸表达(「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\":\"内部错误\"}";
        }
    }
}