Commit 541e0041948cbf6c7e3c52afa681d052a9fc8a77

Authored by zichun
1 parent 82a59f67

C: write-path recentralization — previewChange read-only tool + FormRenderServic…

…e shared kernel + PreviewService (previewId Redis binding, save-side re-validation, state legality checks) + queue-only OpService (sMakePerson dual-write, status=confirmed); retire ProposeWriteTool/OpController/AuditService/ErpClient writes; preview card frontend; skills/prompt/bench sync; D: checkBusinessData stub behind erp.dry-run.enabled
bench/bench_ext.py
... ... @@ -27,7 +27,8 @@ MAX_STEPS = 8
27 27 # ---------------- 銝 bench50_v2 漣憭 prompt / 撌亙摰 ----------------
28 28 import pathlib # noqa: E402
29 29  
30   -from bench50_v2 import UNIFIED_PROMPT, ALL6, DOMAIN_MAP, fn, CASES as FIFTY_CASES # noqa: E402
  30 +from bench50_v2 import (UNIFIED_PROMPT, ALL6, DOMAIN_MAP, fn, CASES as FIFTY_CASES, # noqa: E402
  31 + T_FINDFORMS, T_READFORM, T_LOOKUP, T_ASK)
31 32  
32 33 ROOT = pathlib.Path(__file__).resolve().parent.parent
33 34 SKILLS_DIR = ROOT / "src/main/resources/skills"
... ... @@ -35,10 +36,32 @@ SKILLS_DIR = ROOT / "src/main/resources/skills"
35 36 T_USESKILL = fn("useSkill",
36 37 "頧賢銝憿寞嚗瘚郊撉方秩甇仿炊閬 撱/隞/隞/靽格/雿/摰⊥ 蝑"
37 38 "隡嚗&摰粥嚗摰蝸撖孵 system prompt 皜",
38   - {"skillName": {"type": "string",
39   - "description": " 撱箸隞 / 靽格霈啣 / / 撱箏 / 霂X"}},
  39 + {"skillName": {"type": "string", "description": " system prompt 皜"}},
40 40 ["skillName"])
41 41  
  42 +# rearch3嚗芋儒撌亙霂/皜脫ollectForm/previewChange 銝漣 @Tool
  43 +T_COLLECT_NEW = fn("collectForm",
  44 + "撖寡撕銝撘 ERP 銵典悟銝甈⊥批‵朣葵摮挾嚗葵餈賡銝撌梁鈭挾颲撱/敶"
  45 + "嚗陘**撱箸隞**嚗ntityKeyword = 蝐餃 隞 / 摰X嚗nownFieldsJson = 撌脰秩挾(銝剜->)鈭‵"
  46 + "嚗 {\"鈭批妍\":\"蝥貊",\"":\"1000\",\"(L)\":\"50\"}嚗恥/鈭批/葡悟隞"
  47 + "憛怠頂蝏撉僎漱敺****遙雿霂游歇靽",
  48 + {"entityKeyword": {"type": "string", "description": "閬撱箇/蝐餃 隞 / 摰X / "},
  49 + "knownFieldsJson": {"type": "string", "description": "撌脰秩挾 JSON嚗葉->潘鈭‵銵典"}},
  50 + ["entityKeyword"])
  51 +T_PREVIEW = fn("previewChange",
  52 + "銝箔甈∪**憸**嚗霂鳴撌亙蝏嚗遙雿嚗內霈啣捆"
  53 + "漁嚗僎撣血笆摨嚗/摰⊥/恣/雿//嚗鈭嚗"
  54 + "蝟餌漱敺ction嚗pdate=摮挾嚗ecordKeyword+fieldChinese+newValue嚗"
  55 + "invalid=雿閬/敺摰憭ancelInvalid=憭/xamine=摰⊥嚗"
  56 + "cancelExamine=摰/恣嚗elete=嚗嚗**憓撌亙**嚗憓粥 collectForm嚗"
  57 + "撌亙銵蜓銵其扇敶 findForms停嚗霂游歇靽/撌脣",
  58 + {"action": {"type": "string", "description": "雿pdate|invalid|cancelInvalid|examine|cancelExamine|delete"},
  59 + "entityKeyword": {"type": "string", "description": "摰/蝐餃 隞 / 摰X / 霈W"},
  60 + "recordKeyword": {"type": "string", "description": "霈啣妍霂"},
  61 + "fieldChinese": {"type": "string", "description": "閬挾銝剜 update嚗"},
  62 + "newValue": {"type": "string", "description": "潘 update嚗"}},
  63 + ["action", "entityKeyword", "recordKeyword"])
  64 +
42 65  
43 66 def load_skills():
44 67 """{妍: (銝霂, 甇)} 銝漣 SkillService 撘=活銵==甇"""
... ... @@ -51,7 +74,8 @@ def load_skills():
51 74  
52 75  
53 76 def build_context(arch):
54   - """餈 (system_prompt, tools)ew 辣霂餃漣瞍宏"""
  77 + """餈 (system_prompt, tools)ew 辣霂餃漣瞍宏
  78 + old = rearch1 蝏 prompt + proposeWrite嚗蝥選ew = rearch3 skill + 霂餃極嚗reviewChange嚗"""
55 79 if arch == "old":
56 80 return UNIFIED_PROMPT, ALL6
57 81 if arch == "new":
... ... @@ -59,7 +83,7 @@ def build_context(arch):
59 83 skills = load_skills()
60 84 index = "".join(f"- {name}嚗brief}\n" for name, (brief, _) in skills.items())
61 85 sp = template.replace("{DOMAIN_MAP}", DOMAIN_MAP).replace("{SKILL_INDEX}", index)
62   - return sp, [T_USESKILL] + ALL6
  86 + return sp, [T_USESKILL, T_FINDFORMS, T_READFORM, T_LOOKUP, T_COLLECT_NEW, T_PREVIEW, T_ASK]
63 87 raise SystemExit(f" arch: {arch}")
64 88  
65 89  
... ... @@ -149,6 +173,9 @@ def fixture_result(name, args):
149 173 if name == "proposeWrite":
150 174 return (f"[撌脩&霈斗悅 OP-TEST-1嚗ction={args.get('action','')}嚗={args.get('entityKeyword','')}嚗"
151 175 f"霈啣={args.get('recordKeyword','')}悅嚗銵窈&霈扎")
  176 + if name == "previewChange":
  177 + return (f"[撌脩 PV-TEST-1嚗ction={args.get('action','')}嚗={args.get('entityKeyword','')}嚗"
  178 + f"霈啣={args.get('recordKeyword','')}霂駁嚗銝")
152 179 if name == "askUser":
153 180 return "[憸歇嚗頧桀甇支蛹甇U"
154 181 if name == "useSkill":
... ... @@ -206,13 +233,15 @@ GUARD_NUDGE = "雿瓷隞颱極摮撮蝻
206 233  
207 234  
208 235 def run_trajectory(system_prompt, tools, history, utterance):
209   - """撣衣漣膘餈對撌亙蝑摮 瘜典蝥迤霂甈∴ AgentChatController嚗"""
  236 + """撣衣漣膘餈對撌亙蝑摮 瘜典蝥迤霂甈∴ AgentChatController嚗
  237 + 嚗reviewChange嚗earch3 霂駁極嚗 proposeWrite 霂凋 proposeWrite """
210 238 calls, text, steps, err = run_trajectory_once(system_prompt, tools, history, utterance)
211   - if err or calls or not text or not any(c.isdigit() for c in text):
212   - return calls, text, steps, err
213   - retry_hist = history + [("user", utterance), ("assistant", text)]
214   - calls2, text2, steps2, err2 = run_trajectory_once(system_prompt, tools, retry_hist, GUARD_NUDGE + utterance)
215   - return calls2, text2, steps + steps2, err2
  239 + if not (err or calls or not text or not any(c.isdigit() for c in text)):
  240 + retry_hist = history + [("user", utterance), ("assistant", text)]
  241 + calls, text, steps2, err = run_trajectory_once(system_prompt, tools, retry_hist, GUARD_NUDGE + utterance)
  242 + steps += steps2
  243 + calls = [("proposeWrite" if n == "previewChange" else n, a) for n, a in calls]
  244 + return calls, text, steps, err
216 245  
217 246  
218 247 # ---------------- ----------------
... ...
sql/ai_op_queue.sql
1   --- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。
2   --- 覆盖 update / create / delete / examine / generate(sOpType 区分)。
3   --- update/delete/examine:用 sTargetBillId 指向目标记录;改字段用 sField/sNewValue。
4   --- create/generate:整表 payload 存 sPayload(列->值 JSON);generate 的源引用存 sSourceRef。
5   --- bAutoExecute=1(如报价)→ ERP 确认后自动执行并回 sResultBillId;=0 → 预填表单让用户手动提交。
  1 +-- ai_op_queue:AI 写操作队列(rearch3 §3:**AI 侧唯一的写入口**)。
  2 +-- 用户在预览卡/表单上点按钮(保存/审核/作废/…)→ xlyAi 校验后落一行 sStatus='confirmed'。
  3 +-- xlyAi 工作到此为止:执行/审计由 ERP 侧负责(暂存执行器 /ai/execStaging 读本表;
  4 +-- 现状=报价自动执行、其余进待办)。覆盖 update / create / delete / examine / invalid(sOpType 区分)。
  5 +-- update:sTargetBillId + sField/sNewValue(单字段;一次保存多字段 = 多行)。
  6 +-- invalid:sNewValue 存 handleType(toVoid=作废 / cancel=复原);examine:sNewValue 存 iFlag(1/0)。
  7 +-- create:整表 payload 存 sPayload(列->值 JSON,多表用 __tables__)。
  8 +-- 列名兼容:ERP 执行器仍读 sUserId → sMakePerson(ERP 惯例)双写,ERP 侧切换后可删 sUserId。
6 9 CREATE TABLE IF NOT EXISTS ai_op_queue (
7 10 sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token
8   - sUserId varchar(64),
  11 + sUserId varchar(64), -- 旧列(ERP 执行器在读,暂保留)
  12 + sMakePerson varchar(64), -- 新列(ERP 惯例;与 sUserId 双写)
9 13 sConversationId varchar(96),
10 14 sBrandsId varchar(32),
11 15 sSubsidiaryId varchar(32),
... ...
sql/ai_skill_seed.sql
1 1 -- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。
2 2 INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-query','查询数据','查数量/概况、找记录、看单条详情、翻页时的标准查法','【技能:查询数据】\n1. 不确定该查哪张表单:先 findForms(业务关键词);同名多张时优先选检索结果靠前的那张。\n2. 问总数/概况:readFormData 且 keyword 留空;找某个名称的记录:readFormData 填 keyword;要某条记录的完整信息或某个字段(电话/销售员…):lookupRecord。\n3. 用户要下一页/更多:readFormData 同参数、page 加 1。\n4. 答案里的每个数字都必须来自本轮工具结果;工具没查到就如实说没查到。\n5. 回答用记录名称,不用内部 id。给出答案即停。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
3   -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-quote-create','新建报价','用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价','【技能:新建报价】\n问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。\n1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。\n2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。\n3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。\n4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。\n5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
4   -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-create','新建单据','新建报价以外的单据或资料(客户/供应商/物料/订单…)','【技能:新建单据】\n1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。\n2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。\n3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。\n4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
5   -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-status','单据状态操作','作废/删除、复原、审核、反审核/销审某张已有单据','【技能:单据状态操作】\n动作对照(proposeWrite 的 action):\n- 删除/取消/不要了 → invalid(作废,可复原)\n- 复原/恢复/取消作废 → cancelInvalid\n- 审核/审核通过 → examine\n- 反审核/销审/撤回审核 → cancelExamine\n- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复\n1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。\n2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。\n3. 工具提示多条匹配:念候选让用户选后再调一次。\n4. 提议卡出现即停,请用户点【确认】;绝不说已完成。\n消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
6   -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-update','修改记录','改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段','【技能:修改记录】\n1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。\n2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。\n3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。\n4. 提议卡出现即停,请用户点【确认】;绝不说已完成。\n注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  3 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-quote-create','新建报价','用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价','【技能:新建报价】\n问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。\n1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。\n2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【保存】,停下等待。\n3. 用户点保存后系统自动校验并提交待办(上下文会出现「已提交待办」),你无需再做任何写操作;绝不说「已生成报价单」。\n4. 用户问价格数字:请他在 ERP 里点【核价】得到;你自己不报任何价格数字。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  4 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-create','新建单据','新建报价以外的单据或资料(客户/供应商/物料/订单…)','【技能:新建单据】\n1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。\n2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。\n3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。\n4. 表单弹出即停,请用户填完点【保存】;保存后系统自动校验并提交待办,你无需再做任何写操作,绝不说已完成。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  5 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-status','单据状态操作','作废/删除、复原、审核、反审核/销审某张已有单据','【技能:单据状态操作】\n动作对照(previewChange 的 action):\n- 删除/取消/不要了 → invalid(作废,可复原)\n- 复原/恢复/取消作废 → cancelInvalid\n- 审核/审核通过 → examine\n- 反审核/销审/撤回审核 → cancelExamine\n- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复\n1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。\n2. previewChange(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。该工具只生成预览卡,不写入。\n3. 工具提示多条匹配:念候选让用户选后再调一次;提示状态不合法(如已审核过):如实转告用户,停下。\n4. 预览卡出现即停:请用户核对单据与状态变化后点卡上按钮(审核/作废/…);绝不说已完成。\n消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  6 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-update','修改记录','改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段','【技能:修改记录】\n1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。\n2. 齐了就 previewChange(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。该工具只生成预览卡,不写入。\n3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。\n4. 预览卡出现即停:请用户核对整张表单(改动已高亮,可在卡上继续修正)后点【保存】;绝不说已保存/已完成。\n注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
... ...
src/main/java/com/xly/config/AgentFactory.java
... ... @@ -6,21 +6,22 @@ import com.xly.agent.EventLogChatMemory;
6 6 import com.xly.agent.ReActAgent;
7 7 import com.xly.service.ErpClient;
8 8 import com.xly.service.EventProjectionService;
  9 +import com.xly.service.FormRenderService;
9 10 import com.xly.service.FormResolverService;
10 11 import com.xly.service.LedgerService;
11 12 import com.xly.service.OpService;
  13 +import com.xly.service.PreviewService;
12 14 import com.xly.service.SkillService;
13 15 import com.xly.service.SystemPromptService;
14 16 import com.xly.tool.ErpReadTool;
15 17 import com.xly.tool.FormCollectTool;
16 18 import com.xly.tool.InteractionTool;
17 19 import com.xly.tool.KgQueryTool;
18   -import com.xly.tool.ProposeWriteTool;
  20 +import com.xly.tool.PreviewChangeTool;
19 21 import com.xly.tool.UseSkillTool;
20 22 import dev.langchain4j.model.chat.StreamingChatModel;
21 23 import dev.langchain4j.service.AiServices;
22 24 import org.springframework.beans.factory.annotation.Qualifier;
23   -import org.springframework.jdbc.core.JdbcTemplate;
24 25 import org.springframework.stereotype.Component;
25 26  
26 27 /**
... ... @@ -28,10 +29,11 @@ import org.springframework.stereotype.Component;
28 29 *
29 30 * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程,
30 31 * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的
31   - * 工具实例里(ErpReadTool/ProposeWriteTool/FormCollectTool),从根上保证鉴权与 token 正确。
  32 + * 工具实例里(ErpReadTool/PreviewChangeTool/FormCollectTool),从根上保证鉴权与 token 正确。
32 33 *
33   - * <p>工具集**固定 6 个**(findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser),
34   - * 不再按意图分范围:n=50 基准显示分范围收不到准确率收益,反而破坏 KV-cache 前缀稳定性。
  34 + * <p>固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/previewChange/askUser。
  35 + * **模型可用的工具全部只读/只渲染**(rearch3 §1/§3):写路径 = 预览卡/表单 → 用户点按钮 →
  36 + * 确定性端点写 ai_op_queue,模型碰不到任何写入口。
35 37 * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。
36 38 */
37 39 @Component
... ... @@ -43,9 +45,9 @@ public class AgentFactory {
43 45 private final SystemPromptService systemPromptService;
44 46  
45 47 private final ErpClient erp;
46   - private final JdbcTemplate jdbc;
47 48 private final FormResolverService resolver;
48   - private final OpService ops;
  49 + private final FormRenderService render;
  50 + private final PreviewService previews;
49 51 private final ObjectMapper mapper;
50 52  
51 53 private final KgQueryTool kgQueryTool;
... ... @@ -55,7 +57,8 @@ public class AgentFactory {
55 57 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
56 58 LedgerService ledger, EventProjectionService projection,
57 59 SystemPromptService systemPromptService,
58   - ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
  60 + ErpClient erp, FormResolverService resolver, FormRenderService render,
  61 + PreviewService previews, OpService ops,
59 62 ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool,
60 63 SkillService skillService) {
61 64 this.streamingModel = streamingModel;
... ... @@ -63,20 +66,22 @@ public class AgentFactory {
63 66 this.projection = projection;
64 67 this.systemPromptService = systemPromptService;
65 68 this.erp = erp;
66   - this.jdbc = jdbc;
67 69 this.resolver = resolver;
68   - this.ops = ops;
  70 + this.render = render;
  71 + this.previews = previews;
69 72 this.mapper = mapper;
70 73 this.kgQueryTool = kgQueryTool;
71 74 this.interactionTool = interactionTool;
72 75 this.skillService = skillService;
  76 + // 流程卡的「已提交待办」行读 ai_op_queue.sStatus 只读进度(rearch3 §3)
  77 + projection.setOpStatusLookup(ops::statusLabel);
73 78 }
74 79  
75 80 /**
76   - * 组装 ReAct agent:固定 7 工具(useSkill + 6)+ 唯一 system prompt。
  81 + * 组装 ReAct agent:固定 7 工具 + 唯一 system prompt。
77 82 * {@code maxSequentialToolsInvocations} 作为循环护栏,杜绝失控 ReAct 循环。
78 83 *
79   - * @param convId 会话 id(useSkill 落 skill_active 事件需要)
  84 + * @param convId 会话 id(useSkill/previewChange 落事件需要)
80 85 * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记,
81 86 * 前端历史不显示
82 87 */
... ... @@ -84,8 +89,8 @@ public class AgentFactory {
84 89 Object[] tools = new Object[]{kgQueryTool, interactionTool,
85 90 new UseSkillTool(skillService, ledger, convId),
86 91 new ErpReadTool(erp, resolver, identity),
87   - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
88   - new FormCollectTool(jdbc, resolver, identity, mapper)};
  92 + new PreviewChangeTool(previews, identity, convId),
  93 + new FormCollectTool(render, resolver, identity, mapper)};
89 94  
90 95 return AiServices.builder(ReActAgent.class)
91 96 .streamingChatModel(streamingModel)
... ... @@ -97,9 +102,4 @@ public class AgentFactory {
97 102 .systemMessageProvider(memoryId -> systemPromptService.prompt())
98 103 .build();
99 104 }
100   -
101   - /** 供确定性「表单提交」端点直接调 proposeWrite(action=create)(按钮路径,不经 LLM)。 */
102   - public ProposeWriteTool proposeWriteTool(AgentIdentity identity) {
103   - return new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver);
104   - }
105 105 }
... ...
src/main/java/com/xly/service/AuditService.java deleted
1   -package com.xly.service;
2   -
3   -import org.slf4j.Logger;
4   -import org.slf4j.LoggerFactory;
5   -import org.springframework.jdbc.core.JdbcTemplate;
6   -import org.springframework.stereotype.Service;
7   -
8   -/**
9   - * 业务审计:写操作 / SQL 的不可变留痕(谁、何时、什么动作、目标、结果)。
10   - * 记账失败绝不阻断主流程(审计是旁路)。
11   - */
12   -@Service
13   -public class AuditService {
14   -
15   - private static final Logger log = LoggerFactory.getLogger(AuditService.class);
16   -
17   - private final JdbcTemplate jdbc;
18   -
19   - public AuditService(JdbcTemplate jdbc) {
20   - this.jdbc = jdbc;
21   - }
22   -
23   - public void log(String userId, String conversationId, String action,
24   - String target, String detail, boolean ok, String resultMsg) {
25   - try {
26   - jdbc.update(
27   - "INSERT INTO ai_audit_log(tTime,sUserId,sConversationId,sAction,sTarget,sDetail,sResult,sResultMsg) " +
28   - "VALUES(NOW(),?,?,?,?,?,?,?)",
29   - userId, conversationId, action, trunc(target, 200), trunc(detail, 60000),
30   - ok ? "ok" : "fail", trunc(resultMsg, 500));
31   - } catch (Exception e) {
32   - log.warn("audit write failed (action={}): {}", action, e.getMessage());
33   - }
34   - }
35   -
36   - private static String trunc(String s, int max) {
37   - if (s == null) {
38   - return null;
39   - }
40   - return s.length() > max ? s.substring(0, max) : s;
41   - }
42   -}
src/main/java/com/xly/service/ErpClient.java
... ... @@ -19,18 +19,19 @@ import java.util.List;
19 19 import java.util.Map;
20 20  
21 21 /**
22   - * ERP 后端(xlyEntry)薄 HTTP 客户端。
23   - *
24   - * <p>xlyAi 的读写业务逻辑在 ERP 后端,xlyAi 只是像前端一样发同样的 API 请求。本类负责:
  22 + * ERP 后端(xlyEntry)薄 HTTP 客户端——rearch3 收权后**只读**(§3:AI 侧唯一的写 = ai_op_queue,
  23 + * 执行/审计全部移交 ERP 侧;原 addUpdateDelBusinessData/updatebInvalid/doExamine/execStaging
  24 + * 等写方法已全部退役)。保留:
25 25 * <ul>
26 26 * <li><b>token 解析(架构 §7)</b>:每次调用优先用调用方**透传的用户 ERP 会话 token**
27 27 * (per-request、不长期存、绝不进 prompt);只有在完全没有用户 token 时才退回配置的 dev 账号
28 28 * {@code /checklogin} 登录并缓存。<b>用户 token 过期不会用 dev 账号重登</b>(见 {@code canRelogin}),
29 29 * 避免静默提权;只有 dev-login 自己的会话过期(code=-2)才自动重登一次重试。</li>
  30 + * <li><b>whoami</b>:token 服务端内省(身份唯一来源,fail-closed)。</li>
30 31 * <li><b>读</b>:通用表单读接口 {@code getBusinessDataByFormcustomId}(只传分页/过滤参数)。</li>
31   - * <li><b>写</b>:{@code addUpdateDelBusinessData}(handleType=add/update/del,含多表主-从一次提交)、
32   - * {@code updatebInvalid}(作废/复原)、{@code doExamine}(审核/销审)、{@code getUuid}(取主键)。</li>
33   - * <li><b>暂存执行器</b>:{@code /ai/execStaging/{sId}}(生产路径,由 {@code erp.exec-staging.enabled} 开关)。</li>
  32 + * <li><b>getUuid</b>:为 create 载荷预生成主键(非业务写入)。</li>
  33 + * <li><b>checkBusinessData(Phase D 接口位)</b>:ERP dry-run 校验(独立事务强制回滚,validate-only),
  34 + * ERP 侧完工前由 {@code erp.dry-run.enabled=false} 关闭。</li>
34 35 * </ul>
35 36 */
36 37 @Service
... ... @@ -102,7 +103,7 @@ public class ErpClient {
102 103 }
103 104  
104 105 /**
105   - * 拼进 URL 的 id(formId/moduleId/opId)必须是纯 id 形态。这些值可能来自模型输出,
  106 + * 拼进 URL 的 id(formId/moduleId)必须是纯 id 形态。这些值可能来自模型输出,
106 107 * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。
107 108 */
108 109 private static String safeId(String id) {
... ... @@ -197,166 +198,7 @@ public class ErpClient {
197 198 }
198 199 }
199 200  
200   - /**
201   - * 执行一次字段更新(addUpdateDelBusinessData, handleType=update)。会话过期自动重登重试。
202   - * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。
203   - */
204   - public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) {
205   - return updateForm(null, moduleId, table, billId, field, value);
206   - }
207   -
208   - public JsonNode updateForm(String authToken, String moduleId, String table, String billId, String field, String value) {
209   - JsonNode root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken));
210   - if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
211   - login();
212   - root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken));
213   - }
214   - return root;
215   - }
216   -
217   - /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */
218   - public JsonNode deleteForm(String moduleId, String table, String billId) {
219   - return deleteForm(null, moduleId, table, billId);
220   - }
221   -
222   - public JsonNode deleteForm(String authToken, String moduleId, String table, String billId) {
223   - JsonNode root = doDelete(moduleId, table, billId, resolveToken(authToken));
224   - if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
225   - login();
226   - root = doDelete(moduleId, table, billId, resolveToken(authToken));
227   - }
228   - return root;
229   - }
230   -
231   - /**
232   - * 审核 / 反审核一条单据(ERP {@code /business/doExamine})。{@code iFlag}=1 审核、0 反审核(消审)。
233   - * 审核逻辑由 ERP 按表单数据驱动的存储过程执行({@code gdsmodule.sProcName})。会话过期自动重登重试。
234   - */
235   - public JsonNode examineForm(String authToken, String moduleId, String billId, int iFlag) {
236   - JsonNode root = doExamine(moduleId, billId, iFlag, resolveToken(authToken));
237   - if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
238   - login();
239   - root = doExamine(moduleId, billId, iFlag, resolveToken(authToken));
240   - }
241   - return root;
242   - }
243   -
244   - /**
245   - * 作废 / 取消作废(复原)一条记录(ERP {@code /checkModel/updatebInvalid} → 存储过程 {@code Sp_Invalidation})。
246   - * {@code cancel=false} → 作废(handleType=toVoid);{@code cancel=true} → 取消作废/复原(handleType=cancel)。
247   - * 业务单据的“删除”应走这里(软作废,可复原),而非物理删。会话过期自动重登重试。
248   - */
249   - public JsonNode invalidForm(String authToken, String moduleId, String table, String billId, boolean cancel) {
250   - JsonNode root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken));
251   - if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
252   - login();
253   - root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken));
254   - }
255   - return root;
256   - }
257   -
258   - private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) {
259   - try {
260   - String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + safeId(moduleId);
261   - Map<String, Object> body = new LinkedHashMap<>();
262   - body.put("sId", List.of(billId)); // 后端 sId 期望 List<String>
263   - body.put("sTableName", table);
264   - body.put("handleType", cancel ? "cancel" : "toVoid");
265   - body.put("sClientType", "PC");
266   - body.put("sComputeName", "AI-Agent");
267   - body.put("sIpAddress", "127.0.0.1");
268   - body.put("sLanguage", "chinese");
269   - String json = mapper.writeValueAsString(body);
270   - HttpRequest req = HttpRequest.newBuilder(URI.create(url))
271   - .header("Content-Type", "application/json;charset=UTF-8")
272   - .header("Authorization", tok)
273   - .timeout(Duration.ofSeconds(60))
274   - .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
275   - .build();
276   - HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
277   - return mapper.readTree(resp.body());
278   - } catch (Exception e) {
279   - throw new RuntimeException("ERP 作废异常: " + e.getMessage(), e);
280   - }
281   - }
282   -
283   - /**
284   - * 委托 ERP 侧暂存执行器执行一条暂存写操作(架构 §10)。ERP 读共享库 {@code ai_op_queue} 行、以用户身份
285   - * 执行并回写状态,返回 {@code {status, msg, billId}}。用户 token 必传(执行以用户身份进行)。
286   - */
287   - public JsonNode execStaging(String authToken, String opId) {
288   - try {
289   - String url = baseUrl + "/ai/execStaging/" + safeId(opId);
290   - HttpRequest req = HttpRequest.newBuilder(URI.create(url))
291   - .header("Content-Type", "application/json;charset=UTF-8")
292   - .header("Authorization", resolveToken(authToken))
293   - .timeout(Duration.ofSeconds(120))
294   - .POST(HttpRequest.BodyPublishers.ofString("{}", StandardCharsets.UTF_8))
295   - .build();
296   - HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
297   - JsonNode root = mapper.readTree(resp.body());
298   - if (root.has("status")) {
299   - return root;
300   - }
301   - // ERP 统一响应包装:真实结果 {status,msg,billId} 在 dataset.rows[0]
302   - JsonNode row = root.path("dataset").path("rows").path(0);
303   - return row.isObject() ? row : root;
304   - } catch (Exception e) {
305   - throw new RuntimeException("ERP 暂存执行异常: " + e.getMessage(), e);
306   - }
307   - }
308   -
309   - private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) {
310   - try {
311   - String url = baseUrl + "/business/doExamine?sModelsId=" + safeId(moduleId);
312   - Map<String, Object> paramsMap = new LinkedHashMap<>();
313   - paramsMap.put("sFormGuid", moduleId);
314   - paramsMap.put("sGuid", billId);
315   - paramsMap.put("iFlag", iFlag);
316   - paramsMap.put("sSlaveId", "");
317   - String body = mapper.writeValueAsString(Map.of("paramsMap", paramsMap));
318   - HttpRequest req = HttpRequest.newBuilder(URI.create(url))
319   - .header("Content-Type", "application/json;charset=UTF-8")
320   - .header("Authorization", tok)
321   - .timeout(Duration.ofSeconds(60))
322   - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
323   - .build();
324   - HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
325   - return mapper.readTree(resp.body());
326   - } catch (Exception e) {
327   - throw new RuntimeException("ERP 审核异常: " + e.getMessage(), e);
328   - }
329   - }
330   -
331   - private JsonNode doDelete(String moduleId, String table, String billId, String tok) {
332   - try {
333   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
334   - Map<String, Object> col = new LinkedHashMap<>();
335   - col.put("handleType", "del");
336   - col.put("sId", billId);
337   - Map<String, Object> dataItem = new LinkedHashMap<>();
338   - dataItem.put("sTable", table);
339   - dataItem.put("name", "master");
340   - dataItem.put("column", List.of(col));
341   - String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem)));
342   - HttpRequest req = HttpRequest.newBuilder(URI.create(url))
343   - .header("Content-Type", "application/json;charset=UTF-8")
344   - .header("Authorization", tok)
345   - .timeout(Duration.ofSeconds(30))
346   - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
347   - .build();
348   - HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
349   - return mapper.readTree(resp.body());
350   - } catch (Exception e) {
351   - throw new RuntimeException("ERP 删除异常: " + e.getMessage(), e);
352   - }
353   - }
354   -
355   - /** 取一个新主键 uuid(ERP `/getUuid`)。 */
356   - public String newUuid() {
357   - return newUuid(null);
358   - }
359   -
  201 + /** 取一个新主键 uuid(ERP `/getUuid`;为 create 载荷预生成主键,非业务写入)。 */
