FormCollectTool.java 11.1 KB
package com.xly.tool;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.service.ErpClient;
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;

/**
 * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
 * 而不是逐字段追问。字段/类型/必填/下拉/默认来自 ERP 表单元数据({@code gdsconfigformslave})。
 *
 * <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推
 * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回,
 * agent 再据此走 {@code proposeCreate}(人在环写入)。适合报价这类字段多的新建场景。
 *
 * <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。
 */
public class FormCollectTool {

    private static final int MAX_FIELDS = 40;

    private final ErpClient erp;
    private final JdbcTemplate jdbc;
    private final FormResolverService resolver;
    private final AgentIdentity identity;
    private final ObjectMapper mapper;

    public FormCollectTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver,
                           AgentIdentity identity, ObjectMapper mapper) {
        this.erp = erp;
        this.jdbc = jdbc;
        this.resolver = resolver;
        this.identity = identity;
        this.mapper = mapper;
    }

    @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
            + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填"
            + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。"
            + "用户填完提交后,你再用 proposeCreate 生成待确认的新增。")
    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);
        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();
    }

    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\":\"内部错误\"}";
        }
    }
}