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,7 +27,8 @@ MAX_STEPS = 8
27 # ---------------- 銝 bench50_v2 漣憭 prompt / 撌亙摰 ---------------- 27 # ---------------- 銝 bench50_v2 漣憭 prompt / 撌亙摰 ----------------
28 import pathlib # noqa: E402 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 ROOT = pathlib.Path(__file__).resolve().parent.parent 33 ROOT = pathlib.Path(__file__).resolve().parent.parent
33 SKILLS_DIR = ROOT / "src/main/resources/skills" 34 SKILLS_DIR = ROOT / "src/main/resources/skills"
@@ -35,10 +36,32 @@ SKILLS_DIR = ROOT / "src/main/resources/skills" @@ -35,10 +36,32 @@ SKILLS_DIR = ROOT / "src/main/resources/skills"
35 T_USESKILL = fn("useSkill", 36 T_USESKILL = fn("useSkill",
36 "頧賢銝憿寞嚗瘚郊撉方秩甇仿炊閬 撱/隞/隞/靽格/雿/摰⊥ 蝑" 37 "頧賢銝憿寞嚗瘚郊撉方秩甇仿炊閬 撱/隞/隞/靽格/雿/摰⊥ 蝑"
37 "隡嚗&摰粥嚗摰蝸撖孵 system prompt 皜", 38 "隡嚗&摰粥嚗摰蝸撖孵 system prompt 皜",
38 - {"skillName": {"type": "string",  
39 - "description": " 撱箸隞 / 靽格霈啣 / / 撱箏 / 霂X"}}, 39 + {"skillName": {"type": "string", "description": " system prompt 皜"}},
40 ["skillName"]) 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 def load_skills(): 66 def load_skills():
44 """{妍: (銝霂, 甇)} 銝漣 SkillService 撘=活銵==甇""" 67 """{妍: (銝霂, 甇)} 銝漣 SkillService 撘=活銵==甇"""
@@ -51,7 +74,8 @@ def load_skills(): @@ -51,7 +74,8 @@ def load_skills():
51 74
52 75
53 def build_context(arch): 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 if arch == "old": 79 if arch == "old":
56 return UNIFIED_PROMPT, ALL6 80 return UNIFIED_PROMPT, ALL6
57 if arch == "new": 81 if arch == "new":
@@ -59,7 +83,7 @@ def build_context(arch): @@ -59,7 +83,7 @@ def build_context(arch):
59 skills = load_skills() 83 skills = load_skills()
60 index = "".join(f"- {name}嚗brief}\n" for name, (brief, _) in skills.items()) 84 index = "".join(f"- {name}嚗brief}\n" for name, (brief, _) in skills.items())
61 sp = template.replace("{DOMAIN_MAP}", DOMAIN_MAP).replace("{SKILL_INDEX}", index) 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 raise SystemExit(f" arch: {arch}") 87 raise SystemExit(f" arch: {arch}")
64 88
65 89
@@ -149,6 +173,9 @@ def fixture_result(name, args): @@ -149,6 +173,9 @@ def fixture_result(name, args):
149 if name == "proposeWrite": 173 if name == "proposeWrite":
150 return (f"[撌脩&霈斗悅 OP-TEST-1嚗ction={args.get('action','')}嚗={args.get('entityKeyword','')}嚗" 174 return (f"[撌脩&霈斗悅 OP-TEST-1嚗ction={args.get('action','')}嚗={args.get('entityKeyword','')}嚗"
151 f"霈啣={args.get('recordKeyword','')}悅嚗銵窈&霈扎") 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 if name == "askUser": 179 if name == "askUser":
153 return "[憸歇嚗頧桀甇支蛹甇U" 180 return "[憸歇嚗頧桀甇支蛹甇U"
154 if name == "useSkill": 181 if name == "useSkill":
@@ -206,13 +233,15 @@ GUARD_NUDGE = "雿瓷隞颱極摮撮蝻 @@ -206,13 +233,15 @@ GUARD_NUDGE = "雿瓷隞颱極摮撮蝻
206 233
207 234
208 def run_trajectory(system_prompt, tools, history, utterance): 235 def run_trajectory(system_prompt, tools, history, utterance):
209 - """撣衣漣膘餈對撌亙蝑摮 瘜典蝥迤霂甈∴ AgentChatController嚗""" 236 + """撣衣漣膘餈對撌亙蝑摮 瘜典蝥迤霂甈∴ AgentChatController嚗
  237 + 嚗reviewChange嚗earch3 霂駁極嚗 proposeWrite 霂凋 proposeWrite """
210 calls, text, steps, err = run_trajectory_once(system_prompt, tools, history, utterance) 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 CREATE TABLE IF NOT EXISTS ai_op_queue ( 9 CREATE TABLE IF NOT EXISTS ai_op_queue (
7 sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token 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 sConversationId varchar(96), 13 sConversationId varchar(96),
10 sBrandsId varchar(32), 14 sBrandsId varchar(32),
11 sSubsidiaryId varchar(32), 15 sSubsidiaryId varchar(32),
sql/ai_skill_seed.sql
1 -- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。 1 -- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。
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(); 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,21 +6,22 @@ import com.xly.agent.EventLogChatMemory;
6 import com.xly.agent.ReActAgent; 6 import com.xly.agent.ReActAgent;
7 import com.xly.service.ErpClient; 7 import com.xly.service.ErpClient;
8 import com.xly.service.EventProjectionService; 8 import com.xly.service.EventProjectionService;
  9 +import com.xly.service.FormRenderService;
9 import com.xly.service.FormResolverService; 10 import com.xly.service.FormResolverService;
10 import com.xly.service.LedgerService; 11 import com.xly.service.LedgerService;
11 import com.xly.service.OpService; 12 import com.xly.service.OpService;
  13 +import com.xly.service.PreviewService;
12 import com.xly.service.SkillService; 14 import com.xly.service.SkillService;
13 import com.xly.service.SystemPromptService; 15 import com.xly.service.SystemPromptService;
14 import com.xly.tool.ErpReadTool; 16 import com.xly.tool.ErpReadTool;
15 import com.xly.tool.FormCollectTool; 17 import com.xly.tool.FormCollectTool;
16 import com.xly.tool.InteractionTool; 18 import com.xly.tool.InteractionTool;
17 import com.xly.tool.KgQueryTool; 19 import com.xly.tool.KgQueryTool;
18 -import com.xly.tool.ProposeWriteTool; 20 +import com.xly.tool.PreviewChangeTool;
19 import com.xly.tool.UseSkillTool; 21 import com.xly.tool.UseSkillTool;
20 import dev.langchain4j.model.chat.StreamingChatModel; 22 import dev.langchain4j.model.chat.StreamingChatModel;
21 import dev.langchain4j.service.AiServices; 23 import dev.langchain4j.service.AiServices;
22 import org.springframework.beans.factory.annotation.Qualifier; 24 import org.springframework.beans.factory.annotation.Qualifier;
23 -import org.springframework.jdbc.core.JdbcTemplate;  
24 import org.springframework.stereotype.Component; 25 import org.springframework.stereotype.Component;
25 26
26 /** 27 /**
@@ -28,10 +29,11 @@ import org.springframework.stereotype.Component; @@ -28,10 +29,11 @@ import org.springframework.stereotype.Component;
28 * 29 *
29 * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程, 30 * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程,
30 * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的 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 * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。 37 * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。
36 */ 38 */
37 @Component 39 @Component
@@ -43,9 +45,9 @@ public class AgentFactory { @@ -43,9 +45,9 @@ public class AgentFactory {
43 private final SystemPromptService systemPromptService; 45 private final SystemPromptService systemPromptService;
44 46
45 private final ErpClient erp; 47 private final ErpClient erp;
46 - private final JdbcTemplate jdbc;  
47 private final FormResolverService resolver; 48 private final FormResolverService resolver;
48 - private final OpService ops; 49 + private final FormRenderService render;
  50 + private final PreviewService previews;
49 private final ObjectMapper mapper; 51 private final ObjectMapper mapper;
50 52
51 private final KgQueryTool kgQueryTool; 53 private final KgQueryTool kgQueryTool;
@@ -55,7 +57,8 @@ public class AgentFactory { @@ -55,7 +57,8 @@ public class AgentFactory {
55 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, 57 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
56 LedgerService ledger, EventProjectionService projection, 58 LedgerService ledger, EventProjectionService projection,
57 SystemPromptService systemPromptService, 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 ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool, 62 ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool,
60 SkillService skillService) { 63 SkillService skillService) {
61 this.streamingModel = streamingModel; 64 this.streamingModel = streamingModel;
@@ -63,20 +66,22 @@ public class AgentFactory { @@ -63,20 +66,22 @@ public class AgentFactory {
63 this.projection = projection; 66 this.projection = projection;
64 this.systemPromptService = systemPromptService; 67 this.systemPromptService = systemPromptService;
65 this.erp = erp; 68 this.erp = erp;
66 - this.jdbc = jdbc;  
67 this.resolver = resolver; 69 this.resolver = resolver;
68 - this.ops = ops; 70 + this.render = render;
  71 + this.previews = previews;
69 this.mapper = mapper; 72 this.mapper = mapper;
70 this.kgQueryTool = kgQueryTool; 73 this.kgQueryTool = kgQueryTool;
71 this.interactionTool = interactionTool; 74 this.interactionTool = interactionTool;
72 this.skillService = skillService; 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 * {@code maxSequentialToolsInvocations} 作为循环护栏,杜绝失控 ReAct 循环。 82 * {@code maxSequentialToolsInvocations} 作为循环护栏,杜绝失控 ReAct 循环。
78 * 83 *
79 - * @param convId 会话 id(useSkill 落 skill_active 事件需要) 84 + * @param convId 会话 id(useSkill/previewChange 落事件需要)
80 * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记, 85 * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记,
81 * 前端历史不显示 86 * 前端历史不显示
82 */ 87 */
@@ -84,8 +89,8 @@ public class AgentFactory { @@ -84,8 +89,8 @@ public class AgentFactory {
84 Object[] tools = new Object[]{kgQueryTool, interactionTool, 89 Object[] tools = new Object[]{kgQueryTool, interactionTool,
85 new UseSkillTool(skillService, ledger, convId), 90 new UseSkillTool(skillService, ledger, convId),
86 new ErpReadTool(erp, resolver, identity), 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 return AiServices.builder(ReActAgent.class) 95 return AiServices.builder(ReActAgent.class)
91 .streamingChatModel(streamingModel) 96 .streamingChatModel(streamingModel)
@@ -97,9 +102,4 @@ public class AgentFactory { @@ -97,9 +102,4 @@ public class AgentFactory {
97 .systemMessageProvider(memoryId -> systemPromptService.prompt()) 102 .systemMessageProvider(memoryId -> systemPromptService.prompt())
98 .build(); 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,18 +19,19 @@ import java.util.List;
19 import java.util.Map; 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 * <ul> 25 * <ul>
26 * <li><b>token 解析(架构 §7)</b>:每次调用优先用调用方**透传的用户 ERP 会话 token** 26 * <li><b>token 解析(架构 §7)</b>:每次调用优先用调用方**透传的用户 ERP 会话 token**
27 * (per-request、不长期存、绝不进 prompt);只有在完全没有用户 token 时才退回配置的 dev 账号 27 * (per-request、不长期存、绝不进 prompt);只有在完全没有用户 token 时才退回配置的 dev 账号
28 * {@code /checklogin} 登录并缓存。<b>用户 token 过期不会用 dev 账号重登</b>(见 {@code canRelogin}), 28 * {@code /checklogin} 登录并缓存。<b>用户 token 过期不会用 dev 账号重登</b>(见 {@code canRelogin}),
29 * 避免静默提权;只有 dev-login 自己的会话过期(code=-2)才自动重登一次重试。</li> 29 * 避免静默提权;只有 dev-login 自己的会话过期(code=-2)才自动重登一次重试。</li>
  30 + * <li><b>whoami</b>:token 服务端内省(身份唯一来源,fail-closed)。</li>
30 * <li><b>读</b>:通用表单读接口 {@code getBusinessDataByFormcustomId}(只传分页/过滤参数)。</li> 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 * </ul> 35 * </ul>
35 */ 36 */
36 @Service 37 @Service
@@ -102,7 +103,7 @@ public class ErpClient { @@ -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 * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。 107 * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。
107 */ 108 */
108 private static String safeId(String id) { 109 private static String safeId(String id) {
@@ -197,166 +198,7 @@ public class ErpClient { @@ -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 public String newUuid(String authToken) { 202 public String newUuid(String authToken) {
361 try { 203 try {
362 HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) 204 HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid"))
@@ -373,109 +215,51 @@ public class ErpClient { @@ -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 try { 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 List<Map<String, Object>> data = new ArrayList<>(); 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 Map<String, Object> body = new LinkedHashMap<>(); 242 Map<String, Object> body = new LinkedHashMap<>();
417 body.put("sModelsId", moduleId); 243 body.put("sModelsId", moduleId);
418 body.put("data", data); 244 body.put("data", data);
419 HttpRequest req = HttpRequest.newBuilder(URI.create(url)) 245 HttpRequest req = HttpRequest.newBuilder(URI.create(url))
420 .header("Content-Type", "application/json;charset=UTF-8") 246 .header("Content-Type", "application/json;charset=UTF-8")
421 - .header("Authorization", tok) 247 + .header("Authorization", resolveToken(authToken))
422 .timeout(Duration.ofSeconds(40)) 248 .timeout(Duration.ofSeconds(40))
423 .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) 249 .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8))
424 .build(); 250 .build();
425 HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); 251 HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
426 return mapper.readTree(resp.body()); 252 return mapper.readTree(resp.body());
427 } catch (Exception e) { 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 package com.xly.service; 1 package com.xly.service;
2 2
  3 +import com.xly.agent.AgentIdentity;
3 import org.springframework.jdbc.core.JdbcTemplate; 4 import org.springframework.jdbc.core.JdbcTemplate;
4 import org.springframework.stereotype.Service; 5 import org.springframework.stereotype.Service;
5 6
@@ -7,80 +8,92 @@ import java.util.List; @@ -7,80 +8,92 @@ import java.util.List;
7 import java.util.Map; 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 @Service 21 @Service
13 public class OpService { 22 public class OpService {
14 23
15 private final JdbcTemplate jdbc; 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 this.jdbc = jdbc; 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 String description) { 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 String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); 55 String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
42 jdbc.update( 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 return sId; 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 public Map<String, Object> get(String sId) { 67 public Map<String, Object> get(String sId) {
56 List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId); 68 List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId);
57 return r.isEmpty() ? null : r.get(0); 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,10 +2,10 @@ package com.xly.tool;
2 2
3 import com.fasterxml.jackson.databind.ObjectMapper; 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import com.xly.agent.AgentIdentity; 4 import com.xly.agent.AgentIdentity;
  5 +import com.xly.service.FormRenderService;
5 import com.xly.service.FormResolverService; 6 import com.xly.service.FormResolverService;
6 import dev.langchain4j.agent.tool.P; 7 import dev.langchain4j.agent.tool.P;
7 import dev.langchain4j.agent.tool.Tool; 8 import dev.langchain4j.agent.tool.Tool;
8 -import org.springframework.jdbc.core.JdbcTemplate;  
9 9
10 import java.util.ArrayList; 10 import java.util.ArrayList;
11 import java.util.HashMap; 11 import java.util.HashMap;
@@ -17,24 +17,20 @@ import java.util.regex.Matcher; @@ -17,24 +17,20 @@ import java.util.regex.Matcher;
17 import java.util.regex.Pattern; 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 * <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推 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 * <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。 30 * <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。
33 */ 31 */
34 public class FormCollectTool { 32 public class FormCollectTool {
35 33
36 - private static final int MAX_FIELDS = 40;  
37 -  
38 // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理) 34 // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理)
39 // 标注式:"长和宽50"、"高5cm"、"长:50" 35 // 标注式:"长和宽50"、"高5cm"、"长:50"
40 private static final Pattern DIM_LABELED = 36 private static final Pattern DIM_LABELED =
@@ -43,14 +39,14 @@ public class FormCollectTool { @@ -43,14 +39,14 @@ public class FormCollectTool {
43 private static final Pattern DIM_POS = 39 private static final Pattern DIM_POS =
44 Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?"); 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 private final FormResolverService resolver; 43 private final FormResolverService resolver;
48 private final AgentIdentity identity; 44 private final AgentIdentity identity;
49 private final ObjectMapper mapper; 45 private final ObjectMapper mapper;
50 46
51 - public FormCollectTool(JdbcTemplate jdbc, FormResolverService resolver, 47 + public FormCollectTool(FormRenderService render, FormResolverService resolver,
52 AgentIdentity identity, ObjectMapper mapper) { 48 AgentIdentity identity, ObjectMapper mapper) {
53 - this.jdbc = jdbc; 49 + this.render = render;
54 this.resolver = resolver; 50 this.resolver = resolver;
55 this.identity = identity; 51 this.identity = identity;
56 this.mapper = mapper; 52 this.mapper = mapper;
@@ -59,7 +55,7 @@ public class FormCollectTool { @@ -59,7 +55,7 @@ public class FormCollectTool {
59 @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景" 55 @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
60 + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填" 56 + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填"
61 + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。" 57 + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。"
62 - + "用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。") 58 + + "用户填完点【保存】后,系统自动校验并提交待办——你**无需**再做任何写操作,也绝不能说已保存。")
63 public String collectForm( 59 public String collectForm(
64 @P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword, 60 @P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword,
65 @P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}", 61 @P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}",
@@ -80,76 +76,7 @@ public class FormCollectTool { @@ -80,76 +76,7 @@ public class FormCollectTool {
80 } 76 }
81 77
82 String table = String.valueOf(form.get("sDataSource")); 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 if (fields.isEmpty()) { 80 if (fields.isEmpty()) {
154 return err("该表单没有可填写的业务字段。"); 81 return err("该表单没有可填写的业务字段。");
155 } 82 }
@@ -162,70 +89,10 @@ public class FormCollectTool { @@ -162,70 +89,10 @@ public class FormCollectTool {
162 out.put("moduleId", moduleId); 89 out.put("moduleId", moduleId);
163 out.put("title", "新建" + entityKeyword.trim()); 90 out.put("title", "新建" + entityKeyword.trim());
164 out.put("fields", fields); 91 out.put("fields", fields);
165 - out.put("message", "请在下方表单里填写,填完点【提交】。"); 92 + out.put("message", "请在下方表单里填写,填完点【保存】。");
166 return toJson(out); 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 /** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */ 96 /** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */
230 private Map<String, String> parseKnown(String json) { 97 private Map<String, String> parseKnown(String json) {
231 Map<String, String> m = new HashMap<>(); 98 Map<String, String> m = new HashMap<>();
@@ -248,10 +115,7 @@ public class FormCollectTool { @@ -248,10 +115,7 @@ public class FormCollectTool {
248 } 115 }
249 116
250 private static String normLabel(String s) { 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,7 +8,7 @@ import com.xly.config.AgentFactory;
8 import com.xly.service.AuthzService; 8 import com.xly.service.AuthzService;
9 import com.xly.service.ConversationService; 9 import com.xly.service.ConversationService;
10 import com.xly.service.LedgerService; 10 import com.xly.service.LedgerService;
11 -import com.xly.service.OpService; 11 +import com.xly.service.PreviewService;
12 import dev.langchain4j.service.TokenStream; 12 import dev.langchain4j.service.TokenStream;
13 import dev.langchain4j.service.tool.ToolExecution; 13 import dev.langchain4j.service.tool.ToolExecution;
14 import org.slf4j.Logger; 14 import org.slf4j.Logger;
@@ -35,41 +35,43 @@ import java.util.regex.Pattern; @@ -35,41 +35,43 @@ import java.util.regex.Pattern;
35 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 35 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
36 * 36 *
37 * <p><b>编排</b>:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent 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 * <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件, 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 @RestController 50 @RestController
50 @RequestMapping("/api/agent") 51 @RequestMapping("/api/agent")
51 public class AgentChatController { 52 public class AgentChatController {
52 53
53 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); 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 private final AgentFactory agentFactory; 60 private final AgentFactory agentFactory;
59 private final AuthzService authz; 61 private final AuthzService authz;
60 private final ObjectMapper mapper; 62 private final ObjectMapper mapper;
61 private final ConversationService conversations; 63 private final ConversationService conversations;
62 - private final OpService ops; 64 + private final PreviewService previews;
63 private final LedgerService ledger; 65 private final LedgerService ledger;
64 private final ExecutorService exec = Executors.newCachedThreadPool(); 66 private final ExecutorService exec = Executors.newCachedThreadPool();
65 67
66 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, 68 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
67 - ConversationService conversations, OpService ops, LedgerService ledger) { 69 + ConversationService conversations, PreviewService previews, LedgerService ledger) {
68 this.agentFactory = agentFactory; 70 this.agentFactory = agentFactory;
69 this.authz = authz; 71 this.authz = authz;
70 this.mapper = mapper; 72 this.mapper = mapper;
71 this.conversations = conversations; 73 this.conversations = conversations;
72 - this.ops = ops; 74 + this.previews = previews;
73 this.ledger = ledger; 75 this.ledger = ledger;
74 } 76 }
75 77
@@ -117,8 +119,9 @@ public class AgentChatController { @@ -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 @PostMapping("/form/submit") 126 @PostMapping("/form/submit")
124 public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req, 127 public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req,
@@ -145,43 +148,26 @@ public class AgentChatController { @@ -145,43 +148,26 @@ public class AgentChatController {
145 } 148 }
146 }); 149 });
147 conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单"); 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 try { 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 } catch (Exception e) { 159 } catch (Exception e) {
  160 + log.warn("form submit failed (conv={})", convId, e);
175 out.put("error", "服务异常:" + e.getMessage()); 161 out.put("error", "服务异常:" + e.getMessage());
  162 + return out;
176 } 163 }
177 - return out;  
178 } 164 }
179 165
180 /** 166 /**
181 * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对 167 * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
182 * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术 168 * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
183 - * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没  
184 - * proposeWrite → 附纠正提示。 169 + * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已保存/已提交但本轮
  170 + * 没出过预览卡/表单 → 附纠正提示。
185 */ 171 */
186 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, 172 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
187 String text, boolean allowRetry) { 173 String text, boolean allowRetry) {
@@ -189,12 +175,12 @@ public class AgentChatController { @@ -189,12 +175,12 @@ public class AgentChatController {
189 // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示 175 // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
190 ReActAgent agent = agentFactory.build(identity, convId, !allowRetry); 176 ReActAgent agent = agentFactory.build(identity, convId, !allowRetry);
191 AtomicInteger toolCalls = new AtomicInteger(); 177 AtomicInteger toolCalls = new AtomicInteger();
192 - AtomicBoolean proposed = new AtomicBoolean(false); 178 + AtomicBoolean carded = new AtomicBoolean(false);
193 TokenStream ts = agent.chat(convId, text); 179 TokenStream ts = agent.chat(convId, text);
194 ts.onPartialResponse(token -> send(emitter, "token", token)) 180 ts.onPartialResponse(token -> send(emitter, "token", token))
195 .onToolExecuted(te -> { 181 .onToolExecuted(te -> {
196 toolCalls.incrementAndGet(); 182 toolCalls.incrementAndGet();
197 - handleToolExecuted(emitter, convId, te, proposed); 183 + handleToolExecuted(emitter, te, carded);
198 }) 184 })
199 .onCompleteResponse(resp -> { 185 .onCompleteResponse(resp -> {
200 String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null 186 String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null
@@ -210,8 +196,8 @@ public class AgentChatController { @@ -210,8 +196,8 @@ public class AgentChatController {
210 } 196 }
211 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); 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 // 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写 202 // 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写
217 send(emitter, "done", ""); 203 send(emitter, "done", "");
@@ -248,33 +234,23 @@ public class AgentChatController { @@ -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 send(emitter, "reset", ""); 242 send(emitter, "reset", "");
256 try { 243 try {
257 String toolName = te.request() == null ? "" : te.request().name(); 244 String toolName = te.request() == null ? "" : te.request().name();
258 - if (te.result() == null) { 245 + if (te.result() == null || !CARD_TOOLS.contains(toolName)) {
259 return; 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 } catch (Exception e) { 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,10 +33,8 @@ erp:
33 subsidiary: "1111111111" 33 subsidiary: "1111111111"
34 username: admin 34 username: admin
35 password: "666666" 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 enabled: false 38 enabled: false
41 39
42 # LLM 可观测(Langfuse,架构 §1)。默认关闭;自托管起来后填 key 开启: 40 # LLM 可观测(Langfuse,架构 §1)。默认关闭;自托管起来后填 key 开启:
src/main/resources/prompts/system.txt
@@ -7,19 +7,19 @@ @@ -7,19 +7,19 @@
7 【技能清单(业务流程的权威步骤,用 useSkill 载入全文)】 7 【技能清单(业务流程的权威步骤,用 useSkill 载入全文)】
8 {SKILL_INDEX}凡要 新建/报价/问价/修改/作废/审核 等会改动数据的业务,或不确定流程怎么走:**先 useSkill 载入对应技能**,再严格按技能步骤执行。简单查询可以直接查。 8 {SKILL_INDEX}凡要 新建/报价/问价/修改/作废/审核 等会改动数据的业务,或不确定流程怎么走:**先 useSkill 载入对应技能**,再严格按技能步骤执行。简单查询可以直接查。
9 9
10 -【可用工具(只有这些)】 10 +【可用工具(只有这些,全部只读/只渲染——你没有任何直接写入的能力)】
11 - useSkill(skillName):载入上面某项技能的完整步骤文本。 11 - useSkill(skillName):载入上面某项技能的完整步骤文本。
12 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 12 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
13 - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。 13 - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。
14 - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 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 - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。 17 - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。
18 18
19 【硬规则】 19 【硬规则】
20 1. 绝不编造:答案里的每个数字、单号、表单名都必须来自工具结果;没查到就如实说没查到。 20 1. 绝不编造:答案里的每个数字、单号、表单名都必须来自工具结果;没查到就如实说没查到。
21 -2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**;提议卡出现就停下,请用户点【确认】 21 +2. 写操作一律人在环:你只能弹表单(collectForm)或出预览卡(previewChange),**绝不声称已保存/已提交/已完成**——写入只发生在用户点卡上按钮之后,且由 ERP 决定何时执行。卡片出现就停下,请用户核对后点按钮
22 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,出钱的公司名才是**客户**。引用的客户/产品/物料必须是系统里已有的真实记录——找不到就让用户核对或从下拉里选,**绝不代建**。 22 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,出钱的公司名才是**客户**。引用的客户/产品/物料必须是系统里已有的真实记录——找不到就让用户核对或从下拉里选,**绝不代建**。
23 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答。回答面向业务人员:用记录名称而不是内部 ID。 23 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答。回答面向业务人员:用记录名称而不是内部 ID。
24 24
25 -若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来。 25 +若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来;「已提交待办」的操作不要重复提交。
src/main/resources/skills/quote-create.md
@@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
3 【技能:新建报价】 3 【技能:新建报价】
4 问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。 4 问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。
5 1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。 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 +4,4 @@
4 1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。 4 1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。
5 2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。 5 2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。
6 3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。 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 - 删除/取消/不要了 → invalid(作废,可复原) 5 - 删除/取消/不要了 → invalid(作废,可复原)
6 - 复原/恢复/取消作废 → cancelInvalid 6 - 复原/恢复/取消作废 → cancelInvalid
7 - 审核/审核通过 → examine 7 - 审核/审核通过 → examine
8 - 反审核/销审/撤回审核 → cancelExamine 8 - 反审核/销审/撤回审核 → cancelExamine
9 - 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复 9 - 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复
10 1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。 10 1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。
11 -2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。  
12 -3. 工具提示多条匹配:念候选让用户选后再调一次。  
13 -4. 提议卡出现即停,请用户点【确认】;绝不说已完成。 11 +2. previewChange(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。该工具只生成预览卡,不写入。
  12 +3. 工具提示多条匹配:念候选让用户选后再调一次;提示状态不合法(如已审核过):如实转告用户,停下。
  13 +4. 预览卡出现即停:请用户核对单据与状态变化后点卡上按钮(审核/作废/…);绝不说已完成。
14 消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。 14 消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。
src/main/resources/skills/record-update.md
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段 2 改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段
3 【技能:修改记录】 3 【技能:修改记录】
4 1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。 4 1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。
5 -2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话 5 +2. 齐了就 previewChange(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。该工具只生成预览卡,不写入
6 3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。 6 3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。
7 -4. 提议卡出现即停,请用户点【确认】;绝不说已完成。 7 +4. 预览卡出现即停:请用户核对整张表单(改动已高亮,可在卡上继续修正)后点【保存】;绝不说已保存/已完成。
8 注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。 8 注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。
src/main/resources/templates/chat.html
@@ -187,6 +187,22 @@ body { @@ -187,6 +187,22 @@ body {
187 .ffield .fkrow input { flex: 1; background: var(--bg); } 187 .ffield .fkrow input { flex: 1; background: var(--bg); }
188 .ffield .hint { font-size: 12px; color: var(--text2); margin-top: 2px; } 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 #inputbar { background: var(--bg); padding: 8px 20px 16px; } 207 #inputbar { background: var(--bg); padding: 8px 20px 16px; }
192 #inputbox { 208 #inputbox {
@@ -527,8 +543,8 @@ function handleEvent(ev, bubble, buf) { @@ -527,8 +543,8 @@ function handleEvent(ev, bubble, buf) {
527 bubble.classList.add("thinking"); 543 bubble.classList.add("thinking");
528 bubble.textContent = "…"; 544 bubble.textContent = "…";
529 return ""; 545 return "";
530 - case "write_proposal":  
531 - addProposalCard(ev.opId, ev.summary); 546 + case "change_preview":
  547 + addPreviewCard(ev);
532 return buf; 548 return buf;
533 case "question": 549 case "question":
534 addQuestionCard(ev.question, ev.options || [], ev.allowFree !== false); 550 addQuestionCard(ev.question, ev.options || [], ev.allowFree !== false);
@@ -545,14 +561,85 @@ function handleEvent(ev, bubble, buf) { @@ -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 const card = el("div", "card"); 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 const actions = el("div", "actions"); 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 actions.appendChild(ok); 643 actions.appendChild(ok);
557 actions.appendChild(no); 644 actions.appendChild(no);
558 card.appendChild(actions); 645 card.appendChild(actions);
@@ -561,30 +648,35 @@ function addProposalCard(opId, summary) { @@ -561,30 +648,35 @@ function addProposalCard(opId, summary) {
561 messagesEl.appendChild(card); 648 messagesEl.appendChild(card);
562 scrollBottom(); 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 ok.onclick = async () => { 651 ok.onclick = async () => {
571 ok.disabled = no.disabled = true; 652 ok.disabled = no.disabled = true;
  653 + result.className = "result";
  654 + result.textContent = "提交中…";
572 try { 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 const d = await r.json(); 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 } catch (e) { 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 ok.disabled = no.disabled = true; 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,7 +766,7 @@ function addFormCard(ev) {
674 card.appendChild(grid); 766 card.appendChild(grid);
675 767
676 const actions = el("div", "actions"); 768 const actions = el("div", "actions");
677 - const submit = el("button", "btn primary", "提交"); 769 + const submit = el("button", "btn primary", "保存");
678 const result = el("div", "result"); 770 const result = el("div", "result");
679 actions.appendChild(submit); 771 actions.appendChild(submit);
680 card.appendChild(actions); 772 card.appendChild(actions);
@@ -700,9 +792,9 @@ function addFormCard(ev) { @@ -700,9 +792,9 @@ function addFormCard(ev) {
700 } 792 }
701 submit.disabled = true; 793 submit.disabled = true;
702 result.className = "result"; 794 result.className = "result";
703 - result.textContent = "提交中…"; 795 + result.textContent = "保存中…";
704 try { 796 try {
705 - // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码 797 + // 确定性保存:结构化字段直达 create 校验并写 ai_op_queue(不经 LLM 再编码);执行由 ERP 侧负责
706 const r = await fetch(BASE + "/api/agent/form/submit", { 798 const r = await fetch(BASE + "/api/agent/form/submit", {
707 method: "POST", 799 method: "POST",
708 headers: authHeaders(true), 800 headers: authHeaders(true),
@@ -713,19 +805,18 @@ function addFormCard(ev) { @@ -713,19 +805,18 @@ function addFormCard(ev) {
713 }) 805 })
714 }); 806 });
715 const d = await r.json(); 807 const d = await r.json();
716 - if (d.opId) { 808 + if (d.queued) {
717 result.className = "result ok"; 809 result.className = "result ok";
718 - result.textContent = "已生成待确认提议。";  
719 - addProposalCard(d.opId, d.summary); 810 + result.textContent = d.message || "已提交待办。";
720 } else { 811 } else {
721 submit.disabled = false; 812 submit.disabled = false;
722 result.className = "result bad"; 813 result.className = "result bad";
723 - result.textContent = d.error || "提交失败。"; 814 + result.textContent = d.error || "保存失败。";
724 } 815 }
725 } catch (e) { 816 } catch (e) {
726 submit.disabled = false; 817 submit.disabled = false;
727 result.className = "result bad"; 818 result.className = "result bad";
728 - result.textContent = "提交失败:" + e.message; 819 + result.textContent = "保存失败:" + e.message;
729 } 820 }
730 loadConvs(); 821 loadConvs();
731 }; 822 };
src/test/java/com/xly/service/EventProjectionTest.java
@@ -241,6 +241,36 @@ class EventProjectionTest { @@ -241,6 +241,36 @@ class EventProjectionTest {
241 } 241 }
242 242
243 @Test 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 void skillCardPinsFullTextUntilDone() { 274 void skillCardPinsFullTextUntilDone() {
245 InMemoryLedger log = new InMemoryLedger(); 275 InMemoryLedger log = new InMemoryLedger();
246 log.append(CONV, "user", Map.of("text", "报价纸盒")); 276 log.append(CONV, "user", Map.of("text", "报价纸盒"));