360 202 public String newUuid(String authToken) {
361 203 try {
362 204 HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid"))
... ... @@ -373,109 +215,51 @@ public class ErpClient {
373 215 }
374 216  
375 217 /**
376   - * 新增一条记录。走 {@code addUpdateDelBusinessData?sModelsId=}(handleType=add)——与 ERP 前端一致,
377   - * 复用校验/单号/租户;{@code columns} 里已由 ProposeWrite 备好 sId/sFormId/sBillNo 与类型化字段值。
378   - * 会话过期自动重登重试(仅 dev-login)。
379   - */
380   - public JsonNode createForm(String authToken, String moduleId, String table, Map<String, Object> columns) {
381   - JsonNode root = doCreate(moduleId, table, columns, resolveToken(authToken));
382   - if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
383   - login();
384   - root = doCreate(moduleId, table, columns, resolveToken(authToken));
385   - }
386   - return root;
387   - }
388   -
389   - /**
390   - * 多表联动新增(主-从:报价 = 主表 + 印刷/部件从表 + 多数量)。{@code tables} 每项 = {@code {sTable, name, column(map)}},
391   - * 本方法给每行补 handleType=add 并一次性 POST addUpdateDelBusinessData,从表靠 sParentId 关联主表。
  218 + * <b>Phase D 接口位</b>:ERP dry-run 校验 {@code /business/checkBusinessData}(复用真实校验链、
  219 + * REQUIRES_NEW 独立事务强制回滚,validate-only)。请求体与 addUpdateDelBusinessData 同形。
  220 + * {@code payloadJson} = 列->值 JSON(create 可含 __tables__ 多表);{@code handleType} = add|update|del。
  221 + * ⚠️ ERP 侧该端点尚未提交上线(他人负责);调用方须由 {@code erp.dry-run.enabled} 开关保护。
392 222 */
393   - public JsonNode createMulti(String authToken, String moduleId, List<Map<String, Object>> tables) {
394   - JsonNode root = doCreateMulti(moduleId, tables, resolveToken(authToken));
395   - if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
396   - login();
397   - root = doCreateMulti(moduleId, tables, resolveToken(authToken));
398   - }
399   - return root;
400   - }
401   -
402   - @SuppressWarnings("unchecked")
403   - private JsonNode doCreateMulti(String moduleId, List<Map<String, Object>> tables, String tok) {
  223 + public JsonNode checkBusinessData(String authToken, String moduleId, String table,
  224 + String payloadJson, String handleType) {
404 225 try {
405   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
  226 + String url = baseUrl + "/business/checkBusinessData?sModelsId=" + safeId(moduleId);
  227 + JsonNode payload = mapper.readTree(payloadJson);
406 228 List<Map<String, Object>> data = new ArrayList<>();
407   - for (Map<String, Object> t : tables) {
408   - Map<String, Object> col = new LinkedHashMap<>((Map<String, Object>) t.get("column"));
409   - col.put("handleType", "add");
410   - Map<String, Object> item = new LinkedHashMap<>();
411   - item.put("sTable", t.get("sTable"));
412   - item.put("name", t.get("name"));
413   - item.put("column", List.of(col));
414   - data.add(item);
  229 + if (payload.has("__tables__")) {
  230 + for (JsonNode t : payload.get("__tables__")) {
  231 + @SuppressWarnings("unchecked")
  232 + Map<String, Object> col = mapper.convertValue(t.get("column"), Map.class);
  233 + col.put("handleType", handleType);
  234 + data.add(dataItem(t.path("sTable").asText(""), t.path("name").asText("master"), col));
  235 + }
  236 + } else {
  237 + @SuppressWarnings("unchecked")
  238 + Map<String, Object> col = mapper.convertValue(payload, Map.class);
  239 + col.put("handleType", handleType);
  240 + data.add(dataItem(table, "master", col));
415 241 }
416 242 Map<String, Object> body = new LinkedHashMap<>();
417 243 body.put("sModelsId", moduleId);
418 244 body.put("data", data);
419 245 HttpRequest req = HttpRequest.newBuilder(URI.create(url))
420 246 .header("Content-Type", "application/json;charset=UTF-8")
421   - .header("Authorization", tok)
  247 + .header("Authorization", resolveToken(authToken))
422 248 .timeout(Duration.ofSeconds(40))
423 249 .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8))
424 250 .build();
425 251 HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
426 252 return mapper.readTree(resp.body());
427 253 } catch (Exception e) {
428   - throw new RuntimeException("ERP 多表新增异常: " + e.getMessage(), e);
429   - }
430   - }
431   -
432   - private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) {
433   - try {
434   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
435   - Map<String, Object> col = new LinkedHashMap<>(columns);
436   - col.put("handleType", "add");
437   - Map<String, Object> dataItem = new LinkedHashMap<>();
438   - dataItem.put("sTable", table);
439   - dataItem.put("name", "master");
440   - dataItem.put("column", List.of(col));
441   - Map<String, Object> body = new LinkedHashMap<>();
442   - body.put("sModelsId", moduleId);
443   - body.put("data", List.of(dataItem));
444   - HttpRequest req = HttpRequest.newBuilder(URI.create(url))
445   - .header("Content-Type", "application/json;charset=UTF-8")
446   - .header("Authorization", tok)
447   - .timeout(Duration.ofSeconds(30))
448   - .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8))
449   - .build();
450   - HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
451   - return mapper.readTree(resp.body());
452   - } catch (Exception e) {
453   - throw new RuntimeException("ERP 新增异常: " + e.getMessage(), e);
  254 + throw new RuntimeException("ERP 校验异常: " + e.getMessage(), e);
454 255 }
455 256 }
456 257  
457   - private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) {
458   - try {
459   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
460   - Map<String, Object> col = new LinkedHashMap<>();
461   - col.put("handleType", "update");
462   - col.put("sId", billId);
463   - col.put(field, value);
464   - Map<String, Object> dataItem = new LinkedHashMap<>();
465   - dataItem.put("sTable", table);
466   - dataItem.put("name", "master");
467   - dataItem.put("column", List.of(col));
468   - String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem)));
469   - HttpRequest req = HttpRequest.newBuilder(URI.create(url))
470   - .header("Content-Type", "application/json;charset=UTF-8")
471   - .header("Authorization", tok)
472   - .timeout(Duration.ofSeconds(30))
473   - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
474   - .build();
475   - HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
476   - return mapper.readTree(resp.body());
477   - } catch (Exception e) {
478   - throw new RuntimeException("ERP 更新异常: " + e.getMessage(), e);
479   - }
  258 + private Map<String, Object> dataItem(String table, String name, Map<String, Object> col) {
  259 + Map<String, Object> item = new LinkedHashMap<>();
  260 + item.put("sTable", table);
  261 + item.put("name", name);
  262 + item.put("column", List.of(col));
  263 + return item;
480 264 }
481 265 }
... ...
src/main/java/com/xly/service/FormRenderService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.JsonNode;
  4 +import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.agent.AgentIdentity;
  6 +import org.springframework.jdbc.core.JdbcTemplate;
  7 +import org.springframework.stereotype.Service;
  8 +
  9 +import java.util.ArrayList;
  10 +import java.util.Iterator;
  11 +import java.util.LinkedHashMap;
  12 +import java.util.List;
  13 +import java.util.Map;
  14 +import java.util.Set;
  15 +
  16 +/**
  17 + * 表单渲染/校验的**共享内核**(rearch3 §1 分层定案):collectForm(收集信息)与
  18 + * previewChange(请求核准)在 LLM 层是两个语义不同的工具,实现层共用这里的
  19 + * 表单骨架生成、记录定位读取、字段字典映射、FK 名称→id、类型强转、系统列拒改与 create 载荷构建
  20 + * ——预览与保存端点用**同一份校验**,杜绝"预览过、保存拒"的漂移。
  21 + *
  22 + * <p>本服务只读不写:唯一的写入口在 {@link OpService}(ai_op_queue)。
  23 + */
  24 +@Service
  25 +public class FormRenderService {
  26 +
  27 + private static final int MAX_FIELDS = 40;
  28 +
  29 + private final JdbcTemplate jdbc;
  30 + private final FormResolverService resolver;
  31 + private final ErpClient erp;
  32 + private final ObjectMapper mapper;
  33 +
  34 + public FormRenderService(JdbcTemplate jdbc, FormResolverService resolver, ErpClient erp, ObjectMapper mapper) {
  35 + this.jdbc = jdbc;
  36 + this.resolver = resolver;
  37 + this.erp = erp;
  38 + this.mapper = mapper;
  39 + }
  40 +
  41 + // ---------------------------------------------------------------- 表单骨架
  42 +
  43 + /**
  44 + * 某表单的业务录入字段骨架(label/控件类型/fk 来源/选项/提示)。
  45 + * 主来源 = 字段字典的业务录入字段(含报价策展),字典没有映射时退回
  46 + * {@code gdsconfigformslave} 的可见可写字段(排除系统列)。
  47 + */
  48 + public List<Map<String, Object>> formSkeleton(String table, String formId) {
  49 + List<Map<String, Object>> fields = new ArrayList<>();
  50 + List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS);
  51 + Map<String, String> types = resolver.columnTypes(table);
  52 + for (Map<String, Object> b : biz) {
  53 + String col = str(b.get("col"));
  54 + if (col == null || col.isBlank()) {
  55 + continue;
  56 + }
  57 + Map<String, Object> f = new LinkedHashMap<>();
  58 + f.put("name", col);
  59 + f.put("label", firstNonBlank(str(b.get("label")), col));
  60 + f.put("required", false);
  61 + Object target = b.get("table");
  62 + if (target != null) {
  63 + f.put("target", String.valueOf(target)); // 报价策展:master/slave/note/manyqtys
  64 + }
  65 + Object fk = b.get("fk");
  66 + Object opts = b.get("options");
  67 + String type = str(b.get("type"));
  68 + if (fk != null && !String.valueOf(fk).isBlank()) {
  69 + f.put("type", "fkselect");
  70 + f.put("fkTable", String.valueOf(fk));
  71 + } else if (opts instanceof List && !((List<?>) opts).isEmpty()) {
  72 + f.put("type", "select");
  73 + f.put("options", opts);
  74 + } else if (type != null && !type.isBlank()) {
  75 + f.put("type", type);
  76 + } else {
  77 + f.put("type", inferType(types.get(col)));
  78 + }
  79 + Object hint = b.get("hint");
  80 + if (hint != null && !String.valueOf(hint).isBlank()) {
  81 + f.put("hint", String.valueOf(hint));
  82 + }
  83 + fields.add(f);
  84 + }
  85 + if (!fields.isEmpty() || formId == null) {
  86 + return fields;
  87 + }
  88 + // 兜底:界面元数据的非只读可见字段
  89 + try {
  90 + List<Map<String, Object>> cols = jdbc.queryForList(
  91 + "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " +
  92 + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " +
  93 + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
  94 + for (Map<String, Object> c : cols) {
  95 + String name = str(c.get("sName"));
  96 + if (name == null || name.isBlank() || resolver.isSystemColumn(name)) {
  97 + continue;
  98 + }
  99 + Map<String, Object> f = new LinkedHashMap<>();
  100 + f.put("name", name);
  101 + f.put("label", firstNonBlank(str(c.get("sChinese")), name));
  102 + f.put("required", truthy(c.get("bNotEmpty")));
  103 + String def = str(c.get("sDefault"));
  104 + if (def != null && !def.isBlank()) {
  105 + f.put("default", def);
  106 + }
  107 + List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));
  108 + if (!opts.isEmpty()) {
  109 + f.put("type", "select");
  110 + f.put("options", opts);
  111 + } else {
  112 + f.put("type", "text");
  113 + }
  114 + fields.add(f);
  115 + }
  116 + } catch (Exception ignore) {
  117 + }
  118 + return fields;
  119 + }
  120 +
  121 + // ---------------------------------------------------------------- 记录定位
  122 +
  123 + /** 定位结果:error 非空表示失败(用户可读消息);否则携带主表与唯一记录。 */
  124 + public static final class Located {
  125 + public String error;
  126 + public String formId;
  127 + public String moduleId;
  128 + public String table;
  129 + public JsonNode rec;
  130 + public String billId;
  131 + public String recordName;
  132 + }
  133 +
  134 + /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称/单号模糊匹配 → 0/多条/缺主键报错。 */
  135 + public Located locateRecord(AgentIdentity identity, String entityKeyword, String recordKeyword, String verb) {
  136 + Located l = new Located();
  137 + Map<String, Object> form = resolver.resolveMasterForm(entityKeyword == null ? null : entityKeyword.trim());
  138 + if (form == null) {
  139 + l.error = "找不到「" + entityKeyword + "」对应的可操作主表。";
  140 + return l;
  141 + }
  142 + l.formId = str(form.get("sFormId"));
  143 + l.moduleId = str(form.get("sModuleId"));
  144 + l.table = str(form.get("sDataSource"));
  145 + if (!identity.canAccessModule(l.moduleId)) {
  146 + l.error = "你没有" + verb + "「" + entityKeyword + "」的权限。";
  147 + return l;
  148 + }
  149 + String nameField = resolver.searchField(l.table, recordKeyword);
  150 + JsonNode root;
  151 + try {
  152 + root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim());
  153 + } catch (Exception e) {
  154 + l.error = "定位记录失败:" + e.getMessage();
  155 + return l;
  156 + }
  157 + if (root.path("code").asInt(0) < 0) {
  158 + l.error = "定位记录失败:" + root.path("msg").asText("未知错误");
  159 + return l;
  160 + }
  161 + JsonNode rows = root.path("dataset").path("rows");
  162 + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
  163 + int n = (data != null && data.isArray()) ? data.size() : 0;
  164 + if (n == 0) {
  165 + l.error = "没有找到名称含「" + recordKeyword + "」的记录。";
  166 + return l;
  167 + }
  168 + if (n > 1) {
  169 + StringBuilder names = new StringBuilder();
  170 + for (int i = 0; i < data.size() && i < 5; i++) {
  171 + if (i > 0) names.append("、");
  172 + names.append(nameField == null ? "" : data.get(i).path(nameField).asText(""));
  173 + }
  174 + l.error = "匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。";
  175 + return l;
  176 + }
  177 + l.rec = data.get(0);
  178 + l.billId = l.rec.path("sId").asText(null);
  179 + if (isBlank(l.billId)) {
  180 + l.error = "定位到的记录缺少主键 sId,无法" + verb + "。";
  181 + return l;
  182 + }
  183 + l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword);
  184 + return l;
  185 + }
  186 +
  187 + /** 保存端点用:按主键重读记录(重校验"所见即所写")。 */
  188 + public Located locateById(AgentIdentity identity, String formId, String moduleId, String table, String billId) {
  189 + Located l = new Located();
  190 + l.formId = formId;
  191 + l.moduleId = moduleId;
  192 + l.table = table;
  193 + JsonNode root;
  194 + try {
  195 + root = erp.readForm(identity.token(), formId, moduleId, 1, 2, "sId", billId);
  196 + } catch (Exception e) {
  197 + l.error = "重读记录失败:" + e.getMessage();
  198 + return l;
  199 + }
  200 + if (root.path("code").asInt(0) < 0) {
  201 + l.error = "重读记录失败:" + root.path("msg").asText("未知错误");
  202 + return l;
  203 + }
  204 + JsonNode rows = root.path("dataset").path("rows");
  205 + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
  206 + if (data == null || !data.isArray() || data.isEmpty()) {
  207 + l.error = "该记录已不存在(可能被删除)。";
  208 + return l;
  209 + }
  210 + l.rec = data.get(0);
  211 + l.billId = billId;
  212 + return l;
  213 + }
  214 +
  215 + // ---------------------------------------------------------------- 字段解析与规范化
  216 +
  217 + /** 字段中文名 → {col, fk}(先精确合并模糊,取使用度最高的一列)。找不到返回 null。 */
  218 + public Map<String, Object> resolveColumn(String table, String zh) {
  219 + try {
  220 + List<Map<String, Object>> r = jdbc.queryForList(
  221 + "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " +
  222 + "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " +
  223 + "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1",
  224 + table, zh, "%" + zh + "%");
  225 + if (r.isEmpty()) {
  226 + return null;
  227 + }
  228 + Map<String, Object> m = r.get(0);
  229 + Object fk = m.get("fk");
  230 + if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) {
  231 + m.put("fk", null);
  232 + }
  233 + return m;
  234 + } catch (Exception e) {
  235 + return null;
  236 + }
  237 + }
  238 +
  239 + /** 规范化结果:error 非空 = 值非法(用户可读消息);否则 stored=入库值、shown=展示值。 */
  240 + public static final class Normalized {
  241 + public String error;
  242 + public Object stored;
  243 + public String shown;
  244 + }
  245 +
  246 + /**
  247 + * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内),其余按列类型强转。
  248 + * 预览与保存共用同一份逻辑("所见即所写"的前半)。
  249 + */
  250 + public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) {
  251 + Normalized n = new Normalized();
  252 + if (fk != null && !fk.isBlank()) {
  253 + String id = resolver.resolveFk(fk, identity.brandsId(), value);
  254 + if (id == null) {
  255 + String ent = label == null ? "" : label.replace("名称", "");
  256 + n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。";
  257 + return n;
  258 + }
  259 + n.stored = id;
  260 + n.shown = value;
  261 + return n;
  262 + }
  263 + try {
  264 + n.stored = resolver.coerce(resolver.columnTypes(table).get(col), value);
  265 + } catch (IllegalArgumentException ex) {
  266 + n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。";
  267 + return n;
  268 + }
  269 + if (n.stored == null) {
  270 + n.error = "「" + value + "」不是「" + label + "」可接受的值(日期请用 2026-07-29 这种格式)。";
  271 + return n;
  272 + }
  273 + n.shown = String.valueOf(n.stored);
  274 + return n;
  275 + }
  276 +
  277 + /** 外键 id → 名称(预览展示用:记录里存 id,用户要看名称)。解析不到返回原 id。 */
  278 + public String fkDisplayName(String fkTable, String id) {
  279 + if (fkTable == null || fkTable.isBlank() || id == null || id.isBlank()) {
  280 + return id;
  281 + }
  282 + try {
  283 + String nameField = resolver.resolveNameField(fkTable);
  284 + if (nameField == null) {
  285 + return id;
  286 + }
  287 + List<Map<String, Object>> r = jdbc.queryForList(
  288 + "SELECT `" + nameField + "` FROM `" + fkTable + "` WHERE sId=? LIMIT 1", id);
  289 + if (!r.isEmpty()) {
  290 + Object v = r.get(0).values().iterator().next();
  291 + if (v != null && !String.valueOf(v).isBlank()) {
  292 + return String.valueOf(v);
  293 + }
  294 + }
  295 + } catch (Exception ignore) {
  296 + }
  297 + return id;
  298 + }
  299 +
  300 + // ---------------------------------------------------------------- create 构建
  301 +
  302 + /** create 载荷构建结果:error 非空 = 校验失败;否则携带入队所需的全部信息。 */
  303 + public static final class CreateBuild {
  304 + public String error;
  305 + public String formId;
  306 + public String moduleId;
  307 + public String table;
  308 + public String payload;
  309 + public String description;
  310 + }
  311 +
  312 + /**
  313 + * 新增载荷构建(表单保存按钮的后端):label→列映射、FK 名称→id、类型强转、系统列拒填、
  314 + * NOT-NULL 无默认列补齐、主键/单号生成。报价走多表构建(主表+印刷从表+多数量)。
  315 + * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。
  316 + */
  317 + public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields) {
  318 + CreateBuild out = new CreateBuild();
  319 + if (isBlank(entityKeyword)) {
  320 + out.error = "缺少单据类型。";
  321 + return out;
  322 + }
  323 + Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
  324 + if (form == null) {
  325 + out.error = "找不到「" + entityKeyword + "」对应的可新增主表。";
  326 + return out;
  327 + }
  328 + out.formId = str(form.get("sFormId"));
  329 + out.moduleId = str(form.get("sModuleId"));
  330 + out.table = str(form.get("sDataSource"));
  331 + if (!identity.canAccessModule(out.moduleId)) {
  332 + out.error = "你没有新增「" + entityKeyword + "」的权限。";
  333 + return out;
  334 + }
  335 + if ("quoquotationmaster".equalsIgnoreCase(out.table)) {
  336 + return buildQuote(identity, entityKeyword, fields, out);
  337 + }
  338 +
  339 + Map<String, String> types = resolver.columnTypes(out.table);
  340 + Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
  341 + for (Map<String, Object> bfm : resolver.businessFields(out.table, MAX_FIELDS)) {
  342 + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
  343 + }
  344 + Map<String, Object> col = new LinkedHashMap<>();
  345 + List<String> descParts = new ArrayList<>();
  346 + for (Map.Entry<String, String> e : fields.entrySet()) {
  347 + String zh = e.getKey();
  348 + String v = e.getValue();
  349 + if (isBlank(v)) {
  350 + continue;
  351 + }
  352 + Map<String, Object> fm = labelMap.get(normLabel(zh));
  353 + if (fm == null) {
  354 + fm = resolveColumn(out.table, zh);
  355 + }
  356 + if (fm == null) {
  357 + continue; // 该实体没有这个字段,忽略
  358 + }
  359 + String colName = str(fm.get("col"));
  360 + if (resolver.isSystemColumn(colName)) {
  361 + continue; // 制单人/单据日期/租户/单号…由 ERP 注入
  362 + }
  363 + String fk = fm.get("fk") == null ? null : str(fm.get("fk"));
  364 + Normalized n = normalize(out.table, colName, fk, zh, v, identity);
  365 + if (n.error != null) {
  366 + out.error = n.error + (fk != null ? "(**不要**因此代建" + zh.replace("名称", "") + ")" : "");
  367 + return out;
  368 + }
  369 + col.put(colName, n.stored);
  370 + descParts.add(zh + "=" + n.shown + (n.shown.equals(v) ? "" : "(原话:" + v + ")"));
  371 + }
  372 + if (descParts.isEmpty()) {
  373 + out.error = "请至少提供一个有效字段(如客户名称)。";
  374 + return out;
  375 + }
  376 + for (String rc : requiredCols(out.table)) {
  377 + if (col.containsKey(rc) || resolver.isSystemColumn(rc)) {
  378 + continue;
  379 + }
  380 + col.put(rc, resolver.typeDefault(rc, types.get(rc)));
  381 + }
  382 + col.put("sId", erp.newUuid(identity.token()));
  383 + col.put("sFormId", out.formId);
  384 + String billNo = resolver.nextBillNo(out.table, identity.brandsId());
  385 + if (billNo != null) {
  386 + col.put("sBillNo", billNo);
  387 + }
  388 + try {
  389 + out.payload = mapper.writeValueAsString(col);
  390 + } catch (Exception e) {
  391 + out.error = "内部错误:" + e.getMessage();
  392 + return out;
  393 + }
  394 + out.description = "新增【" + entityKeyword + "】:" + String.join(",", descParts);
  395 + return out;
  396 + }
  397 +
  398 + /**
  399 + * 报价多表构建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。
  400 + * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。
  401 + * 价格由 ERP【核价】计算,只落主-从明细。
  402 + */
  403 + private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword,
  404 + Map<String, String> fields, CreateBuild out) {
  405 + Map<String, String> masterTypes = resolver.columnTypes(out.table);
  406 + Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave");
  407 + Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
  408 + for (Map<String, Object> bfm : resolver.businessFields(out.table, MAX_FIELDS)) {
  409 + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
  410 + }
  411 +
  412 + Map<String, Object> masterCol = new LinkedHashMap<>();
  413 + Map<String, Object> slaveCol = new LinkedHashMap<>();
  414 + List<String> notes = new ArrayList<>();
  415 + List<String> manyQtys = new ArrayList<>();
  416 + List<String> descParts = new ArrayList<>();
  417 + String custId = null;
  418 + for (Map.Entry<String, String> e : fields.entrySet()) {
  419 + String zh = e.getKey();
  420 + String v = e.getValue();
  421 + if (isBlank(v)) {
  422 + continue;
  423 + }
  424 + Map<String, Object> fm = labelMap.get(normLabel(zh));
  425 + if (fm == null) {
  426 + continue;
  427 + }
  428 + String colName = str(fm.get("col"));
  429 + String tgt = fm.get("table") == null ? "master" : str(fm.get("table"));
  430 + String fk = fm.get("fk") == null ? null : str(fm.get("fk"));
  431 + if ("note".equals(tgt)) {
  432 + notes.add(zh + "=" + v);
  433 + descParts.add(zh + "=" + v);
  434 + continue;
  435 + }
  436 + if ("manyqtys".equals(tgt)) {
  437 + for (String q : v.split("[,,、\\s]+")) {
  438 + String n = q.trim();
  439 + if (n.isEmpty()) {
  440 + continue;
  441 + }
  442 + if (!n.matches("\\d+(\\.\\d+)?")) {
  443 + out.error = "多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。";
  444 + return out;
  445 + }
  446 + manyQtys.add(n);
  447 + }
  448 + descParts.add(zh + "=" + String.join(",", manyQtys));
  449 + continue;
  450 + }
  451 + String targetTable = "slave".equals(tgt) ? "quoquotationslave" : out.table;
  452 + Map<String, String> tt = "slave".equals(tgt) ? slaveTypes : masterTypes;
  453 + Normalized n;
  454 + if (fk != null && !fk.isBlank()) {
  455 + n = normalize(out.table, colName, fk, zh, v, identity);
  456 + if (n.error != null) {
  457 + out.error = n.error + "(请从下拉里选真实存在的记录;**不要**代建,也不要把产品/规格当成客户)";
  458 + return out;
  459 + }
  460 + if ("sCustomerId".equals(colName)) {
  461 + custId = String.valueOf(n.stored);
  462 + }
  463 + } else {
  464 + n = new Normalized();
  465 + try {
  466 + n.stored = resolver.coerce(tt.get(colName), v);
  467 + } catch (IllegalArgumentException ex) {
  468 + out.error = "「" + zh + "」" + ex.getMessage() + ",请给一个有效值。";
  469 + return out;
  470 + }
  471 + if (n.stored == null) {
  472 + out.error = "「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-29 这种格式)。";
  473 + return out;
  474 + }
  475 + n.shown = String.valueOf(n.stored);
  476 + }
  477 + if ("slave".equals(tgt)) {
  478 + slaveCol.put(colName, n.stored);
  479 + } else {
  480 + masterCol.put(colName, n.stored);
  481 + }
  482 + String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(n.stored);
  483 + descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")"));
  484 + }
  485 + if (descParts.isEmpty()) {
  486 + out.error = "请至少填写一个字段(如客户名称/产品名称/数量)。";
  487 + return out;
  488 + }
  489 +
  490 + for (String rc : requiredCols(out.table)) {
  491 + if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) {
  492 + continue;
  493 + }
  494 + masterCol.put(rc, resolver.typeDefault(rc, masterTypes.get(rc)));
  495 + }
  496 + String masterId = erp.newUuid(identity.token());
  497 + masterCol.put("sId", masterId);
  498 + masterCol.put("sFormId", out.formId);
  499 + String billNo = resolver.nextBillNo(out.table, identity.brandsId());
  500 + if (billNo != null) {
  501 + masterCol.put("sBillNo", billNo);
  502 + }
  503 +
  504 + List<Map<String, Object>> tables = new ArrayList<>();
  505 + tables.add(tableItem("quoquotationmaster", "master", masterCol));
  506 + if (!slaveCol.isEmpty() || !notes.isEmpty()) {
  507 + if (!notes.isEmpty()) {
  508 + String memo = String.join(";", notes);
  509 + Object exist = slaveCol.get("sMaterialsMemo");
  510 + slaveCol.put("sMaterialsMemo", exist == null ? memo : (exist + ";" + memo));
  511 + }
  512 + slaveCol.put("sId", erp.newUuid(identity.token()));
  513 + slaveCol.put("sParentId", masterId);
  514 + slaveCol.put("sCustomerId", custId == null ? "" : custId);
  515 + tables.add(tableItem("quoquotationslave", "slave", slaveCol));
  516 + }
  517 + for (String q : manyQtys) {
  518 + Map<String, Object> mq = new LinkedHashMap<>();
  519 + mq.put("sId", erp.newUuid(identity.token()));
  520 + mq.put("sParentId", masterId);
  521 + mq.put("dManyQty", q);
  522 + tables.add(tableItem("quoquotationmanyqtys", "slave", mq));
  523 + }
  524 +
  525 + Map<String, Object> wrap = new LinkedHashMap<>();
  526 + wrap.put("__tables__", tables);
  527 + try {
  528 + out.payload = mapper.writeValueAsString(wrap);
  529 + } catch (Exception e) {
  530 + out.error = "内部错误:" + e.getMessage();
  531 + return out;
  532 + }
  533 + out.description = "新增【报价】:" + String.join(",", descParts)
  534 + + "(含印刷/多数量明细;价格请在 ERP 点【核价】计算)";
  535 + return out;
  536 + }
  537 +
  538 + private Map<String, Object> tableItem(String sTable, String name, Map<String, Object> column) {
  539 + Map<String, Object> m = new LinkedHashMap<>();
  540 + m.put("sTable", sTable);
  541 + m.put("name", name);
  542 + m.put("column", column);
  543 + return m;
  544 + }
  545 +
  546 + /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */
  547 + private List<String> requiredCols(String table) {
  548 + List<String> out = new ArrayList<>();
  549 + try {
  550 + List<Map<String, Object>> rows = jdbc.queryForList(
  551 + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " +
  552 + "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table);
  553 + Set<String> skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson");
  554 + for (Map<String, Object> r : rows) {
  555 + String c = str(r.get("COLUMN_NAME"));
  556 + if (c != null && !skip.contains(c)) {
  557 + out.add(c);
  558 + }
  559 + }
  560 + } catch (Exception ignore) {
  561 + }
  562 + return out;
  563 + }
  564 +
  565 + // ---------------------------------------------------------------- 通用
  566 +
  567 + /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */
  568 + public static String normLabel(String s) {
  569 + if (s == null) {
  570 + return "";
  571 + }
  572 + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();
  573 + }
  574 +
  575 + private static String inferType(String dataType) {
  576 + if (dataType == null) {
  577 + return "text";
  578 + }
  579 + String t = dataType.toLowerCase();
  580 + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
  581 + return "number";
  582 + }
  583 + if (t.contains("date") || t.contains("time")) {
  584 + return "date";
  585 + }
  586 + return "text";
  587 + }
  588 +
  589 + private static List<String> simpleOptions(String dropdown) {
  590 + List<String> out = new ArrayList<>();
  591 + if (dropdown == null || dropdown.isBlank()) {
  592 + return out;
  593 + }
  594 + String d = dropdown.trim();
  595 + if (d.toLowerCase().contains("select ") || d.length() > 200) {
  596 + return out;
  597 + }
  598 + for (String o : d.split("[、,,|]")) {
  599 + String t = o.trim();
  600 + if (!t.isEmpty() && out.size() < 30) {
  601 + out.add(t);
  602 + }
  603 + }
  604 + return out;
  605 + }
  606 +
  607 + private static boolean truthy(Object o) {
  608 + if (o == null) {
  609 + return false;
  610 + }
  611 + if (o instanceof Boolean b) {
  612 + return b;
  613 + }
  614 + if (o instanceof Number nu) {
  615 + return nu.intValue() != 0;
  616 + }
  617 + if (o instanceof byte[] b) {
  618 + return b.length > 0 && b[0] != 0;
  619 + }
  620 + String s = o.toString().trim();
  621 + return s.equals("1") || s.equalsIgnoreCase("true");
  622 + }
  623 +
  624 + private static String firstNonBlank(String a, String b) {
  625 + return (a != null && !a.isBlank()) ? a : b;
  626 + }
  627 +
  628 + private static String str(Object o) {
  629 + return o == null ? null : o.toString();
  630 + }
  631 +
  632 + private static boolean isBlank(String s) {
  633 + return s == null || s.isBlank();
  634 + }
  635 +}
