Commit 541e0041948cbf6c7e3c52afa681d052a9fc8a77
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
Showing
23 changed files
with
1824 additions
and
1570 deletions
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", "报价纸盒")); |