package com.xly.tool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.service.ErpClient;
import com.xly.service.FormResolverService;
import com.xly.service.OpService;
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.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* ProposeWrite 工具(写操作,人在环)。
*
*
**只提议并暂存,绝不立即执行**:把「改某条记录的某字段」解析成具体的 表/记录id/列/新值,
* 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里
* (见 OpController),不经过 LLM。
*/
public class ProposeWriteTool {
private final ErpClient erp;
private final JdbcTemplate jdbc;
private final OpService ops;
private final ObjectMapper mapper;
private final AgentIdentity identity;
private final FormResolverService resolver;
public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper,
AgentIdentity identity, FormResolverService resolver) {
this.erp = erp;
this.jdbc = jdbc;
this.ops = ops;
this.mapper = mapper;
this.identity = identity;
this.resolver = resolver;
}
@Tool("提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。"
+ "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);"
+ "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。"
+ "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;"
+ "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。")
public String proposeWrite(
@P("动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete") String action,
@P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword,
@P(value = "记录名称或单号关键词(create 不需要)", required = false) String recordKeyword,
@P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese,
@P(value = "新值(仅 update)", required = false) String newValue,
@P(value = "字段 JSON 中文名->值(仅 create)", required = false) String fieldsJson) {
if (isBlank(action)) {
return err("缺少 action。");
}
switch (action.trim().toLowerCase()) {
case "create": return doCreate(entityKeyword, fieldsJson);
case "update": return doUpdate(entityKeyword, recordKeyword, fieldChinese, newValue);
case "invalid": return doInvalidate(entityKeyword, recordKeyword, false);
case "cancelinvalid": return doInvalidate(entityKeyword, recordKeyword, true);
case "examine": return doExamineOp(entityKeyword, recordKeyword, 1);
case "cancelexamine": return doExamineOp(entityKeyword, recordKeyword, 0);
case "delete": return doDelete(entityKeyword, recordKeyword);
default:
return err("未知 action:" + action + "(支持 create/update/invalid/cancelInvalid/examine/cancelExamine/delete)");
}
}
/** 作废(cancel=false)/复原·取消作废(cancel=true)一条记录 → ERP updatebInvalid(Sp_Invalidation)。业务单据的“删除”走这里。 */
private String doInvalidate(String entityKeyword, String recordKeyword, boolean cancel) {
String verb = cancel ? "复原" : "作废";
if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
return err("缺少实体类型或记录名称。");
}
Located l = locateRecord(entityKeyword, recordKeyword, verb);
if (l.error != null) {
return l.error;
}
String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")";
// sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid
String opId = ops.createDraft(identity.userId(), "invalid", l.formId, l.moduleId, l.table, l.billId,
null, null, null, cancel ? "cancel" : "toVoid", description);
Map out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。");
return toJson(out);
}
/** 修改某条记录的某个字段。见 {@link #proposeWrite}。 */
private String doUpdate(String entityKeyword, String recordKeyword, String fieldChinese, String newValue) {
if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) {
return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。");
}
// 1) 定位主表 + 唯一记录
Located l = locateRecord(entityKeyword, recordKeyword, "修改");
if (l.error != null) {
return l.error;
}
// 2) 字段中文名 -> 技术列名
String field = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1",
l.table, fieldChinese.trim());
if (field == null) {
field = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1",
l.table, "%" + fieldChinese.trim() + "%");
}
if (field == null) {
return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
}
String oldValue = l.rec.path(field).asText("");
// 3) 暂存 draft(不执行)
String description = "将【" + l.recordName + "】的【" + fieldChinese + "】"
+ (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」";
String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId,
field, fieldChinese, oldValue, newValue, description);
Map out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的修改,请在下方点【确认】执行、或【取消】。");
return toJson(out);
}
/** 物理删除一条记录(罕用;业务单据请用 doInvalidate 作废)。见 {@link #proposeWrite}。 */
private String doDelete(String entityKeyword, String recordKeyword) {
if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
return err("缺少实体类型或记录名称。");
}
Located l = locateRecord(entityKeyword, recordKeyword, "删除");
if (l.error != null) {
return l.error;
}
String description = "删除【" + l.recordName + "】(" + entityKeyword + ")";
String opId = ops.createDraft(identity.userId(), "delete", l.formId, l.moduleId, l.table, l.billId,
null, null, l.recordName, null, description);
Map out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的删除,请在下方点【确认】执行、或【取消】。删除不可恢复,请谨慎。");
return toJson(out);
}
/** 新增一条记录(报价走多表 proposeQuote)。见 {@link #proposeWrite}。 */
private String doCreate(String entityKeyword, String fieldsJson) {
if (isBlank(entityKeyword)) {
return err("缺少实体类型。");
}
Map form = resolveForm(entityKeyword.trim());
if (form == null) {
return err("找不到「" + entityKeyword + "」对应的可新增主表。");
}
String formId = str(form.get("sFormId"));
String moduleId = str(form.get("sModuleId"));
String table = str(form.get("sDataSource"));
if (!identity.canAccessModule(moduleId)) {
return err("你没有新增「" + entityKeyword + "」的权限。");
}
// 报价是跨表主-从单据 → 走多表创建(主表 + 印刷/部件从表 + 多数量)
if ("quoquotationmaster".equalsIgnoreCase(table)) {
return proposeQuote(fieldsJson, formId, moduleId, table);
}
Map types = resolver.columnTypes(table);
// 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射
Map> labelMap = new LinkedHashMap<>();
for (Map bfm : resolver.businessFields(table, 40)) {
labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
}
Map col = new LinkedHashMap<>();
List descParts = new ArrayList<>();
try {
if (!isBlank(fieldsJson)) {
JsonNode fj = mapper.readTree(fieldsJson.trim());
Iterator> it = fj.fields();
while (it.hasNext()) {
Map.Entry e = it.next();
String zh = e.getKey();
String v = e.getValue().asText("");
if (isBlank(v)) {
continue;
}
Map fm = labelMap.get(normLabel(zh));
if (fm == null) {
fm = resolveColumn(table, zh); // 回退字段字典
}
if (fm == null) {
continue; // 该实体没有这个字段,忽略
}
String colName = str(fm.get("col"));
// 别让 LLM/用户直填系统列(制单人/单据日期/租户/单号…),交给 ERP 与下方系统列处理
if (resolver.isSystemColumn(colName)) {
continue;
}
String fk = str(fm.get("fk"));
if (fk != null && !fk.isBlank()) {
String id = resolver.resolveFk(fk, v); // 外键:名称 -> id
if (id == null) {
String ent = zh.replace("名称", "");
return err("「" + v + "」不是系统里已有的" + ent + "。请让用户从下拉里选真实存在的"
+ ent + ";**不要**因此新建" + ent + "。");
}
col.put(colName, id);
} else {
Object cv = resolver.coerce(types.get(colName), v); // 按列类型强转
if (cv != null) {
col.put(colName, cv);
}
}
descParts.add(zh + "=" + v);
}
}
} catch (Exception ex) {
return err("字段解析失败:" + ex.getMessage());
}
if (descParts.isEmpty()) {
return err("请至少提供一个有效字段(如客户名称)。");
}
// 自动补齐 NOT-NULL 无默认列(系统列跳过;其余按类型给默认 0/'')
for (String rc : requiredCols(table)) {
if (col.containsKey(rc) || resolver.isSystemColumn(rc)) {
continue;
}
col.put(rc, resolver.typeDefault(rc, types.get(rc)));
}
// 系统列:主键 + 表单id + 单号(租户/制单人/日期由 ERP 注入,不填)
col.put("sId", erp.newUuid(identity.token()));
col.put("sFormId", formId);
String billNo = resolver.nextBillNo(table, identity.brandsId());
if (billNo != null) {
col.put("sBillNo", billNo);
}
String payload;
try {
payload = mapper.writeValueAsString(col);
} catch (Exception e) {
return err("内部错误:" + e.getMessage());
}
String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts);
String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description);
Map out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。");
return toJson(out);
}
/** 审核(iFlag=1)/反审核·销审(iFlag=0)一条单据 → ERP doExamine。见 {@link #proposeWrite}。 */
private String doExamineOp(String entityKeyword, String recordKeyword, int iFlag) {
if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
return err("缺少单据类型或单号/名称。");
}
String verb = iFlag == 1 ? "审核" : "反审核";
Located l = locateRecord(entityKeyword, recordKeyword, verb);
if (l.error != null) {
return l.error;
}
String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")";
// examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动)
String opId = ops.createDraft(identity.userId(), "examine", l.formId, l.moduleId, l.table, l.billId,
null, null, null, String.valueOf(iFlag), description);
Map out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。");
return toJson(out);
}
/**
* 报价多表创建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。
* 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。
* 价格由 ERP【核价】计算,本工具只落主-从明细。生成 __tables__ 结构化 payload,确认端点做多表写入。
*/
private String proposeQuote(String fieldsJson, String formId, String moduleId, String table) {
Map masterTypes = resolver.columnTypes(table);
Map slaveTypes = resolver.columnTypes("quoquotationslave");
Map> labelMap = new LinkedHashMap<>();
for (Map bfm : resolver.businessFields(table, 40)) {
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;
try {
if (!isBlank(fieldsJson)) {
JsonNode fj = mapper.readTree(fieldsJson.trim());
Iterator> it = fj.fields();
while (it.hasNext()) {
Map.Entry e = it.next();
String zh = e.getKey();
String v = e.getValue().asText("");
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 = 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.replaceAll("[^0-9.]", "");
if (!n.isEmpty()) {
manyQtys.add(n);
}
}
descParts.add(zh + "=" + v);
continue;
}
Object value;
if (fk != null && !fk.isBlank()) {
String id = resolver.resolveFk(fk, v);
if (id == null) {
String ent = zh.replace("名称", "");
return err("「" + v + "」不是系统里已有的" + ent + "。请用 collectForm 让用户从下拉里选真实存在的"
+ ent + ";**不要**因此新建" + ent + ",也不要把产品/规格当成客户。");
}
value = id;
if ("sCustomerId".equals(colName)) {
custId = id;
}
} else {
String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName);
value = resolver.coerce(dt, v);
}
if (value != null) {
if ("slave".equals(tgt)) {
slaveCol.put(colName, value);
} else {
masterCol.put(colName, value);
}
}
descParts.add(zh + "=" + v);
}
}
} catch (Exception ex) {
return err("字段解析失败:" + ex.getMessage());
}
if (descParts.isEmpty()) {
return err("请至少填写一个字段(如客户名称/产品名称/数量)。");
}
// 主表:补必填(非系统) + 主键 + 表单id + 单号
for (String rc : requiredCols(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", formId);
String billNo = resolver.nextBillNo(table, identity.brandsId());
if (billNo != null) {
masterCol.put("sBillNo", billNo);
}
List