... ...
src/main/java/com/xly/service/OpService.java
1 1 package com.xly.service;
2 2  
  3 +import com.xly.agent.AgentIdentity;
3 4 import org.springframework.jdbc.core.JdbcTemplate;
4 5 import org.springframework.stereotype.Service;
5 6  
... ... @@ -7,80 +8,92 @@ import java.util.List;
7 8 import java.util.Map;
8 9  
9 10 /**
10   - * ai_op_queue 暂存管理 —— 写操作的人在环闭环:ProposeWrite 写 draft,用户确认后 confirm 端点执行。
  11 + * ai_op_queue 队列写入 —— **AI 侧唯一的写入口**(rearch3 §3 写路径收权)。
  12 + *
  13 + * <p>用户在预览卡/表单上点按钮(保存/审核/作废…)→ 确定性端点校验后经本类落一行
  14 + * {@code sStatus='confirmed'}(用户已当面授权)。**xlyAi 工作到此为止**:是否/何时执行、
  15 + * 执行权属、审计留痕全部由 ERP 侧负责(暂存执行器读本表;现状=报价自动执行、其余进待办)。
  16 + * xlyAi 侧只保留 {@link #statusLabel} 只读展示处理进度(流程卡)。
  17 + *
  18 + * <p>列名兼容:ERP 执行器仍读 {@code sUserId} → 新列 {@code sMakePerson}(ERP 惯例)双写,
  19 + * ERP 侧切换后可删 sUserId。
11 20 */
12 21 @Service
13 22 public class OpService {
14 23  
15 24 private final JdbcTemplate jdbc;
16   - private final AuditService audit;
17 25  
18   - public OpService(JdbcTemplate jdbc, AuditService audit) {
  26 + public OpService(JdbcTemplate jdbc) {
19 27 this.jdbc = jdbc;
20   - this.audit = audit;
21 28 }
22 29  
23   - /** 暂存一条 draft 写操作,返回 opId(不执行)。 */
24   - public String createDraft(String userId, String opType,
25   - String formId, String moduleId, String table, String billId,
26   - String field, String fieldLabel, String oldValue, String newValue,
  30 + /** 单字段更新入队(update)。 */
  31 + public String queueUpdate(AgentIdentity who, String convId, String formId, String moduleId, String table,
  32 + String billId, String field, String fieldLabel, String oldValue, String newValue,
27 33 String description) {
28   - String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
29   - jdbc.update(
30   - "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," +
31   - "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " +
32   - "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())",
33   - sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description);
34   - audit.log(userId, null, "propose", table + "#" + billId, description, true, "draft staged (" + sId + ")");
35   - return sId;
  34 + return insert(who, convId, "update", formId, moduleId, table, billId,
  35 + field, fieldLabel, oldValue, newValue, null, description);
  36 + }
  37 +
  38 + /** 状态操作入队:invalid(sNewValue=toVoid|cancel)/ examine(sNewValue=1|0)/ delete。 */
  39 + public String queueStateOp(AgentIdentity who, String convId, String opType, String formId, String moduleId,
  40 + String table, String billId, String sNewValue, String description) {
  41 + return insert(who, convId, opType, formId, moduleId, table, billId,
  42 + null, null, null, sNewValue, null, description);
  43 + }
  44 +
  45 + /** 新增入队(create):payload = 列->值 JSON(多表用 __tables__)。 */
  46 + public String queueCreate(AgentIdentity who, String convId, String formId, String moduleId, String table,
  47 + String payload, String description) {
  48 + return insert(who, convId, "create", formId, moduleId, table, null,
  49 + null, null, null, null, payload, description);
36 50 }
37 51  
38   - /** 暂存一条 draft(payload 版,用于 create:sPayload=列->值 JSON)。 */
39   - public String createDraftPayload(String userId, String opType, String formId, String moduleId,
40   - String table, String payload, String description) {
  52 + private String insert(AgentIdentity who, String convId, String opType, String formId, String moduleId,
  53 + String table, String billId, String field, String fieldLabel, String oldValue,
  54 + String newValue, String payload, String description) {
41 55 String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
42 56 jdbc.update(
43   - "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," +
44   - "sPayload,sDescription,sStatus,tCreateDate) VALUES(?,?,?,?,?,?,?,?, 'draft', NOW())",
45   - sId, userId, opType, formId, moduleId, table, payload, description);
46   - audit.log(userId, null, "propose", table + " (create)", description, true, "draft staged (" + sId + ")");
  57 + "INSERT INTO ai_op_queue(sId,sUserId,sMakePerson,sBrandsId,sSubsidiaryId,sConversationId,sOpType," +
  58 + "sTargetFormId,sTargetModuleId,sTargetTable,sTargetBillId,sField,sFieldLabel," +
  59 + "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " +
  60 + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())",
  61 + sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType,
  62 + formId, moduleId, table, billId, field, fieldLabel,
  63 + oldValue, newValue, payload, description);
47 64 return sId;
48 65 }
49 66  
50   - /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */
51   - public void attachConversation(String opId, String convId) {
52   - jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId);
53   - }
54   -
55 67 public Map<String, Object> get(String sId) {
56 68 List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId);
57 69 return r.isEmpty() ? null : r.get(0);
58 70 }
59 71  
60   - /** 会话里最近一条待确认(draft)操作。 */
61   - public Map<String, Object> pending(String convId) {
62   - List<Map<String, Object>> r = jdbc.queryForList(
63   - "SELECT * FROM ai_op_queue WHERE sConversationId=? AND sStatus='draft' ORDER BY tCreateDate DESC LIMIT 1",
64   - convId);
65   - return r.isEmpty() ? null : r.get(0);
66   - }
67   -
68   - public void setStatus(String sId, String status, String resultMsg) {
69   - jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?",
70   - status, resultMsg, sId);
71   - }
72   -
73   - /**
74   - * 抢占一条 draft(CAS):只有把 draft 改成 executing 的那个请求返回 true,并发/重复确认直接落空。
75   - * 没有它,两个并发 confirm 都能通过「读到 draft」的判断,把同一张单执行两次。
76   - */
77   - public boolean claim(String sId) {
78   - return jdbc.update("UPDATE ai_op_queue SET sStatus='executing', tConfirmDate=NOW() " +
79   - "WHERE sId=? AND sStatus='draft'", sId) == 1;
  72 + /** sStatus → 人类可读进度(流程卡只读展示;不做任何处理动作)。查不到返回 null。 */
  73 + public String statusLabel(String sId) {
  74 + try {
  75 + List<Map<String, Object>> r = jdbc.queryForList(
  76 + "SELECT sStatus, sResultMsg, sErrorMsg FROM ai_op_queue WHERE sId=?", sId);
  77 + if (r.isEmpty()) {
  78 + return null;
  79 + }
  80 + String st = String.valueOf(r.get(0).get("sStatus"));
  81 + String msg = str(r.get(0).get("sResultMsg"));
  82 + String err = str(r.get(0).get("sErrorMsg"));
  83 + return switch (st) {
  84 + case "confirmed" -> "已提交,等待 ERP 处理";
  85 + case "executing" -> "ERP 处理中";
  86 + case "executed" -> "ERP 已执行成功" + (msg.isBlank() ? "" : ":" + msg);
  87 + case "failed" -> "ERP 执行失败" + (err.isBlank() ? (msg.isBlank() ? "" : ":" + msg) : ":" + err);
  88 + case "cancelled" -> "已取消";
  89 + default -> st;
  90 + };
  91 + } catch (Exception e) {
  92 + return null;
  93 + }
80 94 }
81 95  
82   - /** 执行失败/异常时把 executing 退回 draft 之外的终态由 setStatus 负责;这里仅用于放弃抢占。 */
83   - public void release(String sId) {
84   - jdbc.update("UPDATE ai_op_queue SET sStatus='draft' WHERE sId=? AND sStatus='executing'", sId);
  96 + private static String str(Object o) {
  97 + return o == null ? "" : o.toString();
85 98 }
86 99 }
... ...
src/main/java/com/xly/service/PreviewService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.JsonNode;
  4 +import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.agent.AgentIdentity;
  6 +import org.slf4j.Logger;
  7 +import org.slf4j.LoggerFactory;
  8 +import org.springframework.beans.factory.annotation.Value;
  9 +import org.springframework.data.redis.core.StringRedisTemplate;
  10 +import org.springframework.stereotype.Service;
  11 +
  12 +import java.time.Duration;
  13 +import java.util.ArrayList;
  14 +import java.util.LinkedHashMap;
  15 +import java.util.List;
  16 +import java.util.Map;
  17 +import java.util.UUID;
  18 +
  19 +/**
  20 + * 写操作预览/保存闭环(rearch3 §1「所见即所写」):
  21 + *
  22 + * <ol>
  23 + * <li>{@link #buildPreview}(previewChange 工具的后端,**只读**):定位记录 → 校验改动
  24 + * (字段字典/FK/类型/系统列/状态合法性)→ 渲染整表预览卡(改动高亮/状态变化高亮)→
  25 + * 解析产物(表/记录/列/规范化值/当前值快照)以 previewId 存 Redis({@value #TTL_MINUTES} 分钟 TTL,
  26 + * 服务端绑定,客户端只见 previewId);</li>
  27 + * <li>{@link #save}(用户点卡上按钮后的确定性端点):按 previewId 取回 → 归属/权限重查 →
  28 + * **重读记录重校验**(记录被他人改过/状态已变 → 拒绝保存,请重新预览)→ 写 ai_op_queue
  29 + * ({@link OpService},AI 侧唯一写入口)→ 落 queued 事件。**xlyAi 到此为止**,执行由 ERP 侧负责。</li>
  30 + * </ol>
  31 + *
  32 + * 状态合法性硬检查(预览与保存两次,防 TOCTOU):审核要求未审核、销审要求已审核、
  33 + * 作废要求未作废、复原要求已作废(依据已核实的存储过程行为:重复审核/重复作废 ERP 不拦但有副作用)。
  34 + * 表没有对应状态列时跳过该项检查。
  35 + */
  36 +@Service
  37 +public class PreviewService {
  38 +
  39 + private static final Logger log = LoggerFactory.getLogger(PreviewService.class);
  40 + private static final String KEY_PREFIX = "chat:preview:";
  41 + private static final int TTL_MINUTES = 30;
  42 + private static final int STATE_SUMMARY_FIELDS = 6;
  43 +
  44 + private final FormRenderService render;
  45 + private final FormResolverService resolver;
  46 + private final OpService ops;
  47 + private final LedgerService ledger;
  48 + private final ErpClient erp;
  49 + private final StringRedisTemplate redis;
  50 + private final ObjectMapper mapper;
  51 +
  52 + /** Phase D 接口位:true 时保存前调 ERP dry-run 校验(/business/checkBusinessData,ERP 侧完工后开启)。 */
  53 + @Value("${erp.dry-run.enabled:false}")
  54 + private boolean dryRunEnabled;
  55 +
  56 + public PreviewService(FormRenderService render, FormResolverService resolver, OpService ops,
  57 + LedgerService ledger, ErpClient erp, StringRedisTemplate redis, ObjectMapper mapper) {
  58 + this.render = render;
  59 + this.resolver = resolver;
  60 + this.ops = ops;
  61 + this.ledger = ledger;
  62 + this.erp = erp;
  63 + this.redis = redis;
  64 + this.mapper = mapper;
  65 + }
  66 +
  67 + // ---------------------------------------------------------------- 预览
  68 +
  69 + /** previewChange 的入口。返回卡片 JSON(type=change_preview)或 {"error":...}。 */
  70 + public String buildPreview(AgentIdentity who, String convId, String action, String entityKeyword,
  71 + String recordKeyword, String fieldChinese, String newValue) {
  72 + if (isBlank(action)) {
  73 + return err("缺少 action。");
  74 + }
  75 + String act = normalizeAction(action);
  76 + if (act == null) {
  77 + return err("未知 action:" + action + "(支持 update/invalid/cancelInvalid/examine/cancelExamine/delete;新增走 collectForm)");
  78 + }
  79 + if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
  80 + return err("缺少实体类型或记录名称/单号。");
  81 + }
  82 + String verb = verbOf(act);
  83 + FormRenderService.Located l = render.locateRecord(who, entityKeyword, recordKeyword, verb);
  84 + if (l.error != null) {
  85 + return err(l.error);
  86 + }
  87 + return "update".equals(act)
  88 + ? buildUpdatePreview(who, convId, entityKeyword, fieldChinese, newValue, l)
  89 + : buildStatePreview(who, convId, act, entityKeyword, l);
  90 + }
  91 +
  92 + private String buildUpdatePreview(AgentIdentity who, String convId, String entity,
  93 + String fieldChinese, String newValue, FormRenderService.Located l) {
  94 + if (isBlank(fieldChinese) || newValue == null) {
  95 + return err("update 需要 字段中文名 + 新值。");
  96 + }
  97 + Map<String, Object> fm = render.resolveColumn(l.table, fieldChinese.trim());
  98 + if (fm == null) {
  99 + return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
  100 + }
  101 + String col = String.valueOf(fm.get("col"));
  102 + if (resolver.isSystemColumn(col)) {
  103 + return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。");
  104 + }
  105 + String fk = fm.get("fk") == null ? null : String.valueOf(fm.get("fk"));
  106 + FormRenderService.Normalized n = render.normalize(l.table, col, fk, fieldChinese, newValue, who);
  107 + if (n.error != null) {
  108 + return err(n.error);
  109 + }
  110 +
  111 + String warning = null;
  112 + if (readState(l.rec, "bInvalid") == 1) {
  113 + warning = "注意:该记录当前是**已作废**状态。";
  114 + }
  115 +
  116 + // 整表渲染:master 字段的当前真实值 + 被改字段高亮
  117 + List<Map<String, Object>> skeleton = masterFields(l.table, l.formId);
  118 + ensureFieldPresent(skeleton, col, fieldChinese, fk, l.table);
  119 + List<Map<String, Object>> cardFields = new ArrayList<>();
  120 + Map<String, String> snapshotRaw = new LinkedHashMap<>();
  121 + Map<String, String> snapshotShown = new LinkedHashMap<>();
  122 + List<Map<String, Object>> fieldsMeta = new ArrayList<>();
  123 + for (Map<String, Object> f : skeleton) {
  124 + String name = String.valueOf(f.get("name"));
  125 + String label = String.valueOf(f.get("label"));
  126 + String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable"));
  127 + String raw = l.rec.path(name).asText("");
  128 + String shown = ffk != null ? render.fkDisplayName(ffk, raw) : raw;
  129 + snapshotRaw.put(name, raw);
  130 + snapshotShown.put(name, shown);
  131 + Map<String, Object> meta = new LinkedHashMap<>();
  132 + meta.put("name", name);
  133 + meta.put("label", label);
  134 + if (ffk != null) {
  135 + meta.put("fk", ffk);
  136 + }
  137 + fieldsMeta.add(meta);
  138 +
  139 + Map<String, Object> cf = new LinkedHashMap<>(f);
  140 + cf.put("value", shown);
  141 + if (name.equals(col)) {
  142 + cf.put("changed", true);
  143 + cf.put("newValue", n.shown);
  144 + }
  145 + cardFields.add(cf);
  146 + }
  147 +
  148 + String oldShown = snapshotShown.getOrDefault(col, "");
  149 + String description = "将【" + l.recordName + "】的【" + fieldChinese + "】"
  150 + + (oldShown.isBlank() ? "" : ("由「" + oldShown + "」")) + "改为「" + n.shown + "」"
  151 + + (n.shown.equals(newValue) ? "" : "(原话:" + newValue + ")");
  152 +
  153 + Map<String, Object> draft = new LinkedHashMap<>();
  154 + draft.put("action", "update");
  155 + draft.put("userId", who.userId());
  156 + draft.put("convId", convId);
  157 + draft.put("entity", entity);
  158 + draft.put("formId", l.formId);
  159 + draft.put("moduleId", l.moduleId);
  160 + draft.put("table", l.table);
  161 + draft.put("billId", l.billId);
  162 + draft.put("recordName", l.recordName);
  163 + draft.put("changedCol", col);
  164 + draft.put("changedLabel", fieldChinese);
  165 + draft.put("changedNewShown", n.shown);
  166 + draft.put("snapshotRaw", snapshotRaw);
  167 + draft.put("snapshotShown", snapshotShown);
  168 + draft.put("fieldsMeta", fieldsMeta);
  169 + draft.put("description", description);
  170 + String previewId = stash(draft);
  171 + if (previewId == null) {
  172 + return err("预览暂存失败,请稍后重试。");
  173 + }
  174 +
  175 + Map<String, Object> card = new LinkedHashMap<>();
  176 + card.put("type", "change_preview");
  177 + card.put("previewId", previewId);
  178 + card.put("action", "update");
  179 + card.put("entity", entity);
  180 + card.put("recordName", l.recordName);
  181 + card.put("title", "修改「" + l.recordName + "」(" + entity + ")");
  182 + card.put("summary", description);
  183 + card.put("buttonLabel", "保存");
  184 + card.put("editable", true);
  185 + card.put("fields", cardFields);
  186 + card.put("message", (warning == null ? "" : warning + "\n")
  187 + + "请核对整张表单(改动已高亮,可继续修正其他字段),确认后点【保存】。保存前不会写入任何数据。");
  188 + return toJson(card);
  189 + }
  190 +
  191 + private String buildStatePreview(AgentIdentity who, String convId, String act, String entity,
  192 + FormRenderService.Located l) {
  193 + Map<String, String> types = resolver.columnTypes(l.table);
  194 + int bCheck = readState(l.rec, "bCheck");
  195 + int bInvalid = readState(l.rec, "bInvalid");
  196 +
  197 + // 状态合法性硬检查(依据已核实的存储过程行为;无对应列的表跳过)
  198 + String illegal = stateIllegalReason(act, types, bCheck, bInvalid, l.recordName);
  199 + if (illegal != null) {
  200 + return err(illegal);
  201 + }
  202 + String warning = null;
  203 + if ("delete".equals(act) && types.containsKey("bCheck") && bCheck == 1) {
  204 + warning = "注意:该单据**已审核**,物理删除有风险且不可恢复,建议改用作废。";
  205 + }
  206 +
  207 + // 单据摘要(前几个有值的业务字段)+ 状态变化高亮,无字段编辑
  208 + List<Map<String, Object>> summaryFields = new ArrayList<>();
  209 + for (Map<String, Object> f : masterFields(l.table, l.formId)) {
  210 + if (summaryFields.size() >= STATE_SUMMARY_FIELDS) {
  211 + break;
  212 + }
  213 + String name = String.valueOf(f.get("name"));
  214 + String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable"));
  215 + String raw = l.rec.path(name).asText("");
  216 + if (raw.isBlank()) {
  217 + continue;
  218 + }
  219 + Map<String, Object> cf = new LinkedHashMap<>();
  220 + cf.put("name", name);
  221 + cf.put("label", f.get("label"));
  222 + cf.put("value", ffk != null ? render.fkDisplayName(ffk, raw) : raw);
  223 + summaryFields.add(cf);
  224 + }
  225 + String billNo = l.rec.path("sBillNo").asText("");
  226 + if (!billNo.isBlank()) {
  227 + Map<String, Object> bn = new LinkedHashMap<>();
  228 + bn.put("name", "sBillNo");
  229 + bn.put("label", "单号");
  230 + bn.put("value", billNo);
  231 + summaryFields.add(0, bn);
  232 + }
  233 +
  234 + Map<String, Object> statusChange = statusChangeOf(act);
  235 + String verb = verbOf(act);
  236 + String description = verb + "【" + l.recordName + "】(" + entity + ")";
  237 +
  238 + Map<String, Object> expected = new LinkedHashMap<>();
  239 + if (types.containsKey("bCheck")) {
  240 + expected.put("bCheck", bCheck);
  241 + }
  242 + if (types.containsKey("bInvalid")) {
  243 + expected.put("bInvalid", bInvalid);
  244 + }
  245 +
  246 + Map<String, Object> draft = new LinkedHashMap<>();
  247 + draft.put("action", act);
  248 + draft.put("userId", who.userId());
  249 + draft.put("convId", convId);
  250 + draft.put("entity", entity);
  251 + draft.put("formId", l.formId);
  252 + draft.put("moduleId", l.moduleId);
  253 + draft.put("table", l.table);
  254 + draft.put("billId", l.billId);
  255 + draft.put("recordName", l.recordName);
  256 + draft.put("expected", expected);
  257 + draft.put("description", description);
  258 + String previewId = stash(draft);
  259 + if (previewId == null) {
  260 + return err("预览暂存失败,请稍后重试。");
  261 + }
  262 +
  263 + Map<String, Object> card = new LinkedHashMap<>();
  264 + card.put("type", "change_preview");
  265 + card.put("previewId", previewId);
  266 + card.put("action", act);
  267 + card.put("entity", entity);
  268 + card.put("recordName", l.recordName);
  269 + card.put("title", verb + "「" + l.recordName + "」(" + entity + ")");
  270 + card.put("summary", description);
  271 + card.put("buttonLabel", verb);
  272 + card.put("editable", false);
  273 + card.put("fields", summaryFields);
  274 + card.put("statusChange", statusChange);
  275 + card.put("message", (warning == null ? "" : warning + "\n")
  276 + + "请核对单据信息与状态变化,确认后点【" + verb + "】提交待办。点按钮前不会写入任何数据。");
  277 + return toJson(card);
  278 + }
  279 +
  280 + // ---------------------------------------------------------------- 保存
  281 +
  282 + /**
  283 + * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → 写 ai_op_queue。
  284 + * editedFields = 卡上(可编辑预览)用户最终确认的 label→值;状态类传 null。
  285 + * 返回 {queued,opIds,description,message} 或 {error}。
  286 + */
  287 + public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields) {
  288 + Map<String, Object> out = new LinkedHashMap<>();
  289 + JsonNode draft = unstash(previewId);
  290 + if (draft == null) {
  291 + out.put("error", "预览已过期或不存在,请让助手重新生成预览。");
  292 + return out;
  293 + }
  294 + if (!draft.path("userId").asText("").equals(who.userId())) {
  295 + out.put("error", "无权处理该预览。");
  296 + return out;
  297 + }
  298 + String moduleId = draft.path("moduleId").asText("");
  299 + if (!who.canAccessModule(moduleId)) {
  300 + out.put("error", "你没有该单据的操作权限。");
  301 + return out;
  302 + }
  303 + String action = draft.path("action").asText("");
  304 + String convId = draft.path("convId").asText("");
  305 + FormRenderService.Located cur = render.locateById(who, draft.path("formId").asText(""),
  306 + moduleId, draft.path("table").asText(""), draft.path("billId").asText(""));
  307 + if (cur.error != null) {
  308 + out.put("error", cur.error + " 请重新预览。");
  309 + return out;
  310 + }
  311 + Map<String, Object> result = "update".equals(action)
  312 + ? saveUpdate(who, draft, editedFields, cur, convId)
  313 + : saveStateOp(who, draft, cur, convId, action);
  314 + if (!result.containsKey("error")) {
  315 + discard(previewId); // 一次性:保存成功后预览失效
  316 + }
  317 + return result;
  318 + }
  319 +
  320 + @SuppressWarnings("unchecked")
  321 + private Map<String, Object> saveUpdate(AgentIdentity who, JsonNode draft, Map<String, String> editedFields,
  322 + FormRenderService.Located cur, String convId) {
  323 + Map<String, Object> out = new LinkedHashMap<>();
  324 + String table = draft.path("table").asText("");
  325 + String recordName = draft.path("recordName").asText("");
  326 + JsonNode snapshotRaw = draft.path("snapshotRaw");
  327 + JsonNode snapshotShown = draft.path("snapshotShown");
  328 + String changedCol = draft.path("changedCol").asText("");
  329 + String changedLabel = draft.path("changedLabel").asText("");
  330 + String changedNewShown = draft.path("changedNewShown").asText("");
  331 +
  332 + // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段
  333 + Map<String, String> desired = new LinkedHashMap<>();
  334 + desired.put(changedCol, changedNewShown);
  335 + if (editedFields != null && !editedFields.isEmpty()) {
  336 + for (JsonNode meta : draft.path("fieldsMeta")) {
  337 + String name = meta.path("name").asText("");
  338 + String label = meta.path("label").asText("");
  339 + String v = editedFields.get(label);
  340 + if (v != null) {
  341 + desired.put(name, v);
  342 + }
  343 + }
  344 + }
  345 +
  346 + // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半)
  347 + record Change(String col, String label, String wasShown, Object stored, String shown) { }
  348 + List<Change> changes = new ArrayList<>();
  349 + for (JsonNode meta : draft.path("fieldsMeta")) {
  350 + String col = meta.path("name").asText("");
  351 + String label = meta.path("label").asText("");
  352 + String fk = meta.path("fk").isMissingNode() ? null : meta.path("fk").asText(null);
  353 + String want = desired.get(col);
  354 + if (want == null) {
  355 + continue;
  356 + }
  357 + String wasShown = snapshotShown.path(col).asText("");
  358 + if (want.equals(wasShown)) {
  359 + continue; // 没改
  360 + }
  361 + if (resolver.isSystemColumn(col)) {
  362 + out.put("error", "「" + label + "」是系统字段,不能修改。");
  363 + return out;
  364 + }
  365 + // 所见即所写:预览时的当前值必须仍是现在的当前值(他人已改 → 拒绝)
  366 + String rawAtPreview = snapshotRaw.path(col).asText("");
  367 + String rawNow = cur.rec.path(col).asText("");
  368 + if (!rawNow.equals(rawAtPreview)) {
  369 + out.put("error", "「" + label + "」在预览之后被其他人修改过(现在是「"
  370 + + (fk != null ? render.fkDisplayName(fk, rawNow) : rawNow) + "」),已拒绝保存,请重新预览。");
  371 + return out;
  372 + }
  373 + FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, who);
  374 + if (n.error != null) {
  375 + out.put("error", n.error);
  376 + return out;
  377 + }
  378 + changes.add(new Change(col, label, wasShown, n.stored, n.shown));
  379 + }
  380 + if (changes.isEmpty()) {
  381 + out.put("error", "没有需要保存的改动。");
  382 + return out;
  383 + }
  384 +
  385 + String dryErr = dryRunUpdate(who, draft, desired, cur);
  386 + if (dryErr != null) {
  387 + out.put("error", dryErr);
  388 + return out;
  389 + }
  390 +
  391 + // 第二遍:逐字段入队(ERP 执行器为单字段 update;一次保存多字段 = 多行待办)
  392 + List<String> opIds = new ArrayList<>();
  393 + List<String> descs = new ArrayList<>();
  394 + for (Change c : changes) {
  395 + String desc = "将【" + recordName + "】的【" + c.label() + "】"
  396 + + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」";
  397 + descs.add(desc);
  398 + opIds.add(ops.queueUpdate(who, convId, draft.path("formId").asText(""),
  399 + draft.path("moduleId").asText(""), table, draft.path("billId").asText(""),
  400 + c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc));
  401 + }
  402 + String description = String.join(";", descs);
  403 + recordQueued(convId, who, opIds, description);
  404 + out.put("queued", true);
  405 + out.put("opIds", opIds);
  406 + out.put("description", description);
  407 + out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。");
  408 + return out;
  409 + }
  410 +
  411 + private Map<String, Object> saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur,
  412 + String convId, String action) {
  413 + Map<String, Object> out = new LinkedHashMap<>();
  414 + String table = draft.path("table").asText("");
  415 + Map<String, String> types = resolver.columnTypes(table);
  416 + int bCheck = readState(cur.rec, "bCheck");
  417 + int bInvalid = readState(cur.rec, "bInvalid");
  418 + // 所见即所写:预览时看到的状态必须没变
  419 + JsonNode expected = draft.path("expected");
  420 + if (expected.has("bCheck") && expected.path("bCheck").asInt() != bCheck) {
  421 + out.put("error", "该单据的审核状态在预览之后发生了变化,已拒绝提交,请重新预览。");
  422 + return out;
  423 + }
  424 + if (expected.has("bInvalid") && expected.path("bInvalid").asInt() != bInvalid) {
  425 + out.put("error", "该单据的作废状态在预览之后发生了变化,已拒绝提交,请重新预览。");
  426 + return out;
  427 + }
  428 + String illegal = stateIllegalReason(action, types, bCheck, bInvalid, draft.path("recordName").asText(""));
  429 + if (illegal != null) {
  430 + out.put("error", illegal);
  431 + return out;
  432 + }
  433 + String opType;
  434 + String sNewValue;
  435 + switch (action) {
  436 + case "invalid" -> { opType = "invalid"; sNewValue = "toVoid"; }
  437 + case "cancelinvalid", "cancelInvalid" -> { opType = "invalid"; sNewValue = "cancel"; }
  438 + case "examine" -> { opType = "examine"; sNewValue = "1"; }
  439 + case "cancelexamine", "cancelExamine" -> { opType = "examine"; sNewValue = "0"; }
  440 + case "delete" -> { opType = "delete"; sNewValue = null; }
  441 + default -> {
  442 + out.put("error", "未知动作:" + action);
  443 + return out;
  444 + }
  445 + }
  446 + String description = draft.path("description").asText("");
  447 + String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""),
  448 + draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description);
  449 + recordQueued(convId, who, List.of(opId), description);
  450 + out.put("queued", true);
  451 + out.put("opIds", List.of(opId));
  452 + out.put("description", description);
  453 + out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。");
  454 + return out;
  455 + }
  456 +
  457 + // ---------------------------------------------------------------- create(表单保存按钮的后端)
  458 +
  459 + /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */
  460 + public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, Map<String, String> fields) {
  461 + Map<String, Object> out = new LinkedHashMap<>();
  462 + FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields);
  463 + if (b.error != null) {
  464 + out.put("error", b.error);
  465 + return out;
  466 + }
  467 + String dryErr = dryRunCreate(who, b);
  468 + if (dryErr != null) {
  469 + out.put("error", dryErr);
  470 + return out;
  471 + }
  472 + String opId = ops.queueCreate(who, convId, b.formId, b.moduleId, b.table, b.payload, b.description);
  473 + recordQueued(convId, who, List.of(opId), b.description);
  474 + out.put("queued", true);
  475 + out.put("opIds", List.of(opId));
  476 + out.put("opId", opId);
  477 + out.put("summary", b.description);
  478 + out.put("description", b.description);
  479 + out.put("message", "已提交待办:" + b.description + "。是否/何时执行由 ERP 处理。");
  480 + return out;
  481 + }
  482 +
  483 + // ---------------------------------------------------------------- Phase D:ERP dry-run 接口位
  484 +
  485 + /** ERP 校验 API 完工前恒关(erp.dry-run.enabled=false)。开启后:校验失败返回用户可读错误,网络异常放行(ERP 执行时仍会真校验)。 */
  486 + private String dryRunCreate(AgentIdentity who, FormRenderService.CreateBuild b) {
  487 + if (!dryRunEnabled) {
  488 + return null;
  489 + }
  490 + try {
  491 + JsonNode r = erp.checkBusinessData(who.token(), b.moduleId, b.table, b.payload, "add");
  492 + if (r != null && r.path("code").asInt(1) != 1) {
  493 + return "ERP 校验未通过:" + r.path("msg").asText("数据不合法");
  494 + }
  495 + } catch (Exception e) {
  496 + log.warn("dry-run create failed (放行,执行时 ERP 仍会校验): {}", e.getMessage());
  497 + }
  498 + return null;
  499 + }
  500 +
  501 + private String dryRunUpdate(AgentIdentity who, JsonNode draft, Map<String, String> desired,
  502 + FormRenderService.Located cur) {
  503 + if (!dryRunEnabled) {
  504 + return null;
  505 + }
  506 + try {
  507 + Map<String, Object> col = new LinkedHashMap<>();
  508 + col.put("sId", draft.path("billId").asText(""));
  509 + desired.forEach(col::put);
  510 + JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""),
  511 + draft.path("table").asText(""), mapper.writeValueAsString(col), "update");
  512 + if (r != null && r.path("code").asInt(1) != 1) {
  513 + return "ERP 校验未通过:" + r.path("msg").asText("数据不合法");
  514 + }
  515 + } catch (Exception e) {
  516 + log.warn("dry-run update failed (放行,执行时 ERP 仍会校验): {}", e.getMessage());
  517 + }
  518 + return null;
  519 + }
  520 +
  521 + // ---------------------------------------------------------------- 内部
  522 +
  523 + private void recordQueued(String convId, AgentIdentity who, List<String> opIds, String description) {
  524 + Map<String, Object> data = new LinkedHashMap<>();
  525 + data.put("opIds", opIds);
  526 + data.put("description", description);
  527 + ledger.append(convId, "queued", data, who);
  528 + }
  529 +
  530 + /** 状态-动作合法性:非法返回用户可读原因,合法返回 null。无对应状态列的表跳过检查。 */
  531 + private static String stateIllegalReason(String act, Map<String, String> types, int bCheck, int bInvalid,
  532 + String recordName) {
  533 + boolean hasCheck = types.containsKey("bCheck");
  534 + boolean hasInvalid = types.containsKey("bInvalid");
  535 + switch (act) {
  536 + case "examine":
  537 + if (hasCheck && bCheck == 1) {
  538 + return "【" + recordName + "】已经是**已审核**状态,无需重复审核(重复审核会覆盖审核人并重跑回写)。";
  539 + }
  540 + if (hasInvalid && bInvalid == 1) {
  541 + return "【" + recordName + "】是已作废单据,不能审核。";
  542 + }
  543 + break;
  544 + case "cancelexamine", "cancelExamine":
  545 + if (hasCheck && bCheck == 0) {
  546 + return "【" + recordName + "】还未审核,无法销审。";
  547 + }
  548 + break;
  549 + case "invalid":
  550 + if (hasInvalid && bInvalid == 1) {
  551 + return "【" + recordName + "】已经是**已作废**状态,无需重复作废(重复作废会重跑上下游回写)。";
  552 + }
  553 + break;
  554 + case "cancelinvalid", "cancelInvalid":
  555 + if (hasInvalid && bInvalid == 0) {
  556 + return "【" + recordName + "】不是作废状态,无需复原。";
  557 + }
  558 + break;
  559 + default:
  560 + }
  561 + return null;
  562 + }
  563 +
  564 + /** 记录的状态位(bCheck/bInvalid):1/true → 1,其余 → 0;列不存在也返回 0(调用方按 types 判断是否采信)。 */
  565 + private static int readState(JsonNode rec, String col) {
  566 + JsonNode v = rec == null ? null : rec.path(col);
  567 + if (v == null || v.isMissingNode() || v.isNull()) {
  568 + return 0;
  569 + }
  570 + String s = v.asText("");
  571 + return "1".equals(s) || "true".equalsIgnoreCase(s) ? 1 : 0;
  572 + }
  573 +
  574 + private static Map<String, Object> statusChangeOf(String act) {
  575 + Map<String, Object> m = new LinkedHashMap<>();
  576 + switch (act) {
  577 + case "examine" -> { m.put("label", "审核状态"); m.put("from", "未审核"); m.put("to", "已审核"); }
  578 + case "cancelexamine", "cancelExamine" -> { m.put("label", "审核状态"); m.put("from", "已审核"); m.put("to", "未审核"); }
  579 + case "invalid" -> { m.put("label", "单据状态"); m.put("from", "正常"); m.put("to", "已作废(可复原)"); }
  580 + case "cancelinvalid", "cancelInvalid" -> { m.put("label", "单据状态"); m.put("from", "已作废"); m.put("to", "正常"); }
  581 + case "delete" -> { m.put("label", "单据状态"); m.put("from", "存在"); m.put("to", "物理删除(不可恢复)"); }
  582 + default -> { }
  583 + }
  584 + return m;
  585 + }
  586 +
  587 + private static String verbOf(String act) {
  588 + return switch (act) {
  589 + case "update" -> "保存";
  590 + case "invalid" -> "作废";
  591 + case "cancelinvalid", "cancelInvalid" -> "取消作废";
  592 + case "examine" -> "审核";
  593 + case "cancelexamine", "cancelExamine" -> "反审核";
  594 + case "delete" -> "删除";
  595 + default -> "操作";
  596 + };
  597 + }
  598 +
  599 + private static String normalizeAction(String action) {
  600 + return switch (action.trim().toLowerCase()) {
  601 + case "update" -> "update";
  602 + case "invalid" -> "invalid";
  603 + case "cancelinvalid" -> "cancelInvalid";
  604 + case "examine" -> "examine";
  605 + case "cancelexamine" -> "cancelExamine";
  606 + case "delete" -> "delete";
  607 + default -> null;
  608 + };
  609 + }
  610 +
  611 + /** update 预览渲染的字段集合:master 目标的骨架字段(报价策展含从表字段,改字段只支持主表列)。 */
  612 + private List<Map<String, Object>> masterFields(String table, String formId) {
  613 + List<Map<String, Object>> out = new ArrayList<>();
  614 + for (Map<String, Object> f : render.formSkeleton(table, formId)) {
  615 + Object target = f.get("target");
  616 + if (target == null || "master".equals(String.valueOf(target))) {
  617 + out.add(f);
  618 + }
  619 + }
  620 + return out;
  621 + }
  622 +
  623 + /** 被改字段不在骨架里(低使用度列)时补一行,保证卡上一定能看到改动。 */
  624 + private void ensureFieldPresent(List<Map<String, Object>> skeleton, String col, String label,
  625 + String fk, String table) {
  626 + for (Map<String, Object> f : skeleton) {
  627 + if (col.equals(String.valueOf(f.get("name")))) {
  628 + return;
  629 + }
  630 + }
  631 + Map<String, Object> f = new LinkedHashMap<>();
  632 + f.put("name", col);
  633 + f.put("label", label);
  634 + f.put("required", false);
  635 + if (fk != null && !fk.isBlank()) {
  636 + f.put("type", "fkselect");
  637 + f.put("fkTable", fk);
  638 + } else {
  639 + f.put("type", "text");
  640 + }
  641 + skeleton.add(0, f);
  642 + }
  643 +
  644 + private String stash(Map<String, Object> draft) {
  645 + try {
  646 + String id = UUID.randomUUID().toString().replace("-", "");
  647 + redis.opsForValue().set(KEY_PREFIX + id, mapper.writeValueAsString(draft),
  648 + Duration.ofMinutes(TTL_MINUTES));
  649 + return id;
  650 + } catch (Exception e) {
  651 + log.warn("preview stash failed: {}", e.getMessage());
  652 + return null;
  653 + }
  654 + }
  655 +
  656 + private JsonNode unstash(String previewId) {
  657 + if (previewId == null || previewId.isBlank() || !previewId.matches("[A-Za-z0-9]{16,64}")) {
  658 + return null;
  659 + }
  660 + try {
  661 + String json = redis.opsForValue().get(KEY_PREFIX + previewId);
  662 + return json == null ? null : mapper.readTree(json);
  663 + } catch (Exception e) {
  664 + return null;
  665 + }
  666 + }
  667 +
  668 + private void discard(String previewId) {
  669 + try {
  670 + redis.delete(KEY_PREFIX + previewId);
  671 + } catch (Exception ignore) {
  672 + }
  673 + }
  674 +
  675 + private String err(String msg) {
  676 + Map<String, Object> m = new LinkedHashMap<>();
  677 + m.put("error", msg);
  678 + return toJson(m);
  679 + }
  680 +
  681 + private String toJson(Map<String, Object> m) {
  682 + try {
  683 + return mapper.writeValueAsString(m);
  684 + } catch (Exception e) {
  685 + return "{\"error\":\"内部错误\"}";
  686 + }
  687 + }
  688 +
  689 + private static boolean isBlank(String s) {
  690 + return s == null || s.isBlank();
  691 + }
  692 +}
... ...
src/main/java/com/xly/tool/FormCollectTool.java
... ... @@ -2,10 +2,10 @@ package com.xly.tool;
2 2  
3 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 4 import com.xly.agent.AgentIdentity;
  5 +import com.xly.service.FormRenderService;
5 6 import com.xly.service.FormResolverService;
6 7 import dev.langchain4j.agent.tool.P;
7 8 import dev.langchain4j.agent.tool.Tool;
8   -import org.springframework.jdbc.core.JdbcTemplate;
9 9  
10 10 import java.util.ArrayList;
11 11 import java.util.HashMap;
... ... @@ -17,24 +17,20 @@ import java.util.regex.Matcher;
17 17 import java.util.regex.Pattern;
18 18  
19 19 /**
20   - * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
  20 + * FormCollect 工具(LLM 层的「收集信息」工具)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
21 21 * 而不是逐字段追问。
22 22 *
23   - * <p>字段来源优先 {@link FormResolverService#businessFields}(精选映射 + 字段字典,即"真正要填的业务参数"),
24   - * 每个字段带控件类型 {@code fkselect|select|number|date|text};外键字段的下拉选项由前端另调
25   - * {@code GET /api/agent/form/options} 从来源表实时取。只有该表在字段字典里没有映射时,才退回
26   - * ERP 界面元数据 {@code gdsconfigformslave} 的可见可写字段。
  23 + * <p>表单骨架来自 {@link FormRenderService#formSkeleton}(与 previewChange 预览共用同一实现层);
  24 + * 本类负责预填:用户已说的值(含尺寸表达的确定性拆分)对齐到表单字段。
27 25 *
28 26 * <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推
29   - * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回,
30   - * agent 再据此走 {@code proposeWrite(action=create)}(人在环写入)。适合报价这类字段多的新建场景。
  27 + * SSE {@code form_collect} 事件,前端渲染表单;用户填完点【保存】→ 确定性端点
  28 + * {@code /api/agent/form/submit} 校验并写 ai_op_queue(AI 侧唯一写入口),不再经过 LLM。
31 29 *
32 30 * <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。
33 31 */
34 32 public class FormCollectTool {
35 33  
36   - private static final int MAX_FIELDS = 40;
37   -
38 34 // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理)
39 35 // 标注式:"长和宽50"、"高5cm"、"长:50"
40 36 private static final Pattern DIM_LABELED =
... ... @@ -43,14 +39,14 @@ public class FormCollectTool {
43 39 private static final Pattern DIM_POS =
44 40 Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
45 41  
46   - private final JdbcTemplate jdbc;
  42 + private final FormRenderService render;
47 43 private final FormResolverService resolver;
48 44 private final AgentIdentity identity;
49 45 private final ObjectMapper mapper;
50 46  
51   - public FormCollectTool(JdbcTemplate jdbc, FormResolverService resolver,
  47 + public FormCollectTool(FormRenderService render, FormResolverService resolver,
52 48 AgentIdentity identity, ObjectMapper mapper) {
53   - this.jdbc = jdbc;
  49 + this.render = render;
54 50 this.resolver = resolver;
55 51 this.identity = identity;
56 52 this.mapper = mapper;
... ... @@ -59,7 +55,7 @@ public class FormCollectTool {
59 55 @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
60 56 + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填"
61 57 + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。"
62   - + "用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。")
  58 + + "用户填完点【保存】后,系统自动校验并提交待办——你**无需**再做任何写操作,也绝不能说已保存。")
63 59 public String collectForm(
64 60 @P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword,
65 61 @P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}",
... ... @@ -80,76 +76,7 @@ public class FormCollectTool {
80 76 }
81 77  
82 78 String table = String.valueOf(form.get("sDataSource"));
83   - List<Map<String, Object>> fields = new ArrayList<>();
84   -
85   - // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的
86   - // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。
87   - List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS);
88   - Map<String, String> types = resolver.columnTypes(table);
89   - for (Map<String, Object> b : biz) {
90   - String col = str(b.get("col"));
91   - if (col == null || col.isBlank()) {
92   - continue;
93   - }
94   - Map<String, Object> f = new LinkedHashMap<>();
95   - f.put("name", col);
96   - f.put("label", firstNonBlank(str(b.get("label")), col));
97   - f.put("required", false);
98   - Object fk = b.get("fk");
99   - Object opts = b.get("options");
100   - String type = str(b.get("type"));
101   - if (fk != null && !String.valueOf(fk).isBlank()) {
102   - // 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id
103   - f.put("type", "fkselect");
104   - f.put("fkTable", String.valueOf(fk));
105   - } else if (opts instanceof List && !((List<?>) opts).isEmpty()) {
106   - f.put("type", "select");
107   - f.put("options", opts);
108   - } else if (type != null && !type.isBlank()) {
109   - f.put("type", type);
110   - } else {
111   - f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text
112   - }
113   - Object hint = b.get("hint");
114   - if (hint != null && !String.valueOf(hint).isBlank()) {
115   - f.put("hint", String.valueOf(hint));
116   - }
117   - fields.add(f);
118   - }
119   -
120   - // 兜底:字段字典没有该表映射时,退回 gdsconfigformslave 的非只读可见字段(排除系统列)。
121   - if (fields.isEmpty()) {
122   - try {
123   - List<Map<String, Object>> cols = jdbc.queryForList(
124   - "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " +
125   - "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " +
126   - "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
127   - for (Map<String, Object> c : cols) {
128   - String name = str(c.get("sName"));
129   - if (name == null || name.isBlank() || resolver.isSystemColumn(name)) {
130   - continue;
131   - }
132   - Map<String, Object> f = new LinkedHashMap<>();
133   - f.put("name", name);
134   - f.put("label", firstNonBlank(str(c.get("sChinese")), name));
135   - f.put("required", truthy(c.get("bNotEmpty")));
136   - String def = str(c.get("sDefault"));
137   - if (def != null && !def.isBlank()) {
138   - f.put("default", def);
139   - }
140   - List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));
141   - if (!opts.isEmpty()) {
142   - f.put("type", "select");
143   - f.put("options", opts);
144   - } else {
145   - f.put("type", "text");
146   - }
147   - fields.add(f);
148   - }
149   - } catch (Exception e) {
150   - return err("读取表单结构失败:" + e.getMessage());
151   - }
152   - }
  79 + List<Map<String, Object>> fields = render.formSkeleton(table, formId);
153 80 if (fields.isEmpty()) {
154 81 return err("该表单没有可填写的业务字段。");
155 82 }
... ... @@ -162,70 +89,10 @@ public class FormCollectTool {
162 89 out.put("moduleId", moduleId);
163 90 out.put("title", "新建" + entityKeyword.trim());
164 91 out.put("fields", fields);
165   - out.put("message", "请在下方表单里填写,填完点【提交】。");
  92 + out.put("message", "请在下方表单里填写,填完点【保存】。");
166 93 return toJson(out);
167 94 }
168 95  
169   - /** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */
170   - private List<String> simpleOptions(String dropdown) {
171   - List<String> out = new ArrayList<>();
172   - if (dropdown == null || dropdown.isBlank()) {
173   - return out;
174   - }
175   - String d = dropdown.trim();
176   - if (d.toLowerCase().contains("select ") || d.length() > 200) {
177   - return out; // SQL 或过长 -> 不当作枚举
178   - }
179   - for (String o : d.split("[、,,|]")) {
180   - String t = o.trim();
181   - if (!t.isEmpty() && out.size() < 30) {
182   - out.add(t);
183   - }
184   - }
185   - return out;
186   - }
187   -
188   - /** 按列的 MySQL 类型推断表单控件类型。 */
189   - private static String inferType(String dataType) {
190   - if (dataType == null) {
191   - return "text";
192   - }
193   - String t = dataType.toLowerCase();
194   - if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
195   - return "number";
196   - }
197   - if (t.contains("date") || t.contains("time")) {
198   - return "date";
199   - }
200   - return "text";
201   - }
202   -
203   - private static boolean truthy(Object o) {
204   - if (o == null) {
205   - return false;
206   - }
207   - if (o instanceof Boolean) {
208   - return (Boolean) o;
209   - }
210   - if (o instanceof Number) {
211   - return ((Number) o).intValue() != 0;
212   - }
213   - if (o instanceof byte[]) {
214   - byte[] b = (byte[]) o;
215   - return b.length > 0 && b[0] != 0;
216   - }
217   - String s = o.toString().trim();
218   - return s.equals("1") || s.equalsIgnoreCase("true");
219   - }
220   -
221   - private static String firstNonBlank(String a, String b) {
222   - return (a != null && !a.isBlank()) ? a : b;
223   - }
224   -
225   - private static String str(Object o) {
226   - return o == null ? null : o.toString();
227   - }
228   -
229 96 /** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */
230 97 private Map<String, String> parseKnown(String json) {
231 98 Map<String, String> m = new HashMap<>();
... ... @@ -248,10 +115,7 @@ public class FormCollectTool {
248 115 }
249 116  
250 117 private static String normLabel(String s) {
251   - if (s == null) {
252   - return "";
253   - }
254   - return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();
  118 + return FormRenderService.normLabel(s);
255 119 }
256 120  
257 121 /**
... ...
src/main/java/com/xly/tool/PreviewChangeTool.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +import com.xly.agent.AgentIdentity;
  4 +import com.xly.service.PreviewService;
  5 +import dev.langchain4j.agent.tool.P;
  6 +import dev.langchain4j.agent.tool.Tool;
  7 +
  8 +/**
  9 + * PreviewChange 工具(写路径的模型侧入口,**只读**)。
  10 + *
  11 + * <p>模型解析出「对哪条记录做什么改动」后,本工具生成一张**预览卡**:整表当前值 + 改动/状态变化高亮 +
  12 + * ERP 同名操作按钮(保存/审核/反审核/作废/取消作废/删除)。**本工具不写入任何数据**——用户点卡上按钮后,
  13 + * 确定性端点(/api/agent/preview/{id}/save)重校验并写 ai_op_queue(AI 侧唯一写入口),执行由 ERP 侧负责。
  14 + *
  15 + * <p>由 {@code AgentFactory} 按请求身份新建:携带 {@link AgentIdentity}(工具内鉴权)与会话 id。
  16 + */
  17 +public class PreviewChangeTool {
  18 +
  19 + private final PreviewService previews;
  20 + private final AgentIdentity identity;
  21 + private final String convId;
  22 +
  23 + public PreviewChangeTool(PreviewService previews, AgentIdentity identity, String convId) {
  24 + this.previews = previews;
  25 + this.identity = identity;
  26 + this.convId = convId;
  27 + }
  28 +
  29 + @Tool("为一次写操作生成**预览卡**(只读:本工具绝不写入数据,也不生成任何单据)。卡片展示记录当前内容、"
  30 + + "改动或状态变化高亮,并带对应操作按钮(保存/审核/反审核/作废/取消作废/删除);只有用户点了按钮,"
  31 + + "系统才会提交待办。action:update=改字段(recordKeyword+fieldChinese+newValue);"
  32 + + "invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废;examine=审核;"
  33 + + "cancelExamine=销审/反审核;delete=物理删除(业务单据别用)。**新增不用本工具**(新增走 collectForm)。"
  34 + + "本工具自行定位主表与记录,无需先 findForms。预览卡出现后你就停下等用户,绝不能说已保存/已完成。")
  35 + public String previewChange(
  36 + @P("动作:update|invalid|cancelInvalid|examine|cancelExamine|delete") String action,
  37 + @P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword,
  38 + @P("记录名称或单号关键词") String recordKeyword,
  39 + @P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese,
  40 + @P(value = "新值(仅 update,逐字照抄用户原话)", required = false) String newValue) {
  41 + return previews.buildPreview(identity, convId, action, entityKeyword, recordKeyword, fieldChinese, newValue);
  42 + }
  43 +}
... ...
src/main/java/com/xly/tool/ProposeWriteTool.java deleted
1   -package com.xly.tool;
2   -
3   -import com.fasterxml.jackson.databind.JsonNode;
4   -import com.fasterxml.jackson.databind.ObjectMapper;
5   -import com.xly.agent.AgentIdentity;
6   -import com.xly.service.ErpClient;
7   -import com.xly.service.FormResolverService;
8   -import com.xly.service.OpService;
9   -import dev.langchain4j.agent.tool.P;
10   -import dev.langchain4j.agent.tool.Tool;
11   -import org.springframework.jdbc.core.JdbcTemplate;
12   -
13   -import java.util.ArrayList;
14   -import java.util.Iterator;
15   -import java.util.LinkedHashMap;
16   -import java.util.List;
17   -import java.util.Map;
18   -import java.util.Set;
19   -
20   -/**
21   - * ProposeWrite 工具(写操作,人在环)。
22   - *
23   - * <p>**只提议并暂存,绝不立即执行**:把「改某条记录的某字段」解析成具体的 表/记录id/列/新值,
24   - * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里
25   - * (见 OpController),不经过 LLM。
26   - */
27   -public class ProposeWriteTool {
28   -
29   - private final ErpClient erp;
30   - private final JdbcTemplate jdbc;
31   - private final OpService ops;
32   - private final ObjectMapper mapper;
33   - private final AgentIdentity identity;
34   - private final FormResolverService resolver;
35   -
36   - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper,
37   - AgentIdentity identity, FormResolverService resolver) {
38   - this.erp = erp;
39   - this.jdbc = jdbc;
40   - this.ops = ops;
41   - this.mapper = mapper;
42   - this.identity = identity;
43   - this.resolver = resolver;
44   - }
45   -
46   - @Tool("提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。"
47   - + "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);"
48   - + "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。"
49   - + "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;"
50   - + "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。")
51   - public String proposeWrite(
52   - @P("动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete") String action,
53   - @P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword,
54   - @P(value = "记录名称或单号关键词(create 不需要)", required = false) String recordKeyword,
55   - @P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese,
56   - @P(value = "新值(仅 update)", required = false) String newValue,
57   - @P(value = "字段 JSON 中文名->值(仅 create)", required = false) String fieldsJson) {
58   - if (isBlank(action)) {
59   - return err("缺少 action。");
60   - }
61   - switch (action.trim().toLowerCase()) {
62   - case "create": return doCreate(entityKeyword, fieldsJson);
63   - case "update": return doUpdate(entityKeyword, recordKeyword, fieldChinese, newValue);
64   - case "invalid": return doInvalidate(entityKeyword, recordKeyword, false);
65   - case "cancelinvalid": return doInvalidate(entityKeyword, recordKeyword, true);
66   - case "examine": return doExamineOp(entityKeyword, recordKeyword, 1);
67   - case "cancelexamine": return doExamineOp(entityKeyword, recordKeyword, 0);
68   - case "delete": return doDelete(entityKeyword, recordKeyword);
69   - default:
70   - return err("未知 action:" + action + "(支持 create/update/invalid/cancelInvalid/examine/cancelExamine/delete)");
71   - }
72   - }
73   -
74   - /** 作废(cancel=false)/复原·取消作废(cancel=true)一条记录 → ERP updatebInvalid(Sp_Invalidation)。业务单据的“删除”走这里。 */
75   - private String doInvalidate(String entityKeyword, String recordKeyword, boolean cancel) {
76   - String verb = cancel ? "复原" : "作废";
77   - if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
78   - return err("缺少实体类型或记录名称。");
79   - }
80   - Located l = locateRecord(entityKeyword, recordKeyword, verb);
81   - if (l.error != null) {
82   - return l.error;
83   - }
84   - String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")";
85   - // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid
86   - String opId = ops.createDraft(identity.userId(), "invalid", l.formId, l.moduleId, l.table, l.billId,
87   - null, null, null, cancel ? "cancel" : "toVoid", description);
88   -
89   - Map<String, Object> out = new LinkedHashMap<>();
90   - out.put("opId", opId);
91   - out.put("summary", description);
92   - out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。");
93   - return toJson(out);
94   - }
95   -
96   - /** 修改某条记录的某个字段。见 {@link #proposeWrite}。 */
97   - private String doUpdate(String entityKeyword, String recordKeyword, String fieldChinese, String newValue) {
98   -
99   - if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) {
100   - return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。");
101   - }
102   -
103   - // 1) 定位主表 + 唯一记录
104   - Located l = locateRecord(entityKeyword, recordKeyword, "修改");
105   - if (l.error != null) {
106   - return l.error;
107   - }
108   -
109   - // 2) 字段中文名 -> 技术列名
110   - String field = queryOne(
111   - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1",
112   - l.table, fieldChinese.trim());
113   - if (field == null) {
114   - field = queryOne(
115   - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1",
116   - l.table, "%" + fieldChinese.trim() + "%");
117   - }
118   - if (field == null) {
119   - return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
120   - }
121   - // 系统/审计列(单号、制单人、租户、主键、日期标志…)不允许由对话改写
122   - if (resolver.isSystemColumn(field)) {
123   - return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。");
124   - }
125   - String oldValue = l.rec.path(field).asText("");
126   -
127   - // 3) 值规范化:外键列把名称解析成 id,其余按列类型强转(失败即报错,绝不静默写入错值)
128   - String fkTable = queryOne(
129   - "SELECT MAX(sFkTable) FROM viw_kg_field_dict WHERE sTable=? AND sField=?", l.table, field);
130   - Object stored;
131   - String shown = newValue;
132   - if (fkTable != null && !fkTable.isBlank() && !"null".equalsIgnoreCase(fkTable)) {
133   - String id = resolver.resolveFk(fkTable, identity.brandsId(), newValue);
134   - if (id == null) {
135   - String ent = fieldChinese.replace("名称", "");
136   - return err("「" + newValue + "」不是系统里已有的" + ent + ",请从已有记录里选一个。");
137   - }
138   - stored = id;
139   - } else {
140   - try {
141   - stored = resolver.coerce(resolver.columnTypes(l.table).get(field), newValue);
142   - } catch (IllegalArgumentException ex) {
143   - return err("「" + fieldChinese + "」" + ex.getMessage() + ",请给一个有效值。");
144   - }
145   - if (stored == null) {
146   - return err("「" + newValue + "」不是「" + fieldChinese + "」可接受的值(日期请用 2026-07-28 这种格式)。");
147   - }
148   - shown = String.valueOf(stored);
149   - }
150   -
151   - // 4) 暂存 draft(不执行)。摘要展示**将要写入的值**,与 payload 同源,避免"看到的与执行的不一致"
152   - String description = "将【" + l.recordName + "】的【" + fieldChinese + "】"
153   - + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + shown + "」"
154   - + (shown.equals(newValue) ? "" : "(原话:" + newValue + ")");
155   - String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId,
156   - field, fieldChinese, oldValue, String.valueOf(stored), description);
157   -
158   - Map<String, Object> out = new LinkedHashMap<>();
159   - out.put("opId", opId);
160   - out.put("summary", description);
161   - out.put("message", "已为你生成一条待确认的修改,请在下方点【确认】执行、或【取消】。");
162   - return toJson(out);
163   - }
164   -
165   - /** 物理删除一条记录(罕用;业务单据请用 doInvalidate 作废)。见 {@link #proposeWrite}。 */
166   - private String doDelete(String entityKeyword, String recordKeyword) {
167   -
168   - if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
169   - return err("缺少实体类型或记录名称。");
170   - }
171   - Located l = locateRecord(entityKeyword, recordKeyword, "删除");
172   - if (l.error != null) {
173   - return l.error;
174   - }
175   - String description = "删除【" + l.recordName + "】(" + entityKeyword + ")";
176   - String opId = ops.createDraft(identity.userId(), "delete", l.formId, l.moduleId, l.table, l.billId,
177   - null, null, l.recordName, null, description);
178   -
179   - Map<String, Object> out = new LinkedHashMap<>();
180   - out.put("opId", opId);
181   - out.put("summary", description);
182   - out.put("message", "已为你生成一条待确认的删除,请在下方点【确认】执行、或【取消】。删除不可恢复,请谨慎。");
183   - return toJson(out);
184   - }
185   -
186   - /** 新增一条记录(报价走多表 proposeQuote)。见 {@link #proposeWrite}。 */
187   - private String doCreate(String entityKeyword, String fieldsJson) {
188   -
189   - if (isBlank(entityKeyword)) {
190   - return err("缺少实体类型。");
191   - }
192   - Map<String, Object> form = resolveForm(entityKeyword.trim());
193   - if (form == null) {
194   - return err("找不到「" + entityKeyword + "」对应的可新增主表。");
195   - }
196   - String formId = str(form.get("sFormId"));
197   - String moduleId = str(form.get("sModuleId"));
198   - String table = str(form.get("sDataSource"));
199   - if (!identity.canAccessModule(moduleId)) {
200   - return err("你没有新增「" + entityKeyword + "」的权限。");
201   - }
202   -
203   - // 报价是跨表主-从单据 → 走多表创建(主表 + 印刷/部件从表 + 多数量)
204   - if ("quoquotationmaster".equalsIgnoreCase(table)) {
205   - return proposeQuote(fieldsJson, formId, moduleId, table);
206   - }
207   -
208   - Map<String, String> types = resolver.columnTypes(table);
209   - // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射
210   - Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
211   - for (Map<String, Object> bfm : resolver.businessFields(table, 40)) {
212   - labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
213   - }
214   - Map<String, Object> col = new LinkedHashMap<>();
215   - List<String> descParts = new ArrayList<>();
216   - try {
217   - if (!isBlank(fieldsJson)) {
218   - JsonNode fj = mapper.readTree(fieldsJson.trim());
219   - Iterator<Map.Entry<String, JsonNode>> it = fj.fields();
220   - while (it.hasNext()) {
221   - Map.Entry<String, JsonNode> e = it.next();
222   - String zh = e.getKey();
223   - String v = e.getValue().asText("");
224   - if (isBlank(v)) {
225   - continue;
226   - }
227   - Map<String, Object> fm = labelMap.get(normLabel(zh));
228   - if (fm == null) {
229   - fm = resolveColumn(table, zh); // 回退字段字典
230   - }
231   - if (fm == null) {
232   - continue; // 该实体没有这个字段,忽略
233   - }
234   - String colName = str(fm.get("col"));
235   - // 别让 LLM/用户直填系统列(制单人/单据日期/租户/单号…),交给 ERP 与下方系统列处理
236   - if (resolver.isSystemColumn(colName)) {
237   - continue;
238   - }
239   - String fk = str(fm.get("fk"));
240   - if (fk != null && !fk.isBlank()) {
241   - String id = resolver.resolveFk(fk, identity.brandsId(), v); // 外键:名称 -> id
242   - if (id == null) {
243   - String ent = zh.replace("名称", "");
244   - return err("「" + v + "」不是系统里已有的" + ent + "。请让用户从下拉里选真实存在的"
245   - + ent + ";**不要**因此新建" + ent + "。");
246   - }
247   - col.put(colName, id);
248   - descParts.add(zh + "=" + v);
249   - } else {
250   - Object cv;
251   - try {
252   - cv = resolver.coerce(types.get(colName), v); // 按列类型强转
253   - } catch (IllegalArgumentException ex) {
254   - return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。");
255   - }
256   - if (cv == null) {
257   - return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。");
258   - }
259   - col.put(colName, cv);
260   - // 摘要只写**真正入库的值**,避免确认卡片与 payload 不一致
261   - descParts.add(zh + "=" + cv + (String.valueOf(cv).equals(v) ? "" : "(原话:" + v + ")"));
262   - }
263   - }
264   - }
265   - } catch (Exception ex) {
266   - return err("字段解析失败:" + ex.getMessage());
267   - }
268   - if (descParts.isEmpty()) {
269   - return err("请至少提供一个有效字段(如客户名称)。");
270   - }
271   -
272   - // 自动补齐 NOT-NULL 无默认列(系统列跳过;其余按类型给默认 0/'')
273   - for (String rc : requiredCols(table)) {
274   - if (col.containsKey(rc) || resolver.isSystemColumn(rc)) {
275   - continue;
276   - }
277   - col.put(rc, resolver.typeDefault(rc, types.get(rc)));
278   - }
279   - // 系统列:主键 + 表单id + 单号(租户/制单人/日期由 ERP 注入,不填)
280   - col.put("sId", erp.newUuid(identity.token()));
281   - col.put("sFormId", formId);
282   - String billNo = resolver.nextBillNo(table, identity.brandsId());
283   - if (billNo != null) {
284   - col.put("sBillNo", billNo);
285   - }
286   -
287   - String payload;
288   - try {
289   - payload = mapper.writeValueAsString(col);
290   - } catch (Exception e) {
291   - return err("内部错误:" + e.getMessage());
292   - }
293   - String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts);
294   - String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description);
295   -
296   - Map<String, Object> out = new LinkedHashMap<>();
297   - out.put("opId", opId);
298   - out.put("summary", description);
299   - out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。");
300   - return toJson(out);
301   - }
302   -
303   - /** 审核(iFlag=1)/反审核·销审(iFlag=0)一条单据 → ERP doExamine。见 {@link #proposeWrite}。 */
304   - private String doExamineOp(String entityKeyword, String recordKeyword, int iFlag) {
305   -
306   - if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
307   - return err("缺少单据类型或单号/名称。");
308   - }
309   - String verb = iFlag == 1 ? "审核" : "反审核";
310   - Located l = locateRecord(entityKeyword, recordKeyword, verb);
311   - if (l.error != null) {
312   - return l.error;
313   - }
314   - String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")";
315   - // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动)
316   - String opId = ops.createDraft(identity.userId(), "examine", l.formId, l.moduleId, l.table, l.billId,
317   - null, null, null, String.valueOf(iFlag), description);
318   -
319   - Map<String, Object> out = new LinkedHashMap<>();
320   - out.put("opId", opId);
321   - out.put("summary", description);
322   - out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。");
323   - return toJson(out);
324   - }
325   -
326   - /**
327   - * 报价多表创建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。
328   - * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。
329   - * 价格由 ERP【核价】计算,本工具只落主-从明细。生成 __tables__ 结构化 payload,确认端点做多表写入。
330   - */
331   - private String proposeQuote(String fieldsJson, String formId, String moduleId, String table) {
332   - Map<String, String> masterTypes = resolver.columnTypes(table);
333   - Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave");
334   - Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
335   - for (Map<String, Object> bfm : resolver.businessFields(table, 40)) {
336   - labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm);
337   - }
338   -
339   - Map<String, Object> masterCol = new LinkedHashMap<>();
340   - Map<String, Object> slaveCol = new LinkedHashMap<>();
341   - List<String> notes = new ArrayList<>();
342   - List<String> manyQtys = new ArrayList<>();
343   - List<String> descParts = new ArrayList<>();
344   - String custId = null;
345   - try {
346   - if (!isBlank(fieldsJson)) {
347   - JsonNode fj = mapper.readTree(fieldsJson.trim());
348   - Iterator<Map.Entry<String, JsonNode>> it = fj.fields();
349   - while (it.hasNext()) {
350   - Map.Entry<String, JsonNode> e = it.next();
351   - String zh = e.getKey();
352   - String v = e.getValue().asText("");
353   - if (isBlank(v)) {
354   - continue;
355   - }
356   - Map<String, Object> fm = labelMap.get(normLabel(zh));
357   - if (fm == null) {
358   - continue;
359   - }
360   - String colName = str(fm.get("col"));
361   - String tgt = fm.get("table") == null ? "master" : str(fm.get("table"));
362   - String fk = str(fm.get("fk"));
363   - if ("note".equals(tgt)) {
364   - notes.add(zh + "=" + v);
365   - descParts.add(zh + "=" + v);
366   - continue;
367   - }
368   - if ("manyqtys".equals(tgt)) {
369   - for (String q : v.split("[,,、\\s]+")) {
370   - String n = q.trim();
371   - if (n.isEmpty()) {
372   - continue;
373   - }
374   - // 逐个必须是干净数字:以前 replaceAll 会把「三千」吞成空、「1k」变成 1
375   - if (!n.matches("\\d+(\\.\\d+)?")) {
376   - return err("多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。");
377   - }
378   - manyQtys.add(n);
379   - }
380   - descParts.add(zh + "=" + String.join(",", manyQtys));
381   - continue;
382   - }
383   - Object value;
384   - if (fk != null && !fk.isBlank()) {
385   - String id = resolver.resolveFk(fk, identity.brandsId(), v);
386   - if (id == null) {
387   - String ent = zh.replace("名称", "");
388   - return err("「" + v + "」不是系统里已有的" + ent + "。请用 collectForm 让用户从下拉里选真实存在的"
389   - + ent + ";**不要**因此新建" + ent + ",也不要把产品/规格当成客户。");
390   - }
391   - value = id;
392   - if ("sCustomerId".equals(colName)) {
393   - custId = id;
394   - }
395   - } else {
396   - String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName);
397   - try {
398   - value = resolver.coerce(dt, v);
399   - } catch (IllegalArgumentException ex) {
400   - return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。");
401   - }
402   - if (value == null) {
403   - return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。");
404   - }
405   - }
406   - if ("slave".equals(tgt)) {
407   - slaveCol.put(colName, value);
408   - } else {
409   - masterCol.put(colName, value);
410   - }
411   - // 摘要写真正入库的值(FK 显示用户给的名称,数值显示规范化后的数)
412   - String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(value);
413   - descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")"));
414   - }
415   - }
416   - } catch (Exception ex) {
417   - return err("字段解析失败:" + ex.getMessage());
418   - }
419   - if (descParts.isEmpty()) {
420   - return err("请至少填写一个字段(如客户名称/产品名称/数量)。");
421   - }
422   -
423   - // 主表:补必填(非系统) + 主键 + 表单id + 单号
424   - for (String rc : requiredCols(table)) {
425   - if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) {
426   - continue;
427   - }
428   - masterCol.put(rc, resolver.typeDefault(rc, masterTypes.get(rc)));
429   - }
430   - String masterId = erp.newUuid(identity.token());
431   - masterCol.put("sId", masterId);
432   - masterCol.put("sFormId", formId);
433   - String billNo = resolver.nextBillNo(table, identity.brandsId());
434   - if (billNo != null) {
435   - masterCol.put("sBillNo", billNo);
436   - }
437   -
438   - List<Map<String, Object>> tables = new ArrayList<>();
439   - tables.add(tableItem("quoquotationmaster", "master", masterCol));
440   -
441   - // 印刷/部件从表:有从表字段或印刷备注就建一行(sCustomerId 从表 NOT-NULL)
442   - if (!slaveCol.isEmpty() || !notes.isEmpty()) {
443   - if (!notes.isEmpty()) {
444   - String memo = String.join(";", notes);
445   - Object exist = slaveCol.get("sMaterialsMemo");
446   - slaveCol.put("sMaterialsMemo", exist == null ? memo : (exist + ";" + memo));
447   - }
448   - slaveCol.put("sId", erp.newUuid(identity.token()));
449   - slaveCol.put("sParentId", masterId);
450   - slaveCol.put("sCustomerId", custId == null ? "" : custId);
451   - tables.add(tableItem("quoquotationslave", "slave", slaveCol));
452   - }
453   - // 多数量报价:每个数量一行
454   - for (String q : manyQtys) {
455   - Map<String, Object> mq = new LinkedHashMap<>();
456   - mq.put("sId", erp.newUuid(identity.token()));
457   - mq.put("sParentId", masterId);
458   - mq.put("dManyQty", q);
459   - tables.add(tableItem("quoquotationmanyqtys", "slave", mq));
460   - }
461   -
462   - Map<String, Object> wrap = new LinkedHashMap<>();
463   - wrap.put("__tables__", tables);
464   - String payload;
465   - try {
466   - payload = mapper.writeValueAsString(wrap);
467   - } catch (Exception e) {
468   - return err("内部错误:" + e.getMessage());
469   - }
470   - String description = "新增【报价】:" + String.join(",", descParts)
471   - + "(含印刷/多数量明细;价格请在 ERP 点【核价】计算)";
472   - String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description);
473   -
474   - Map<String, Object> out = new LinkedHashMap<>();
475   - out.put("opId", opId);
476   - out.put("summary", description);
477   - out.put("message", "已为你生成一条待确认的报价新增(主表+印刷明细+多数量),请点【确认】执行、或【取消】。价格在 ERP 里点【核价】计算。");
478   - return toJson(out);
479   - }
480   -
481   - private Map<String, Object> tableItem(String sTable, String name, Map<String, Object> column) {
482   - Map<String, Object> m = new LinkedHashMap<>();
483   - m.put("sTable", sTable);
484   - m.put("name", name);
485   - m.put("column", column);
486   - return m;
487   - }
488   -
489   - /** 中文名 -> {col, fk}(字段字典,先精确合并模糊,取使用度最高的一列)。 */
490   - private Map<String, Object> resolveColumn(String table, String zh) {
491   - try {
492   - List<Map<String, Object>> r = jdbc.queryForList(
493   - "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " +
494   - "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " +
495   - "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1",
496   - table, zh, "%" + zh + "%");
497   - return r.isEmpty() ? null : r.get(0);
498   - } catch (Exception e) {
499   - return null;
500   - }
501   - }
502   -
503   - /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */
504   - private List<String> requiredCols(String table) {
505   - List<String> out = new ArrayList<>();
506   - try {
507   - List<Map<String, Object>> rows = jdbc.queryForList(
508   - "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " +
509   - "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table);
510   - Set<String> skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson");
511   - for (Map<String, Object> r : rows) {
512   - String c = str(r.get("COLUMN_NAME"));
513   - if (c != null && !skip.contains(c)) {
514   - out.add(c);
515   - }
516   - }
517   - } catch (Exception ignore) {
518   - }
519   - return out;
520   - }
521   -
522   - /** 定位实体的可写主表——统一走 FormResolverService(与 collectForm 同源,含从属/参数表排除)。 */
523   - private Map<String, Object> resolveForm(String entityKeyword) {
524   - return resolver.resolveMasterForm(entityKeyword);
525   - }
526   -
527   - /** {@link #locateRecord} 的结果:error 非空表示失败(已是 err() JSON);否则携带定位到的主表与唯一记录。 */
528   - private static final class Located {
529   - String error;
530   - String formId;
531   - String moduleId;
532   - String table;
533   - JsonNode rec;
534   - String billId;
535   - String recordName;
536   - }
537   -
538   - /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称字段模糊匹配 → 0 条/多条/缺主键报错。 */
539   - private Located locateRecord(String entityKeyword, String recordKeyword, String verb) {
540   - Located l = new Located();
541   - Map<String, Object> form = resolveForm(entityKeyword.trim());
542   - if (form == null) {
543   - l.error = err("找不到「" + entityKeyword + "」对应的可操作主表。");
544   - return l;
545   - }
546   - l.formId = str(form.get("sFormId"));
547   - l.moduleId = str(form.get("sModuleId"));
548   - l.table = str(form.get("sDataSource"));
549   - if (!identity.canAccessModule(l.moduleId)) {
550   - l.error = err("你没有" + verb + "「" + entityKeyword + "」的权限。");
551   - return l;
552   - }
553   - String nameField = resolver.searchField(l.table, recordKeyword);
554   - JsonNode root;
555   - try {
556   - root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim());
557   - } catch (Exception e) {
558   - l.error = err("定位记录失败:" + e.getMessage());
559   - return l;
560   - }
561   - if (root.path("code").asInt(0) < 0) {
562   - l.error = err("定位记录失败:" + root.path("msg").asText("未知错误"));
563   - return l;
564   - }
565   - JsonNode rows = root.path("dataset").path("rows");
566   - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
567   - int n = (data != null && data.isArray()) ? data.size() : 0;
568   - if (n == 0) {
569   - l.error = err("没有找到名称含「" + recordKeyword + "」的记录。");
570   - return l;
571   - }
572   - if (n > 1) {
573   - StringBuilder names = new StringBuilder();
574   - for (int i = 0; i < data.size() && i < 5; i++) {
575   - if (i > 0) names.append("、");
576   - names.append(nameField == null ? "" : data.get(i).path(nameField).asText(""));
577   - }
578   - l.error = err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。");
579   - return l;
580   - }
581   - l.rec = data.get(0);
582   - l.billId = l.rec.path("sId").asText(null);
583   - if (isBlank(l.billId)) {
584   - l.error = err("定位到的记录缺少主键 sId,无法" + verb + "。");
585   - return l;
586   - }
587   - l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword);
588   - return l;
589   - }
590   -
591   - private static String str(Object o) {
592   - return o == null ? null : o.toString();
593   - }
594   -
595   - private String queryOne(String sql, Object... args) {
596   - try {
597   - List<Map<String, Object>> r = jdbc.queryForList(sql, args);
598   - if (!r.isEmpty()) {
599   - Object v = r.get(0).values().iterator().next();
600   - return v == null ? null : v.toString();
601   - }
602   - } catch (Exception ignore) {
603   - }
604   - return null;
605   - }
606   -
607   - private static boolean isBlank(String s) {
608   - return s == null || s.isBlank();
609   - }
610   -
611   - /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */
612   - private static String normLabel(String s) {
613   - if (s == null) {
614   - return "";
615   - }
616   - return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();
617   - }
618   -
619   - private String err(String msg) {
620   - Map<String, Object> m = new LinkedHashMap<>();
621   - m.put("error", msg);
622   - return toJson(m);
623   - }
624   -
625   - private String toJson(Map<String, Object> m) {
626   - try {
627   - return mapper.writeValueAsString(m);
628   - } catch (Exception e) {
629   - return "{\"error\":\"内部错误\"}";
630   - }
631   - }
632   -}
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -8,7 +8,7 @@ import com.xly.config.AgentFactory;
8 8 import com.xly.service.AuthzService;
9 9 import com.xly.service.ConversationService;
10 10 import com.xly.service.LedgerService;
11   -import com.xly.service.OpService;
  11 +import com.xly.service.PreviewService;
12 12 import dev.langchain4j.service.TokenStream;
13 13 import dev.langchain4j.service.tool.ToolExecution;
14 14 import org.slf4j.Logger;
... ... @@ -35,41 +35,43 @@ import java.util.regex.Pattern;
35 35 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
36 36 *
37 37 * <p><b>编排</b>:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent
38   - * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。
39   - * 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。
40   - * 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*}
41   - * 确定性端点、身份内省 fail-closed、工具内鉴权。
  38 + * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/previewChange/askUser,
  39 + * **全部只读/只渲染**)。流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」
  40 + * 卡上跨轮续办)。写路径:previewChange/collectForm 只出卡片,用户点卡上按钮 → 确定性端点
  41 + * (/api/agent/preview/{id}/save、/api/agent/form/submit)校验后写 ai_op_queue(AI 侧唯一写入口),
  42 + * 执行由 ERP 侧负责。安全不变量在工具与端点内:身份内省 fail-closed、工具内鉴权、token 不进 prompt。
42 43 *
43 44 * <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
44   - * 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。
  45 + * 前端不显示),复发则标注未核实;声称「已保存/已提交/已完成」却没出过预览卡/表单 → 附纠正提示。
45 46 *
46   - * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清
47   - * {@code question}、表单收集 {@code form_collect}。确认/取消与表单提交走确定性端点,不经过 LLM。
  47 + * <p>帧格式:{@code {"type":"token|reset|done|error"}}、预览卡 {@code change_preview}、澄清
  48 + * {@code question}、表单收集 {@code form_collect}。保存与表单提交走确定性端点,不经过 LLM。
48 49 */
49 50 @RestController
50 51 @RequestMapping("/api/agent")
51 52 public class AgentChatController {
52 53  
53 54 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
54   - private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite");
55   - /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */
56   - private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)");
  55 + private static final Set<String> CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm");
  56 + /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */
  57 + private static final Pattern WRITE_CLAIM =
  58 + Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)");
57 59  
58 60 private final AgentFactory agentFactory;
59 61 private final AuthzService authz;
60 62 private final ObjectMapper mapper;
61 63 private final ConversationService conversations;
62   - private final OpService ops;
  64 + private final PreviewService previews;
63 65 private final LedgerService ledger;
64 66 private final ExecutorService exec = Executors.newCachedThreadPool();
65 67  
66 68 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
67   - ConversationService conversations, OpService ops, LedgerService ledger) {
  69 + ConversationService conversations, PreviewService previews, LedgerService ledger) {
68 70 this.agentFactory = agentFactory;
69 71 this.authz = authz;
70 72 this.mapper = mapper;
71 73 this.conversations = conversations;
72   - this.ops = ops;
  74 + this.previews = previews;
73 75 this.ledger = ledger;
74 76 }
75 77  
... ... @@ -117,8 +119,9 @@ public class AgentChatController {
117 119 }
118 120  
119 121 /**
120   - * 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create),
121   - * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
  122 + * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue
  123 + * (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。
  124 + * 同步返回 {queued,opId,summary,message} 或 {error}。
122 125 */
123 126 @PostMapping("/form/submit")
124 127 public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req,
... ... @@ -145,43 +148,26 @@ public class AgentChatController {
145 148 }
146 149 });
147 150 conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单");
148   - ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()));
  151 + ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()), identity);
149 152  
150   - String fieldsJson;
151 153 try {
152   - fieldsJson = mapper.writeValueAsString(fields);
153   - } catch (Exception e) {
154   - out.put("error", "字段序列化失败:" + e.getMessage());
155   - return out;
156   - }
157   - String result = agentFactory.proposeWriteTool(identity)
158   - .proposeWrite("create", entity, null, null, null, fieldsJson);
159   - try {
160   - JsonNode r = mapper.readTree(result);
161   - String opId = r.path("opId").asText(null);
162   - if (opId != null && !opId.isBlank()) {
163   - ops.attachConversation(opId, convId);
164   - String summary = r.path("summary").asText("");
165   - ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
166   - out.put("opId", opId);
167   - out.put("summary", summary);
168   - out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
169   - } else {
170   - String err = r.path("error").asText("无法完成该新增。");
171   - ledger.append(convId, "assistant", Map.of("text", err));
172   - out.put("error", err);
  154 + Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields);
  155 + if (r.containsKey("error")) {
  156 + ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity);
173 157 }
  158 + return r;
174 159 } catch (Exception e) {
  160 + log.warn("form submit failed (conv={})", convId, e);
175 161 out.put("error", "服务异常:" + e.getMessage());
  162 + return out;
176 163 }
177   - return out;
178 164 }
179 165  
180 166 /**
181 167 * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
182 168 * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
183   - * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没
184   - * proposeWrite → 附纠正提示。
  169 + * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已保存/已提交但本轮
  170 + * 没出过预览卡/表单 → 附纠正提示。
185 171 */
186 172 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
187 173 String text, boolean allowRetry) {
... ... @@ -189,12 +175,12 @@ public class AgentChatController {
189 175 // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
190 176 ReActAgent agent = agentFactory.build(identity, convId, !allowRetry);
191 177 AtomicInteger toolCalls = new AtomicInteger();
192   - AtomicBoolean proposed = new AtomicBoolean(false);
  178 + AtomicBoolean carded = new AtomicBoolean(false);
193 179 TokenStream ts = agent.chat(convId, text);
194 180 ts.onPartialResponse(token -> send(emitter, "token", token))
195 181 .onToolExecuted(te -> {
196 182 toolCalls.incrementAndGet();
197   - handleToolExecuted(emitter, convId, te, proposed);
  183 + handleToolExecuted(emitter, te, carded);
198 184 })
199 185 .onCompleteResponse(resp -> {
200 186 String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null
... ... @@ -210,8 +196,8 @@ public class AgentChatController {
210 196 }
211 197 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。");
212 198 }
213   - if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
214   - send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。");
  199 + if (!carded.get() && WRITE_CLAIM.matcher(answer).find()) {
  200 + send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成预览卡/表单(没有出现卡片即没有任何写入),请重新描述一次您要做的操作。");
215 201 }
216 202 // 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写
217 203 send(emitter, "done", "");
... ... @@ -248,33 +234,23 @@ public class AgentChatController {
248 234 }
249 235  
250 236 /**
251   - * 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件。
252   - * 落账由 EventLogChatMemory 统一完成(tool_call/tool_result 事件),这里只做 SSE 与 op 绑定。
  237 + * 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件
  238 + * (change_preview / question / form_collect)。落账由 EventLogChatMemory 统一完成
  239 + * (tool_call/tool_result 事件),这里只做 SSE 推送。
253 240 */
254   - private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) {
  241 + private void handleToolExecuted(SseEmitter emitter, ToolExecution te, AtomicBoolean carded) {
255 242 send(emitter, "reset", "");
256 243 try {
257 244 String toolName = te.request() == null ? "" : te.request().name();
258   - if (te.result() == null) {
  245 + if (te.result() == null || !CARD_TOOLS.contains(toolName)) {
259 246 return;
260 247 }
261   - if (WRITE_TOOLS.contains(toolName)) {
262   - JsonNode r = mapper.readTree(te.result());
263   - String opId = r.path("opId").asText(null);
264   - if (opId != null && !opId.isBlank()) {
265   - ops.attachConversation(opId, convId);
266   - Map<String, Object> card = new LinkedHashMap<>();
267   - card.put("type", "write_proposal");
268   - card.put("opId", opId);
269   - card.put("summary", r.path("summary").asText(""));
270   - sendEvent(emitter, card);
271   - proposed.set(true);
272   - }
273   - } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
274   - JsonNode r = mapper.readTree(te.result());
275   - String type = r.path("type").asText("");
276   - if ("question".equals(type) || "form_collect".equals(type)) {
277   - sendEvent(emitter, mapper.convertValue(r, Map.class));
  248 + JsonNode r = mapper.readTree(te.result());
  249 + String type = r.path("type").asText("");
  250 + if ("change_preview".equals(type) || "question".equals(type) || "form_collect".equals(type)) {
  251 + sendEvent(emitter, mapper.convertValue(r, Map.class));
  252 + if ("change_preview".equals(type) || "form_collect".equals(type)) {
  253 + carded.set(true);
278 254 }
279 255 }
280 256 } catch (Exception e) {
... ...
src/main/java/com/xly/web/OpController.java deleted
1   -package com.xly.web;
2   -
3   -import com.fasterxml.jackson.databind.JsonNode;
4   -import com.fasterxml.jackson.databind.ObjectMapper;
5   -import com.xly.agent.AgentIdentity;
6   -import com.xly.service.AuditService;
7   -import com.xly.service.AuthzService;
8   -import com.xly.service.ConversationService;
9   -import com.xly.service.ErpClient;
10   -import com.xly.service.FormResolverService;
11   -import com.xly.service.LedgerService;
12   -import com.xly.service.OpService;
13   -import org.springframework.http.HttpStatus;
14   -import org.springframework.web.server.ResponseStatusException;
15   -import org.slf4j.Logger;
16   -import org.slf4j.LoggerFactory;
17   -import org.springframework.web.bind.annotation.GetMapping;
18   -import org.springframework.web.bind.annotation.PathVariable;
19   -import org.springframework.web.bind.annotation.PostMapping;
20   -import org.springframework.web.bind.annotation.RequestHeader;
21   -import org.springframework.web.bind.annotation.RequestMapping;
22   -import org.springframework.web.bind.annotation.RequestParam;
23   -import org.springframework.web.bind.annotation.RestController;
24   -
25   -import java.util.LinkedHashMap;
26   -import java.util.List;
27   -import java.util.Map;
28   -
29   -/**
30   - * 写操作确认端点(**确定性、不经过 LLM**)—— 人在环写入闭环的执行侧。
31   - *
32   - * <p>用户在对话框点【确认】-> {@code /confirm} 才真正调 ERP 执行;点【取消】-> {@code /cancel}。
33   - * 覆盖全部 op 类型:create(含报价这类多表主-从)、update、delete、invalid/cancelInvalid、
34   - * examine/cancelExamine。执行完回写 {@code ai_op_queue} 状态 + 审计。
35   - *
36   - * <p>两条执行路径由 {@code erp.exec-staging.enabled} 切换:直连 ERP 通用写接口(本地默认),
37   - * 或委托 ERP 侧暂存执行器 {@code /ai/execStaging}(生产路径,事务化、以用户身份执行)。
38   - *
39   - * <p>当前**恒同步**:确认即执行并同步返回终态。架构 §10 设计的「auto 流程异步 + 卡片三态 + SSE 终态推送」
40   - * 尚未实现(见 docs/agent-architecture.md §18)。
41   - */
42   -@RestController
43   -@RequestMapping("/api/agent/op")
44   -public class OpController {
45   -
46   - private static final Logger log = LoggerFactory.getLogger(OpController.class);
47   -
48   - private final OpService ops;
49   - private final ErpClient erp;
50   - private final AuditService audit;
51   - private final ObjectMapper mapper;
52   - private final LedgerService ledger;
53   - private final AuthzService authz;
54   - private final ConversationService conversations;
55   - private final FormResolverService resolver;
56   -
57   - /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */
58   - @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}")
59   - private boolean execStagingEnabled;
60   -
61   - public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper,
62   - LedgerService ledger,
63   - AuthzService authz, ConversationService conversations,
64   - FormResolverService resolver) {
65   - this.ops = ops;
66   - this.erp = erp;
67   - this.audit = audit;
68   - this.mapper = mapper;
69   - this.ledger = ledger;
70   - this.authz = authz;
71   - this.conversations = conversations;
72   - this.resolver = resolver;
73   - }
74   -
75   - /** 确认/取消的结果落事件日志(rightPush 原子,流式输出中途点确认也不覆盖对话事件)——LLM 下轮经投影即知这单已执行/失败/取消。 */
76   - private void recordOutcome(String conv, String opId, String status, String msg, String description) {
77   - if (conv == null || conv.isBlank() || "null".equals(conv)) {
78   - return;
79   - }
80   - try {
81   - ledger.append(conv, "cancelled".equals(status) ? "cancel" : "confirm", Map.of(
82   - "opId", opId == null ? "" : opId,
83   - "status", status,
84   - "msg", msg == null ? "" : msg,
85   - "description", description == null ? "" : description));
86   - } catch (Exception e) {
87   - log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage());
88   - }
89   - }
90   -
91   - /**
92   - * 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。
93   - * 需登录且只能查自己的会话;只回渲染必需的字段——绝不外泄 sPayload/内部表名等。
94   - */
95   - @GetMapping("/pending")
96   - public Map<String, Object> pending(@RequestParam("conversationId") String conversationId,
97   - @RequestHeader(value = "Authorization", required = false) String authToken) {
98   - String uid = requireLogin(authToken).userId();
99   - if (!conversations.owns(uid, conversationId)) {
100   - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话");
101   - }
102   - Map<String, Object> op = ops.pending(conversationId);
103   - if (op == null) {
104   - return Map.of();
105   - }
106   - Map<String, Object> out = new LinkedHashMap<>();
107   - out.put("opId", op.get("sId"));
108   - out.put("summary", op.get("sDescription"));
109   - out.put("status", op.get("sStatus"));
110   - return out;
111   - }
112   -
113   - /**
114   - * 确认执行:调 ERP 执行暂存的写操作,回写状态。
115   - * <p>{@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份
116   - * 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。
117   - */
118   - @PostMapping("/{id}/confirm")
119   - public Map<String, Object> confirm(@PathVariable("id") String id,
120   - @RequestHeader(value = "Authorization", required = false) String authToken) {
121   - AgentIdentity me = requireLogin(authToken);
122   - Map<String, Object> op = ops.get(id);
123   - if (op == null) {
124   - return result("failed", "找不到该操作", null);
125   - }
126   - requireOwner(me, op);
127   - if (!"draft".equals(String.valueOf(op.get("sStatus")))) {
128   - return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op);
129   - }
130   - // CAS 抢占:并发/重复确认只有一个能真正执行,杜绝同一张单被执行两次
131   - if (!ops.claim(id)) {
132   - Map<String, Object> cur = ops.get(id);
133   - String st = cur == null ? "unknown" : String.valueOf(cur.get("sStatus"));
134   - return result(st, "该操作正在处理或已处理过(" + st + ")", op);
135   - }
136   - String uid = str(op.get("sUserId"));
137   - String conv = str(op.get("sConversationId"));
138   - String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId"));
139   - String detail = str(op.get("sDescription"));
140   -
141   - // 生产路径:委托 ERP 侧暂存执行器(它以用户身份执行、事务化并自行回写 ai_op_queue 状态)。
142   - if (execStagingEnabled) {
143   - return confirmViaExecutor(id, op, authToken, uid, conv, target, detail);
144   - }
145   - try {
146   - String opType = str(op.get("sOpType"));
147   - JsonNode r;
148   - if ("create".equals(opType)) {
149   - JsonNode payload = mapper.readTree(str(op.get("sPayload")));
150   - if (payload.has("__tables__")) { // 报价等多表主-从创建
151   - @SuppressWarnings("unchecked")
152   - List<Map<String, Object>> tables = mapper.convertValue(payload.get("__tables__"), List.class);
153   - if (!tables.isEmpty()) {
154   - @SuppressWarnings("unchecked")
155   - Map<String, Object> masterCol = (Map<String, Object>) tables.get(0).get("column");
156   - refreshBillNo(masterCol, str(op.get("sTargetTable")), me.brandsId());
157   - }
158   - r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables);
159   - } else {
160   - @SuppressWarnings("unchecked")
161   - Map<String, Object> columns = mapper.convertValue(payload, Map.class);
162   - refreshBillNo(columns, str(op.get("sTargetTable")), me.brandsId());
163   - r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns);
164   - }
165   - } else if ("delete".equals(opType)) {
166   - r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")));
167   - } else if ("invalid".equals(opType)) {
168   - boolean cancel = "cancel".equals(str(op.get("sNewValue"))); // cancel=复原/取消作废,toVoid=作废
169   - r = erp.invalidForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), cancel);
170   - } else if ("examine".equals(opType)) {
171   - int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核
172   - r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag);
173   - } else {
174   - r = erp.updateForm(authToken,
175   - str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")),
176   - str(op.get("sField")), str(op.get("sNewValue")));
177   - }
178   - int code = r.path("code").asInt(0);
179   - if (code == 1) {
180   - ops.setStatus(id, "executed", "操作成功");
181   - audit.log(uid, conv, "confirm", target, detail, true, "executed");
182   - recordOutcome(conv, id, "executed", null, detail);
183   - return result("executed", "已执行:" + op.get("sDescription"), op);
184   - }
185   - String msg = r.path("msg").asText("执行失败");
186   - ops.setStatus(id, "failed", msg);
187   - audit.log(uid, conv, "confirm", target, detail, false, msg);
188   - recordOutcome(conv, id, "failed", msg, detail);
189   - return result("failed", msg, op);
190   - } catch (Exception e) {
191   - log.warn("confirm op {} failed", id, e);
192   - ops.setStatus(id, "failed", e.getMessage());
193   - audit.log(uid, conv, "confirm", target, detail, false, e.getMessage());
194   - recordOutcome(conv, id, "failed", e.getMessage(), detail);
195   - return result("failed", "执行异常:" + e.getMessage(), op);
196   - }
197   - }
198   -
199   - /** 委托 ERP 侧暂存执行器执行(§10);执行器自行回写 ai_op_queue 状态,这里只映射结果 + 审计。 */
200   - private Map<String, Object> confirmViaExecutor(String id, Map<String, Object> op, String authToken,
201   - String uid, String conv, String target, String detail) {
202   - try {
203   - JsonNode r = erp.execStaging(authToken, id);
204   - String st = r.path("status").asText("failed");
205   - String msg = r.path("msg").asText("");
206   - boolean ok = "executed".equals(st);
207   - audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg)));
208   - recordOutcome(conv, id, ok ? "executed" : "failed", msg, detail);
209   - return result(ok ? "executed" : "failed",
210   - ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op);
211   - } catch (Exception e) {
212   - log.warn("execStaging confirm op {} failed", id, e);
213   - audit.log(uid, conv, "confirm", target, detail, false, e.getMessage());
214   - recordOutcome(conv, id, "failed", e.getMessage(), detail);
215   - return result("failed", "执行异常:" + e.getMessage(), op);
216   - }
217   - }
218   -
219   - /** 取消(需登录且必须是本人的操作)。 */
220   - @PostMapping("/{id}/cancel")
221   - public Map<String, Object> cancel(@PathVariable("id") String id,
222   - @RequestHeader(value = "Authorization", required = false) String authToken) {
223   - AgentIdentity me = requireLogin(authToken);
224   - Map<String, Object> op = ops.get(id);
225   - if (op != null) {
226   - requireOwner(me, op);
227   - }
228   - if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) {
229   - ops.setStatus(id, "cancelled", "用户取消");
230   - audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel",
231   - str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")),
232   - str(op.get("sDescription")), true, "cancelled");
233   - recordOutcome(str(op.get("sConversationId")), id, "cancelled", null, str(op.get("sDescription")));
234   - }
235   - return result("cancelled", "已取消", op);
236   - }
237   -
238   - /**
239   - * 单号在 propose 时算的是当时的 MAX+1;期间别人建过单就会撞号,所以**执行前重新生成**。
240   - * 拿不到新号时保留旧号(由 ERP 侧唯一约束兜底),不因此中断执行。
241   - */
242   - private void refreshBillNo(Map<String, Object> columns, String table, String brandsId) {
243   - if (columns == null || !columns.containsKey("sBillNo")) {
244   - return;
245   - }
246   - String fresh = resolver.nextBillNo(table, brandsId);
247   - if (fresh != null && !fresh.isBlank()) {
248   - columns.put("sBillNo", fresh);
249   - }
250   - }
251   -
252   - /** 需登录(token 服务端内省);未登录/过期 → 401。 */
253   - private AgentIdentity requireLogin(String authToken) {
254   - AgentIdentity id = authz.resolveIdentity(authToken);
255   - if (id == null) {
256   - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
257   - }
258   - return id;
259   - }
260   -
261   - /** 只有提议的发起人本人能确认/取消——否则任何人拿到 opId 就能替别人写库。 */
262   - private void requireOwner(AgentIdentity me, Map<String, Object> op) {
263   - String owner = str(op.get("sUserId"));
264   - if (owner == null || !owner.equals(me.userId())) {
265   - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权处理该操作");
266   - }
267   - }
268   -
269   - private Map<String, Object> result(String status, String msg, Map<String, Object> op) {
270   - Map<String, Object> m = new LinkedHashMap<>();
271   - m.put("status", status);
272   - m.put("msg", msg);
273   - if (op != null) {
274   - m.put("opId", op.get("sId"));
275   - m.put("description", op.get("sDescription"));
276   - }
277   - return m;
278   - }
279   -
280   - private static String str(Object o) {
281   - return o == null ? null : o.toString();
282   - }
283   -}
src/main/java/com/xly/web/PreviewController.java 0 → 100644
  1 +package com.xly.web;
  2 +
  3 +import com.xly.agent.AgentIdentity;
  4 +import com.xly.service.AuthzService;
  5 +import com.xly.service.PreviewService;
  6 +import org.springframework.http.HttpStatus;
  7 +import org.springframework.web.bind.annotation.PathVariable;
  8 +import org.springframework.web.bind.annotation.PostMapping;
  9 +import org.springframework.web.bind.annotation.RequestBody;
  10 +import org.springframework.web.bind.annotation.RequestHeader;
  11 +import org.springframework.web.bind.annotation.RequestMapping;
  12 +import org.springframework.web.bind.annotation.RestController;
  13 +import org.springframework.web.server.ResponseStatusException;
  14 +
  15 +import java.util.Map;
  16 +
  17 +/**
  18 + * 预览卡按钮端点(**确定性、不经过 LLM**)—— 用户在预览卡上点【保存/审核/作废/…】后调用。
  19 + *
  20 + * <p>{@link PreviewService#save} 按 previewId 取回服务端暂存的解析产物,**重读记录重校验**
  21 + * (所见即所写:记录被他人改过/状态已变 → 拒绝),通过后写一行 ai_op_queue(AI 侧唯一写入口)。
  22 + * **xlyAi 到此为止**:是否/何时执行由 ERP 侧负责。
  23 + *
  24 + * <p>安全:需登录(token 服务端内省 fail-closed);预览归属本人(previewId 服务端绑定 userId);
  25 + * 写队列前重查表单权限——三层都在 PreviewService.save 内完成。
  26 + */
  27 +@RestController
  28 +@RequestMapping("/api/agent/preview")
  29 +public class PreviewController {
  30 +
  31 + private final PreviewService previews;
  32 + private final AuthzService authz;
  33 +
  34 + public PreviewController(PreviewService previews, AuthzService authz) {
  35 + this.previews = previews;
  36 + this.authz = authz;
  37 + }
  38 +
  39 + public static class SaveReq {
  40 + /** 可编辑预览(update)上用户最终确认的 字段中文名->值;状态类操作可空。 */
  41 + public Map<String, String> fields;
  42 + }
  43 +
  44 + @PostMapping("/{id}/save")
  45 + public Map<String, Object> save(@PathVariable("id") String id,
  46 + @RequestBody(required = false) SaveReq req,
  47 + @RequestHeader(value = "Authorization", required = false) String auth) {
  48 + AgentIdentity me = authz.resolveIdentity(auth);
  49 + if (me == null) {
  50 + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
  51 + }
  52 + return previews.save(me, id, req == null ? null : req.fields);
  53 + }
  54 +}
... ...
src/main/resources/application-saaslocal.yml
... ... @@ -33,10 +33,8 @@ erp:
33 33 subsidiary: "1111111111"
34 34 username: admin
35 35 password: "666666"
36   - # 写入确认执行路径(架构 §10):
37   - # false = xlyAi 直连 ERP 通用写接口执行(本地默认,已测)。
38   - # true = 委托 ERP 侧暂存执行器 /ai/execStaging(生产路径,以用户 token 身份执行、事务化、支持审核/预填/待办)。
39   - exec-staging:
  36 + # Phase D 接口位:ERP dry-run 校验(/business/checkBusinessData)。ERP 侧完工前保持 false。
  37 + dry-run:
40 38 enabled: false
41 39  
42 40 # LLM 可观测(Langfuse,架构 §1)。默认关闭;自托管起来后填 key 开启:
... ...
src/main/resources/prompts/system.txt
... ... @@ -7,19 +7,19 @@
7 7 【技能清单(业务流程的权威步骤,用 useSkill 载入全文)】
8 8 {SKILL_INDEX}凡要 新建/报价/问价/修改/作废/审核 等会改动数据的业务,或不确定流程怎么走:**先 useSkill 载入对应技能**,再严格按技能步骤执行。简单查询可以直接查。
9 9  
10   -【可用工具(只有这些)】
  10 +【可用工具(只有这些,全部只读/只渲染——你没有任何直接写入的能力)】
11 11 - useSkill(skillName):载入上面某项技能的完整步骤文本。
12 12 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
13 13 - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。
14 14 - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。
15   -- collectForm(entityKeyword, knownFieldsJson?):新增单据时弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填(值逐字照抄原话)。
16   -- proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。
  15 +- collectForm(entityKeyword, knownFieldsJson?):新增单据时弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填(值逐字照抄原话)。用户点【保存】后系统自动校验并提交待办。
  16 +- previewChange(action, entityKeyword, recordKeyword, fieldChinese?, newValue?):对**已有记录**的写操作生成预览卡(只读:绝不写入)。卡片带 ERP 同名按钮(保存/审核/反审核/作废/取消作废/删除),用户点了按钮系统才提交待办。action:update=改字段;invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。
17 17 - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。
18 18  
19 19 【硬规则】
20 20 1. 绝不编造:答案里的每个数字、单号、表单名都必须来自工具结果;没查到就如实说没查到。
21   -2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**;提议卡出现就停下,请用户点【确认】
  21 +2. 写操作一律人在环:你只能弹表单(collectForm)或出预览卡(previewChange),**绝不声称已保存/已提交/已完成**——写入只发生在用户点卡上按钮之后,且由 ERP 决定何时执行。卡片出现就停下,请用户核对后点按钮
22 22 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,出钱的公司名才是**客户**。引用的客户/产品/物料必须是系统里已有的真实记录——找不到就让用户核对或从下拉里选,**绝不代建**。
23 23 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答。回答面向业务人员:用记录名称而不是内部 ID。
24 24  
25   -若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来。
  25 +若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来;「已提交待办」的操作不要重复提交。
... ...
src/main/resources/skills/quote-create.md
... ... @@ -3,7 +3,6 @@
3 3 【技能:新建报价】
4 4 问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。
5 5 1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。
6   -2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。
7   -3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。
8   -4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。
9   -5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。
  6 +2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【保存】,停下等待。
  7 +3. 用户点保存后系统自动校验并提交待办(上下文会出现「已提交待办」),你无需再做任何写操作;绝不说「已生成报价单」。
  8 +4. 用户问价格数字:请他在 ERP 里点【核价】得到;你自己不报任何价格数字。
... ...
src/main/resources/skills/record-create.md
... ... @@ -4,4 +4,4 @@
4 4 1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。
5 5 2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。
6 6 3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。
7   -4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。
  7 +4. 表单弹出即停,请用户填完点【保存】;保存后系统自动校验并提交待办,你无需再做任何写操作,绝不说已完成。
... ...
src/main/resources/skills/record-status.md
1 1 单据状态操作
2 2 作废/删除、复原、审核、反审核/销审某张已有单据
3 3 【技能:单据状态操作】
4   -动作对照(proposeWrite 的 action):
  4 +动作对照(previewChange 的 action):
5 5 - 删除/取消/不要了 → invalid(作废,可复原)
6 6 - 复原/恢复/取消作废 → cancelInvalid
7 7 - 审核/审核通过 → examine
8 8 - 反审核/销审/撤回审核 → cancelExamine
9 9 - 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复
10 10 1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。
11   -2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。
12   -3. 工具提示多条匹配:念候选让用户选后再调一次。
13   -4. 提议卡出现即停,请用户点【确认】;绝不说已完成。
  11 +2. previewChange(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。该工具只生成预览卡,不写入。
  12 +3. 工具提示多条匹配:念候选让用户选后再调一次;提示状态不合法(如已审核过):如实转告用户,停下。
  13 +4. 预览卡出现即停:请用户核对单据与状态变化后点卡上按钮(审核/作废/…);绝不说已完成。
14 14 消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。
... ...
src/main/resources/skills/record-update.md
... ... @@ -2,7 +2,7 @@
2 2 改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段
3 3 【技能:修改记录】
4 4 1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。
5   -2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话
  5 +2. 齐了就 previewChange(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。该工具只生成预览卡,不写入
6 6 3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。
7   -4. 提议卡出现即停,请用户点【确认】;绝不说已完成。
  7 +4. 预览卡出现即停:请用户核对整张表单(改动已高亮,可在卡上继续修正)后点【保存】;绝不说已保存/已完成。
8 8 注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。
... ...
src/main/resources/templates/chat.html
... ... @@ -187,6 +187,22 @@ body {
187 187 .ffield .fkrow input { flex: 1; background: var(--bg); }
188 188 .ffield .hint { font-size: 12px; color: var(--text2); margin-top: 2px; }
189 189  
  190 +/* previewChange 预览卡 */
  191 +.ffield.changed label { color: var(--accent); font-weight: 600; }
  192 +.ffield.changed input, .ffield.changed select { border-color: var(--accent); background: var(--accent-weak); }
  193 +.ffield .oldval { font-size: 12px; color: var(--text2); margin-top: 2px; text-decoration: line-through; }
  194 +.preview-ro { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; }
  195 +.preview-ro .l { color: var(--text2); min-width: 90px; }
  196 +.status-change {
  197 + margin-top: 10px;
  198 + padding: 8px 12px;
  199 + background: var(--accent-weak);
  200 + border-radius: 8px;
  201 + font-weight: 600;
  202 +}
  203 +.status-change .from { color: var(--text2); text-decoration: line-through; margin-right: 6px; }
  204 +.status-change .to { color: var(--accent); }
  205 +
190 206 /* 输入区 */
191 207 #inputbar { background: var(--bg); padding: 8px 20px 16px; }
192 208 #inputbox {
... ... @@ -527,8 +543,8 @@ function handleEvent(ev, bubble, buf) {
527 543 bubble.classList.add("thinking");
528 544 bubble.textContent = "…";
529 545 return "";
530   - case "write_proposal":
531   - addProposalCard(ev.opId, ev.summary);
  546 + case "change_preview":
  547 + addPreviewCard(ev);
532 548 return buf;
533 549 case "question":
534 550 addQuestionCard(ev.question, ev.options || [], ev.allowFree !== false);
... ... @@ -545,14 +561,85 @@ function handleEvent(ev, bubble, buf) {
545 561 }
546 562 }
547 563  
548   -/* ================= 写提议卡 ================= */
549   -function addProposalCard(opId, summary) {
  564 +/* ================= previewChange 预览卡 =================
  565 + 整表当前值 + 改动高亮(update 可继续在卡上修正字段)/ 单据摘要 + 状态变化高亮(状态类只读)。
  566 + 点卡上按钮(保存/审核/作废/…)→ 确定性端点 /preview/{id}/save 重校验后写 ai_op_queue;
  567 + 点按钮前不写入任何数据。【不用了】仅关闭卡片(服务端预览 30 分钟自动过期)。 */
  568 +function addPreviewCard(ev) {
550 569 const card = el("div", "card");
551   - card.appendChild(el("div", "card-title", "待确认操作"));
552   - card.appendChild(el("div", "muted", summary || ""));
  570 + card.appendChild(el("div", "card-title", ev.title || "写操作预览"));
  571 + if (ev.message) {
  572 + const m = el("div", "muted");
  573 + m.style.whiteSpace = "pre-wrap";
  574 + m.textContent = ev.message;
  575 + card.appendChild(m);
  576 + }
  577 + const controls = []; // {label, get()} —— 仅 editable 预览收集
  578 + if (ev.editable) {
  579 + const grid = el("div", "form-grid");
  580 + (ev.fields || []).forEach(f => {
  581 + const wrap = el("div", "ffield" + (f.changed ? " changed" : ""));
  582 + const lab = el("label");
  583 + lab.textContent = f.label || f.name;
  584 + wrap.appendChild(lab);
  585 + const initial = f.changed ? (f.newValue || "") : (f.value || "");
  586 + let getter;
  587 + if (f.type === "fkselect") {
  588 + const row = el("div", "fkrow");
  589 + const inp = el("input");
  590 + inp.readOnly = true;
  591 + inp.placeholder = "点击选择…";
  592 + inp.value = initial;
  593 + const pick = el("button", "btn", "选择");
  594 + pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; });
  595 + row.appendChild(inp);
  596 + row.appendChild(pick);
  597 + wrap.appendChild(row);
  598 + getter = () => inp.value.trim();
  599 + } else if (f.type === "select" && Array.isArray(f.options) && f.options.length) {
  600 + const sel = el("select");
  601 + sel.appendChild(el("option", null, ""));
  602 + f.options.forEach(o => {
  603 + const op = el("option", null, o);
  604 + op.value = o;
  605 + sel.appendChild(op);
  606 + });
  607 + sel.value = initial;
  608 + wrap.appendChild(sel);
  609 + getter = () => sel.value;
  610 + } else {
  611 + const inp = el("input");
  612 + if (f.type === "number") { inp.type = "text"; inp.inputMode = "decimal"; }
  613 + else if (f.type === "date") inp.type = "date";
  614 + inp.value = initial;
  615 + wrap.appendChild(inp);
  616 + getter = () => inp.value.trim();
  617 + }
  618 + if (f.changed && f.value) wrap.appendChild(el("div", "oldval", "原值:" + f.value));
  619 + grid.appendChild(wrap);
  620 + controls.push({ label: f.label || f.name, get: getter });
  621 + });
  622 + card.appendChild(grid);
  623 + } else {
  624 + (ev.fields || []).forEach(f => {
  625 + const row = el("div", "preview-ro");
  626 + row.appendChild(el("span", "l", (f.label || f.name) + ":"));
  627 + row.appendChild(el("span", null, f.value == null ? "" : String(f.value)));
  628 + card.appendChild(row);
  629 + });
  630 + if (ev.statusChange && ev.statusChange.label) {
  631 + const sc = el("div", "status-change");
  632 + sc.appendChild(el("span", null, ev.statusChange.label + ":"));
  633 + sc.appendChild(el("span", "from", ev.statusChange.from || ""));
  634 + sc.appendChild(el("span", null, "→ "));
  635 + sc.appendChild(el("span", "to", ev.statusChange.to || ""));
  636 + card.appendChild(sc);
  637 + }
  638 + }
  639 +
553 640 const actions = el("div", "actions");
554   - const ok = el("button", "btn primary", "确认执行");
555   - const no = el("button", "btn", "取消");
  641 + const ok = el("button", "btn primary", ev.buttonLabel || "保存");
  642 + const no = el("button", "btn", "不用了");
556 643 actions.appendChild(ok);
557 644 actions.appendChild(no);
558 645 card.appendChild(actions);
... ... @@ -561,30 +648,35 @@ function addProposalCard(opId, summary) {
561 648 messagesEl.appendChild(card);
562 649 scrollBottom();
563 650  
564   - const finish = (cls, text) => {
565   - ok.disabled = no.disabled = true;
566   - result.className = "result " + cls;
567   - result.textContent = text;
568   - scrollBottom();
569   - };
570 651 ok.onclick = async () => {
571 652 ok.disabled = no.disabled = true;
  653 + result.className = "result";
  654 + result.textContent = "提交中…";
572 655 try {
573   - const r = await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/confirm",
574   - { method: "POST", headers: authHeaders(false) });
  656 + const fields = {};
  657 + controls.forEach(c => { const v = c.get(); if (v) fields[c.label] = v; });
  658 + const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save",
  659 + { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) });
575 660 const d = await r.json();
576   - finish(d.status === "executed" ? "ok" : "bad", d.msg || d.status);
  661 + if (d.queued) {
  662 + result.className = "result ok";
  663 + result.textContent = d.message || "已提交待办。";
  664 + } else {
  665 + ok.disabled = no.disabled = false;
  666 + result.className = "result bad";
  667 + result.textContent = d.error || "提交失败。";
  668 + }
577 669 } catch (e) {
578   - finish("bad", "执行请求失败:" + e.message);
  670 + ok.disabled = no.disabled = false;
  671 + result.className = "result bad";
  672 + result.textContent = "提交失败:" + e.message;
579 673 }
  674 + scrollBottom();
580 675 };
581   - no.onclick = async () => {
  676 + no.onclick = () => {
582 677 ok.disabled = no.disabled = true;
583   - try {
584   - await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/cancel",
585   - { method: "POST", headers: authHeaders(false) });
586   - } catch (e) { }
587   - finish("bad", "已取消");
  678 + result.className = "result";
  679 + result.textContent = "已关闭(未提交任何操作)。";
588 680 };
589 681 }
590 682  
... ... @@ -674,7 +766,7 @@ function addFormCard(ev) {
674 766 card.appendChild(grid);
675 767  
676 768 const actions = el("div", "actions");
677   - const submit = el("button", "btn primary", "提交");
  769 + const submit = el("button", "btn primary", "保存");
678 770 const result = el("div", "result");
679 771 actions.appendChild(submit);
680 772 card.appendChild(actions);
... ... @@ -700,9 +792,9 @@ function addFormCard(ev) {
700 792 }
701 793 submit.disabled = true;
702 794 result.className = "result";
703   - result.textContent = "提交中…";
  795 + result.textContent = "保存中…";
704 796 try {
705   - // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码
  797 + // 确定性保存:结构化字段直达 create 校验并写 ai_op_queue(不经 LLM 再编码);执行由 ERP 侧负责
706 798 const r = await fetch(BASE + "/api/agent/form/submit", {
707 799 method: "POST",
708 800 headers: authHeaders(true),
... ... @@ -713,19 +805,18 @@ function addFormCard(ev) {
713 805 })
714 806 });
715 807 const d = await r.json();
716   - if (d.opId) {
  808 + if (d.queued) {
717 809 result.className = "result ok";
718   - result.textContent = "已生成待确认提议。";
719   - addProposalCard(d.opId, d.summary);
  810 + result.textContent = d.message || "已提交待办。";
720 811 } else {
721 812 submit.disabled = false;
722 813 result.className = "result bad";
723   - result.textContent = d.error || "提交失败。";
  814 + result.textContent = d.error || "保存失败。";
724 815 }
725 816 } catch (e) {
726 817 submit.disabled = false;
727 818 result.className = "result bad";
728   - result.textContent = "提交失败:" + e.message;
  819 + result.textContent = "保存失败:" + e.message;
729 820 }
730 821 loadConvs();
731 822 };
... ...
src/test/java/com/xly/service/EventProjectionTest.java
... ... @@ -241,6 +241,36 @@ class EventProjectionTest {
241 241 }
242 242  
243 243 @Test
  244 + void previewCardPinsAndQueuedShowsReadOnlyProgress() {
  245 + InMemoryLedger log = new InMemoryLedger();
  246 + EventLogChatMemory mem = memory(log);
  247 + log.append(CONV, "user", Map.of("text", "把必胜客电话改成13800138000"));
  248 + log.append(CONV, "skill_active", Map.of("name", "修改记录", "text", "【技能:修改记录】…"));
  249 + mem.add(toolCall("t1", "previewChange", "{\"action\":\"update\"}"));
  250 + mem.add(ToolExecutionResultMessage.from("t1", "previewChange",
  251 + "{\"type\":\"change_preview\",\"previewId\":\"pv1\",\"summary\":\"将【必胜客】的【联系电话】改为「13800138000」\"}"));
  252 +
  253 + String card = proj.activeCard(log.events(CONV));
  254 + assertTrue(card.contains("预览卡") && card.contains("尚未写入"), "预览卡钉住且明示未写入");
  255 + assertTrue(proj.historyView(log.events(CONV)).stream()
  256 + .anyMatch(h -> h.get("content").startsWith("【预览】")), "预览从工具结果同源进历史");
  257 +
  258 + // 用户点【保存】→ queued:技能线摘下,待办行只读展示 sStatus
  259 + proj.setOpStatusLookup(opId -> "op1".equals(opId) ? "已提交,等待 ERP 处理" : null);
  260 + log.append(CONV, "queued", Map.of("opIds", java.util.List.of("op1"),
  261 + "description", "将【必胜客】的【联系电话】改为「13800138000」"));
  262 + card = proj.activeCard(log.events(CONV));
  263 + assertFalse(card.contains("【技能:修改记录】"), "queued 后技能线摘下(AI 侧流程到写入队列为止)");
  264 + assertTrue(card.contains("已提交待办") && card.contains("等待 ERP 处理"), "待办行附 sStatus 只读进度");
  265 + assertTrue(proj.historyView(log.events(CONV)).stream()
  266 + .anyMatch(h -> h.get("content").startsWith("【已提交待办】")));
  267 + assertTrue(proj.project("SYS", log.events(CONV)).stream()
  268 + .anyMatch(m -> m instanceof AiMessage a && a.text() != null && a.text().contains("绝不声称已执行成功")),
  269 + "LLM 正文里 queued 事件明示执行权在 ERP");
  270 + proj.setOpStatusLookup(null);
  271 + }
  272 +
  273 + @Test
244 274 void skillCardPinsFullTextUntilDone() {
245 275 InMemoryLedger log = new InMemoryLedger();
246 276 log.append(CONV, "user", Map.of("text", "报价纸盒"));
... ...