using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; namespace 成套报价软件 { /// /// AI助手本地接口(第二阶段:AI×选型页断路器选型替换)。 /// HttpListener 监听 127.0.0.1:18790(占用则顺延,记 shezhi\AI助手.config.xml 的 接口端口)。 /// 鉴权:查询参数 token == OpenClawHost.令牌(与网关同源,仅本机可用)。 /// 接口: /// GET /api/xuanxing?token=… → 当前项目选型页元件清单 JSON(含分类字段 fenlei:"塑壳"|"微型"|null) /// POST /api/replace?token=… → 替换元件(body=JSON:旧名称/旧规格定位,新值 SET) /// ★★铁律:UPDATE 的 SET 列表绝不包含"数量"列;派生价按库里原数量 CAST(数量 AS REAL) 计算; /// 替换前强制校验目标行分类 ∈ {塑壳, 微型}(分类断路器()),其他类型/识别不到的一律拒绝。 /// 替换后刷新选型页与报价页(UI 线程 Invoke)。 /// public partial class MainForm { private System.Net.HttpListener _aiHttp; private Thread _aiHttpThread; private int _aiHttpPort = 18790; // ---------- 生命周期 ---------- /// 启动本地接口(MainForm.Load 挂调;后台线程,失败仅记日志不弹窗)开。summary 标签见上。 internal void 启动AI助手接口() { try { _aiHttpPort = 读取接口端口(); _aiHttp = new System.Net.HttpListener(); bool 绑定成功 = false; for (int p = _aiHttpPort; p < _aiHttpPort + 10; p++) { try { _aiHttp.Prefixes.Clear(); _aiHttp.Prefixes.Add("http://127.0.0.1:" + p + "/"); _aiHttp.Start(); _aiHttpPort = p; 绑定成功 = true; break; } catch { } } if (!绑定成功) { OpenClawHost.写日志("[AI接口] 端口 " + _aiHttpPort + " 起连续 10 个绑定失败,接口未启动"); return; } _aiHttpThread = new Thread(AI接口轮询); _aiHttpThread.IsBackground = true; _aiHttpThread.Start(); OpenClawHost.写日志("[AI接口] 已启动 http://127.0.0.1:" + _aiHttpPort + "/"); } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 启动失败: " + ex.Message); } } /// 停止本地接口(FormClosing 挂调)。 internal void 停止AI助手接口() { try { if (_aiHttp != null) { _aiHttp.Stop(); _aiHttp.Close(); _aiHttp = null; } } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 停止接口失败: " + ex.Message); } } private int 读取接口端口() { try { string f = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "shezhi", "AI助手.config.xml"); if (File.Exists(f)) { string xml = File.ReadAllText(f, Encoding.UTF8); string 开 = "<接口端口>", 关 = ""; int i = xml.IndexOf(开, StringComparison.Ordinal); if (i >= 0) { int j = xml.IndexOf(关, i + 开.Length, StringComparison.Ordinal); if (j > i) { int p; if (int.TryParse(xml.Substring(i + 开.Length, j - i - 开.Length).Trim(), out p) && p >= 1024 && p <= 65535) return p; } } } } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 读端口配置失败,用默认18790: " + ex.Message); } return 18790; } // ---------- 请求循环 ---------- private void AI接口轮询() { var listener = _aiHttp; while (listener != null) { HttpListenerContext ctx; try { ctx = listener.GetContext(); } catch { break; } // Stop() 后退出 try { string path = ctx.Request.Url.AbsolutePath.TrimEnd('/'); string token = ctx.Request.QueryString["token"] ?? ""; if (!string.Equals(token, OpenClawHost.令牌, StringComparison.Ordinal)) { 回文(ctx, 403, "{\"ok\":false,\"error\":\"令牌无效\"}"); continue; } if (string.Equals(path, "/api/xuanxing", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)) { string body = AI接口_选型清单(); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/replace", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_替换元件(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/page", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_执行页面脚本(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/replaceall", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_网页式全量替换(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/replacebatch", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_批量替换元件(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/selectselection", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_选中选型行(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/editselection", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_编辑选型行(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/importquote", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_导入报价(bodyJson); 回文(ctx, 200, body); } else if (string.Equals(path, "/api/excelgrid", StringComparison.OrdinalIgnoreCase) && string.Equals(ctx.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { string bodyJson; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) { bodyJson = r.ReadToEnd(); } string body = AI接口_Excel网格(bodyJson); 回文(ctx, 200, body); } else { 回文(ctx, 404, "{\"ok\":false,\"error\":\"未知接口 " + path + "\"}"); } } catch (Exception ex) { try { 回文(ctx, 500, "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"); } catch { } } } } private static void 回文(HttpListenerContext ctx, int code, string body) { byte[] b = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = code; ctx.Response.ContentType = "application/json; charset=utf-8"; ctx.Response.ContentLength64 = b.Length; ctx.Response.OutputStream.Write(b, 0, b.Length); ctx.Response.OutputStream.Close(); } // ---------- GET /api/xuanxing:选型页元件清单(含分类) ---------- /// 照 LoadSelectionSummary 的查询段重新读库聚合(不碰 UI 内存表,线程安全), /// 输出 JSON:{ok, project, rows:[{mingcheng,guige,danwei,shuliang,biaojia,caigouxishu,baochuxishu,pinpai,xilie,leixing,fenlei,xianghaoshu}]}。 /// 枚举"活"元件表(2026-09-21 删除残留根治配套):只返回图号表里仍存在、且所属箱号行 /// 仍存在的元件表——删除图号/箱号历史残留的孤儿表(表索引行+物理表没清干净)不再进入 /// AI 清单/替换范围。活键判定兼容两套登记:内部ID(主窗口)与显示名(扒图 Form3 老数据)。 private static List 活元件表列表(System.Data.SQLite.SQLiteConnection conn) { var result = new List(); try { // 1) 活图号键集合(内部ID+显示名都收) var liveTh = new HashSet(StringComparer.Ordinal); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID, 图号 FROM " + TuhaoTableName + ";", conn)) using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { string id = rd.IsDBNull(0) ? "" : rd.GetString(0); string nm = rd.IsDBNull(1) ? "" : rd.GetString(1); if (!string.IsNullOrWhiteSpace(id)) liveTh.Add(id); if (!string.IsNullOrWhiteSpace(nm)) liveTh.Add(nm); } } if (liveTh.Count == 0) return result; // 2) 各图号的活箱号键集合(箱号表行仍在=活;内部ID+显示名都算活) // 只看图号键活着的箱号索引行,箱号行读其表收两键 var liveXhByTh = new Dictionary>(StringComparer.Ordinal); using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 图号, 实际表名 FROM 表索引 WHERE 类型='箱号';", conn)) using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { string tk = rd.IsDBNull(0) ? "" : rd.GetString(0); string xhTbl = rd.IsDBNull(1) ? "" : rd.GetString(1); if (string.IsNullOrWhiteSpace(tk) || !liveTh.Contains(tk) || string.IsNullOrWhiteSpace(xhTbl)) continue; var set = new HashSet(StringComparer.Ordinal); using (var cmdX = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID, 箱号 FROM \"" + xhTbl.Replace("\"", "") + "\";", conn)) using (var rdX = cmdX.ExecuteReader()) { while (rdX.Read()) { string xid = rdX.IsDBNull(0) ? "" : rdX.GetString(0); string xnm = rdX.IsDBNull(1) ? "" : rdX.GetString(1); if (!string.IsNullOrWhiteSpace(xid)) set.Add(xid); if (!string.IsNullOrWhiteSpace(xnm)) set.Add(xnm); } } liveXhByTh[tk] = set; } } // 3) 元件索引行:图号活 + 箱号活 才收(孤儿表彻底挡在 AI 之外) using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 图号, 箱号, 实际表名 FROM 表索引 WHERE 类型='元件';", conn)) using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { string tk = rd.IsDBNull(0) ? "" : rd.GetString(0); string xk = rd.IsDBNull(1) ? "" : rd.GetString(1); string tbl = rd.IsDBNull(2) ? "" : rd.GetString(2); if (string.IsNullOrWhiteSpace(tk) || string.IsNullOrWhiteSpace(tbl)) continue; if (!liveTh.Contains(tk)) continue; HashSet set; if (!liveXhByTh.TryGetValue(tk, out set) || set == null || !set.Contains(xk)) continue; if (!result.Contains(tbl)) result.Add(tbl); } } } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 收集元件表清单失败(AI端将看不到报价表): " + ex.Message); } return result; } private string AI接口_选型清单() { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"当前没有打开项目,请先在项目列表双击进入项目\"}"; string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return "{\"ok\":false,\"error\":\"项目库文件不存在\"}"; var merged = new Dictionary>(StringComparer.Ordinal); try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ 只枚举活元件表(图号/箱号仍在):删除残留的孤儿表不进 AI 清单 var tblNames = 活元件表列表(conn); foreach (string realTbl in tblNames) { string tbl = "\"" + realTbl.Replace("\"", "") + "\""; string sql = "SELECT 元件名称,规格,单位,数量,表价,采购系数,报出系数,品牌,系列,类型 FROM " + tbl + ";"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { string n = 取串(rd, "元件名称"); string s = 取串(rd, "规格"); double sl = 取数(rd, "数量"); double bj = 取数(rd, "表价"); string lx = 取串(rd, "类型"); string key = n + "\u0001" + s + "\u0001" + lx + "\u0001" + bj.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture); Dictionary row; if (!merged.TryGetValue(key, out row)) { row = new Dictionary(); row["mingcheng"] = n; row["guige"] = s; row["danwei"] = 取串(rd, "单位"); row["shuliang"] = sl; row["biaojia"] = bj; row["caigouxishu"] = 取数(rd, "采购系数"); row["baochuxishu"] = 取数(rd, "报出系数"); row["pinpai"] = 取串(rd, "品牌"); row["xilie"] = 取串(rd, "系列"); row["leixing"] = lx; row["_biaoge"] = new HashSet(); ((HashSet)row["_biaoge"]).Add(realTbl); merged[key] = row; } else { row["shuliang"] = Convert.ToDouble(row["shuliang"]) + sl; ((HashSet)row["_biaoge"]).Add(realTbl); } } } } } } catch (Exception ex) { return "{\"ok\":false,\"error\":\"读选型数据失败: " + Json转义(ex.Message) + "\"}"; } var js = new System.Web.Script.Serialization.JavaScriptSerializer(); var root = new Dictionary(); root["ok"] = true; root["project"] = _currentProjectId; var list = new List>(); foreach (var kv in merged) { var r = kv.Value; string fenlei = 分类断路器(Convert.ToString(r["mingcheng"]), Convert.ToString(r["guige"]), Convert.ToString(r["leixing"])); var out2 = new Dictionary(); out2["mingcheng"] = r["mingcheng"]; out2["guige"] = r["guige"]; out2["danwei"] = r["danwei"]; out2["shuliang"] = Math.Round(Convert.ToDouble(r["shuliang"]), 6); out2["biaojia"] = r["biaojia"]; out2["caigouxishu"] = r["caigouxishu"]; out2["baochuxishu"] = r["baochuxishu"]; out2["pinpai"] = r["pinpai"]; out2["xilie"] = r["xilie"]; out2["leixing"] = r["leixing"]; out2["fenlei"] = fenlei; // "塑壳" | "微型" | null(不许替换) out2["xianghaoshu"] = ((HashSet)r["_biaoge"]).Count; // 影响箱号数 list.Add(out2); } root["rows"] = list; root["count"] = list.Count; return js.Serialize(root); } // ---------- POST /api/replace:替换元件(数量铁律) ---------- /// body=JSON:{jiu_mingcheng, jiu_guige, xin_mingcheng, xin_guige, xin_biaojia, /// xin_caigouxishu, xin_pinpai, xin_xilie, xin_danwei?}。 /// 校验分类∈{塑壳,微型}→表索引白名单表→单事务参数化 UPDATE(SET 不含数量,派生价按原数量) /// →刷新选型页/报价页。返回 {ok,rows,fenlei}。 private string AI接口_替换元件(string bodyJson) { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"当前没有打开项目\"}"; string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return "{\"ok\":false,\"error\":\"项目库文件不存在\"}"; var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } if (p == null) return "{\"ok\":false,\"error\":\"请求体为空\"}"; string jiuMing = 取参(p, "jiu_mingcheng").Trim(); string jiuGui = 取参(p, "jiu_guige").Trim(); string xinMing = 取参(p, "xin_mingcheng").Trim(); string xinGui = 取参(p, "xin_guige").Trim(); if (!p.ContainsKey("jiu_guige")) return "{\"ok\":false,\"error\":\"缺 jiu_guige 字段(名称+规格是联合定位键,原行规格为空也要显式传空串)\"}"; if (jiuMing.Length == 0 && jiuGui.Length == 0) return "{\"ok\":false,\"error\":\"名称与规格不能同时为空(无法定位选型页行)\"}"; if (xinMing.Length == 0 && xinGui.Length == 0) return "{\"ok\":false,\"error\":\"缺少新值(xin_mingcheng/xin_guige 至少一项)\"}"; if (取参(p, "xin_shuliang").Length > 0) return "{\"ok\":false,\"error\":\"★禁止传 xin_shuliang:数量列绝对不允许改动\"}"; double xinBiaojia; if (!double.TryParse(取参(p, "xin_biaojia"), out xinBiaojia)) xinBiaojia = 0; double xinCaigou; if (!double.TryParse(取参(p, "xin_caigouxishu"), out xinCaigou) || xinCaigou <= 0) xinCaigou = 1; string xinPinpai = 取参(p, "xin_pinpai").Trim(); string xinXilie = 取参(p, "xin_xilie").Trim(); string xinDanwei = 取参(p, "xin_danwei").Trim(); // ★ 元件名称规则=元件库页面点击替换同款(用户要求 AI 替换结果与点击一致): // 品名(xin_pinming)优先 → 系列名清理(去"XX系列"前缀) → xin_mingcheng → 旧值兜底 string 新元件名称 = 取参(p, "xin_pinming").Trim(); if (新元件名称.Length == 0 && xinXilie.Length > 0) 新元件名称 = 清理系列字样(xinXilie); if (新元件名称.Length == 0) 新元件名称 = xinMing; if (新元件名称.Length == 0) 新元件名称 = jiuMing; // ★ 分类防线:目标行必须是塑壳/微型断路器,其他类型/识别不到一律拒绝 string dbLeixing = ""; // ★ 只枚举活元件表(图号/箱号仍在):孤儿残留表不参与分类判断与替换 var tblNames = new List(); int 总行数 = 0; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); tblNames = 活元件表列表(conn); // 找到匹配行并读取其类型列(任一匹配行的类型参与分类判断) foreach (string realTbl in tblNames) { string tbl = "\"" + realTbl.Replace("\"", "") + "\""; string sqlFind = "SELECT 类型 FROM " + tbl + " WHERE 元件名称=@n AND 规格=@s LIMIT 1;"; using (var cmdFind = new System.Data.SQLite.SQLiteCommand(sqlFind, conn)) { cmdFind.Parameters.AddWithValue("@n", jiuMing); cmdFind.Parameters.AddWithValue("@s", jiuGui); object o = cmdFind.ExecuteScalar(); if (o != null && o != DBNull.Value) { dbLeixing = o.ToString(); break; } } } } string aiReason; string fenlei = AI接口分类放行(jiuMing, jiuGui, dbLeixing, xinMing, xinGui, out aiReason); if (fenlei == null) return "{\"ok\":false,\"error\":\"" + Json转义(aiReason) + "\"}"; // ★ 附件码语法域校验+表价防线(2026-09-25):不存在的型号(如32008)/无效表价一律拒绝 if (xinGui.Length > 0) { string 码误 = 校验断路器附件码(xinGui); if (码误 != null) return "{\"ok\":false,\"error\":\"" + Json转义(码误) + "\"}"; } if (xinBiaojia <= 0) return "{\"ok\":false,\"error\":\"xin_biaojia 必须是大于0的数字(表价须来自元件库全维度命中行)\"}"; // ★ 替换落库:单事务参数化 UPDATE;SET 绝不含"数量";派生价按库里原数量重算 using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var tran = conn.BeginTransaction()) { foreach (string realTbl in tblNames) { string tbl = "\"" + realTbl.Replace("\"", "") + "\""; string sqlUpd = "UPDATE " + tbl + " SET " + "元件名称=@nn, 规格=@ng, 表价=@bj, 采购系数=@cgx, " + "品牌=@pp, 系列=@xl" + (xinDanwei.Length > 0 ? ", 单位=@dw" : "") + ", 采购单价 = @bj * @cgx, " + "采购价 = (@bj * @cgx) * CAST(数量 AS REAL), " + "报出单价 = (@bj * @cgx) * 报出系数, " + "报出价 = ((@bj * @cgx) * 报出系数) * CAST(数量 AS REAL) " + "WHERE 元件名称=@on AND 规格=@og;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sqlUpd, conn, tran)) { cmd.Parameters.AddWithValue("@nn", 新元件名称); cmd.Parameters.AddWithValue("@ng", xinGui.Length > 0 ? xinGui : jiuGui); cmd.Parameters.AddWithValue("@bj", xinBiaojia); cmd.Parameters.AddWithValue("@cgx", xinCaigou); cmd.Parameters.AddWithValue("@pp", xinPinpai); cmd.Parameters.AddWithValue("@xl", xinXilie); if (xinDanwei.Length > 0) cmd.Parameters.AddWithValue("@dw", xinDanwei); cmd.Parameters.AddWithValue("@on", jiuMing); cmd.Parameters.AddWithValue("@og", jiuGui); 总行数 += cmd.ExecuteNonQuery(); } } tran.Commit(); } } // ★ UI 刷新(接口线程 → UI 线程):清缓存 + 重载选型页/报价页 + 刷项目汇总 try { if (this.InvokeRequired) { this.Invoke((System.Windows.Forms.MethodInvoker)delegate { AI替换后刷新(); }); } else { AI替换后刷新(); } } catch (Exception exUi) { OpenClawHost.写日志("[AI接口] 刷新UI失败: " + exUi.Message); } OpenClawHost.写日志("[AI接口] 替换: " + jiuMing + "/" + jiuGui + " → " + xinMing + "/" + xinGui + " 表价=" + xinBiaojia + " 影响 " + 总行数 + " 行"); if (总行数 == 0) return "{\"ok\":false,\"rows\":0,\"error\":\"库里没有匹配行(名称+规格定位不到,可能已被替换过)\"}"; return "{\"ok\":true,\"rows\":" + 总行数 + ",\"fenlei\":\"" + fenlei + "\"}"; } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 替换异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } // ---------- POST /api/replacebatch:批量替换(最快流程,2026-09-22 用户要求) ---------- /// 批量替换:body={"items":[{jiu_mingcheng,jiu_guige,xin_pinming,xin_guige,xin_biaojia, /// xin_xishu,xin_pinpai,xin_xilie},...]}。单事务逐项按 /api/replace 完全同款规则 /// (品名链/分类防线/活表过滤/派生价重算,数量绝不改)UPDATE,分类不过的项跳过并回报原因, /// 全部完成只做一次 UI 刷新——比逐行 selectselection+editselection 快一个量级。 private string AI接口_批量替换元件(string bodyJson) { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"当前没有打开项目\"}"; string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return "{\"ok\":false,\"error\":\"项目库文件不存在\"}"; var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } if (p == null || !p.ContainsKey("items") || !(p["items"] is System.Collections.IEnumerable)) return "{\"ok\":false,\"error\":\"缺 items 数组\"}"; var items = new List>(); foreach (object o in (System.Collections.IEnumerable)p["items"]) { var d = o as Dictionary; if (d != null) items.Add(d); } if (items.Count == 0) return "{\"ok\":false,\"error\":\"items 为空\"}"; var tblNames = new List(); var 结果 = new List(); int 总行数 = 0, 成功项 = 0; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); tblNames = 活元件表列表(conn); using (var tran = conn.BeginTransaction()) { foreach (var it in items) { string jiuMing = 取参(it, "jiu_mingcheng").Trim(); string jiuGui = 取参(it, "jiu_guige").Trim(); string xinMing = 取参(it, "xin_mingcheng").Trim(); string xinGui = 取参(it, "xin_guige").Trim(); double xinBiaojia; if (!double.TryParse(取参(it, "xin_biaojia"), out xinBiaojia)) xinBiaojia = 0; double xinXishu; if (!double.TryParse(取参(it, "xin_xishu"), out xinXishu)) xinXishu = 1; if (取参(it, "xin_caigouxishu").Length > 0) double.TryParse(取参(it, "xin_caigouxishu"), out xinXishu); if (xinXishu <= 0) xinXishu = 1; string xinPinpai = 取参(it, "xin_pinpai").Trim(); string xinXilie = 取参(it, "xin_xilie").Trim(); // ★ 品名链=元件库页面点击替换同款:品名优先→系列名清理→xin_mingcheng→旧值 string 新元件名称 = 取参(it, "xin_pinming").Trim(); if (新元件名称.Length == 0 && xinXilie.Length > 0) 新元件名称 = 清理系列字样(xinXilie); if (新元件名称.Length == 0) 新元件名称 = xinMing; if (新元件名称.Length == 0) 新元件名称 = jiuMing; if (jiuMing.Length == 0 && jiuGui.Length == 0) { 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":0,\"error\":\"名称与规格同时为空\"}"); continue; } if (取参(it, "xin_shuliang").Length > 0) { 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":0,\"error\":\"禁止传数量\"}"); continue; } // 分类防线:活表里找该行类型再放行(同单条版) string dbLeixing = ""; foreach (string realTbl in tblNames) { string tblF = "\"" + realTbl.Replace("\"", "") + "\""; using (var cmdFind = new System.Data.SQLite.SQLiteCommand( "SELECT 类型 FROM " + tblF + " WHERE 元件名称=@n AND 规格=@s LIMIT 1;", conn, tran)) { cmdFind.Parameters.AddWithValue("@n", jiuMing); cmdFind.Parameters.AddWithValue("@s", jiuGui); object o = cmdFind.ExecuteScalar(); if (o != null && o != DBNull.Value) { dbLeixing = o.ToString(); break; } } } string aiReason; string fenlei = AI接口分类放行(jiuMing, jiuGui, dbLeixing, xinMing, xinGui, out aiReason); if (fenlei == null) { 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":0,\"error\":\"" + Json转义(aiReason) + "\"}"); continue; } // ★ 附件码语法域校验+表价防线(2026-09-25):不存在的型号(如32008)/无效表价的项跳过并回报 if (xinGui.Length > 0) { string 码误 = 校验断路器附件码(xinGui); if (码误 != null) { 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":0,\"error\":\"" + Json转义(码误) + "\"}"); continue; } } if (xinBiaojia <= 0) { 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":0,\"error\":\"xin_biaojia 必须是大于0的数字(表价须来自元件库全维度命中行)\"}"); continue; } // 替换落库:逐活表参数化 UPDATE(SET 绝不含数量,派生价按原数量重算) int itemRows = 0; foreach (string realTbl in tblNames) { string tbl = "\"" + realTbl.Replace("\"", "") + "\""; string sqlUpd = "UPDATE " + tbl + " SET " + "元件名称=@nn, 规格=@ng, 表价=@bj, 采购系数=@cgx, " + "品牌=@pp, 系列=@xl, " + "采购单价 = @bj * @cgx, " + "采购价 = (@bj * @cgx) * CAST(数量 AS REAL), " + "报出单价 = (@bj * @cgx) * 报出系数, " + "报出价 = ((@bj * @cgx) * 报出系数) * CAST(数量 AS REAL) " + "WHERE 元件名称=@on AND 规格=@og;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sqlUpd, conn, tran)) { cmd.Parameters.AddWithValue("@nn", 新元件名称); cmd.Parameters.AddWithValue("@ng", xinGui.Length > 0 ? xinGui : jiuGui); cmd.Parameters.AddWithValue("@bj", xinBiaojia); cmd.Parameters.AddWithValue("@cgx", xinXishu); cmd.Parameters.AddWithValue("@pp", xinPinpai); cmd.Parameters.AddWithValue("@xl", xinXilie); cmd.Parameters.AddWithValue("@on", jiuMing); cmd.Parameters.AddWithValue("@og", jiuGui); itemRows += cmd.ExecuteNonQuery(); } } // ★ 0 行命中=该项失败(2026-09-25:不再静默 ok,消灭假成功) if (itemRows == 0) { 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":0,\"error\":\"库里没有匹配行(名称+规格定位不到,可能已被替换过)\"}"); continue; } 总行数 += itemRows; 成功项++; 结果.Add("{\"guige\":\"" + Json转义(jiuGui) + "\",\"rows\":" + itemRows + ",\"fenlei\":\"" + fenlei + "\"}"); } tran.Commit(); } } // ★ 全部完成只做一次 UI 刷新(单条版每行刷一次,批量版砍掉 N-1 次) try { if (this.InvokeRequired) { this.Invoke((System.Windows.Forms.MethodInvoker)delegate { AI替换后刷新(); }); } else { AI替换后刷新(); } } catch (Exception exUi) { OpenClawHost.写日志("[AI接口] 刷新UI失败: " + exUi.Message); } OpenClawHost.写日志("[AI接口] 批量替换: " + items.Count + " 项,成功 " + 成功项 + ",共影响 " + 总行数 + " 行"); var sb = new System.Text.StringBuilder(); sb.Append("{\"ok\":true,\"items\":").Append(items.Count) .Append(",\"done\":").Append(成功项) .Append(",\"rows\":").Append(总行数) .Append(",\"results\":["); for (int i = 0; i < 结果.Count; i++) { if (i > 0) sb.Append(","); sb.Append(结果[i]); } sb.Append("]}"); return sb.ToString(); } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 批量替换异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } // ---------- POST /api/page:AI 的"鼠标"——在元件库页面里执行 JS(点击/读值) ---------- /// body=JSON:{js:"(async()=>{...})()"}。在元件库窗口(w4)的 WebView2 页面里执行脚本, /// 返回 {ok, result}(result=页面返回值的 JSON 串)。AI 用它像人一样点选系列/壳架/极数/电流并读 /// 页面真实渲染的型号(.p-name)/价格/品名——所见即所得,不再从价格表拼。 private string AI接口_执行页面脚本(string bodyJson) { try { var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } string script = 取参(p, "js"); if (string.IsNullOrWhiteSpace(script)) return "{\"ok\":false,\"error\":\"缺 js 字段(要在元件库页面里执行的脚本)\"}"; if (script.Length > 20000) return "{\"ok\":false,\"error\":\"脚本过长(>20KB)\"}"; // ExecuteScriptAsync 必须 UI 线程调用:整个访问(含就绪判断)放 BeginInvoke 里, // ContinueWith 收结果,接口线程 Wait(非 UI 线程,安全) var tcs = new System.Threading.Tasks.TaskCompletionSource(); try { this.BeginInvoke((System.Windows.Forms.MethodInvoker)delegate { try { var wv = this.webView21; // 控件别名:DockWindow4 的元件库 WebView2 if (wv == null || wv.CoreWebView2 == null) { tcs.TrySetResult(null); // 用 null 标记"页面未就绪" return; } var task = wv.CoreWebView2.ExecuteScriptAsync(script); task.ContinueWith(delegate(System.Threading.Tasks.Task t) { if (t.IsFaulted) tcs.TrySetException(t.Exception.InnerException); else tcs.TrySetResult(t.Result); }); } catch (Exception exUi) { tcs.TrySetException(exUi); } }); } catch (Exception exInv) { return "{\"ok\":false,\"error\":\"调度到界面线程失败: " + Json转义(exInv.Message) + "\"}"; } if (!tcs.Task.Wait(8000)) return "{\"ok\":false,\"error\":\"页面脚本执行超时(8 秒)\"}"; string result = tcs.Task.Result; // 页面返回值的 JSON 编码串;null=页面未就绪 if (result == null) return "{\"ok\":false,\"error\":\"元件库页面未就绪(软件刚启动或元件库窗口还没加载过页面;先打开一次元件库窗口再试)\"}"; return "{\"ok\":true,\"result\":" + (result ?? "null") + "}"; } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 页面脚本异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } // ---------- POST /api/replaceall:网页"替换"按钮同款协议 → 全项目替换(数量铁律) ---------- /// body=JSON:{jiu_mingcheng, jiu_guige(定位旧行), name(页面完整型号→规格), /// price(页面实算价→表价), brand, xishu(折扣→采购系数), xilie(系列), pinming(品名→元件名称)}。 /// 字段语义与页面"替换"按钮的 postMessage 协议完全同款(WebView21_WebMessageReceived→替换选中元件), /// 但按旧规格替换全项目所有箱号的匹配行,且 ★数量列绝不写(页面按钮会覆盖数量,故替换动作用本接口而非真点按钮)。 /// 分类防线:目标行必须是塑壳/微型断路器。 private string AI接口_网页式全量替换(string bodyJson) { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"当前没有打开项目\"}"; string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return "{\"ok\":false,\"error\":\"项目库文件不存在\"}"; var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } if (p == null) return "{\"ok\":false,\"error\":\"请求体为空\"}"; string jiuMing = 取参(p, "jiu_mingcheng").Trim(); string jiuGui = 取参(p, "jiu_guige").Trim(); string name = 取参(p, "name").Trim(); // 页面完整型号(.p-name) string pinming = 取参(p, "pinming").Trim(); // 品名 string brand = 取参(p, "brand").Trim(); string xishu = 取参(p, "xishu").Trim(); // 折扣(字符串两位小数) string xilie = 取参(p, "xilie").Trim(); // 系列名 double price; if (!double.TryParse(取参(p, "price"), out price)) price = 0; if (!p.ContainsKey("jiu_guige") || string.IsNullOrEmpty(name)) return "{\"ok\":false,\"error\":\"缺 jiu_guige / name(定位旧行与页面完整型号)\"}"; if (取参(p, "xin_shuliang").Length > 0 || 取参(p, "shuliang").Length > 0) return "{\"ok\":false,\"error\":\"★禁止传数量字段:数量列绝对不允许改动\"}"; // 元件名称=品名非空?品名:系列名去系列字样(照 替换选中元件 同款规则) string 元件名称 = string.IsNullOrWhiteSpace(pinming) ? 清理系列字样(xilie) : pinming; double 系数 = 1.0; double.TryParse(xishu, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out 系数); if (系数 <= 0) 系数 = 1.0; // 分类防线(照 /api/replace) string dbLeixing = ""; // ★ 只枚举活元件表(图号/箱号仍在):孤儿残留表不参与分类判断与替换 var tblNames = new List(); int 总行数 = 0; using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); tblNames = 活元件表列表(conn); foreach (string realTbl in tblNames) { string tbl = "\"" + realTbl.Replace("\"", "") + "\""; using (var cmdFind = new System.Data.SQLite.SQLiteCommand("SELECT 类型 FROM " + tbl + " WHERE 元件名称=@n AND 规格=@s LIMIT 1;", conn)) { cmdFind.Parameters.AddWithValue("@n", jiuMing); cmdFind.Parameters.AddWithValue("@s", jiuGui); object o = cmdFind.ExecuteScalar(); if (o != null && o != DBNull.Value) { dbLeixing = o.ToString(); break; } } } } string fenlei = 分类断路器(jiuMing, jiuGui, dbLeixing); if (string.Equals(fenlei, "排除")) return "{\"ok\":false,\"error\":\"目标行是明确非断路器类(隔离开关/万能式/熔断器等),按规则不替换\"}"; if (!string.Equals(fenlei, "塑壳") && !string.Equals(fenlei, "微型")) { // 旧行识别不到(可能是打错的型号)→ 用 AI 给的新型号线再验 string 新类 = 分类断路器(pinming, name, dbLeixing); if (!string.Equals(新类, "塑壳") && !string.Equals(新类, "微型")) return "{\"ok\":false,\"error\":\"旧行与替换型号都无法识别为塑壳/微型断路器(打错的型号请先纠错成规范型号,如 DZ4/PLE→DZ47sLE)\"}"; fenlei = 新类; } // ★ 附件码语法域校验+表价防线(2026-09-25) string 码误R = 校验断路器附件码(name); if (码误R != null) return "{\"ok\":false,\"error\":\"" + Json转义(码误R) + "\"}"; if (price <= 0) return "{\"ok\":false,\"error\":\"price 必须是大于0的数字(页面实算价)\"}"; // 全项目替换(SET 无数量列;派生价按原数量;报出系数保留原值) using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); using (var tran = conn.BeginTransaction()) { foreach (string realTbl in tblNames) { string tbl = "\"" + realTbl.Replace("\"", "") + "\""; string sqlUpd = "UPDATE " + tbl + " SET " + "元件名称=@mc, 规格=@gg, 表价=@bj, 采购系数=@xs, 品牌=@pp, 系列=@xl, " + "采购单价 = @bj * @xs, " + "采购价 = (@bj * @xs) * CAST(数量 AS REAL), " + "报出单价 = (@bj * @xs) * 报出系数, " + "报出价 = ((@bj * @xs) * 报出系数) * CAST(数量 AS REAL) " + "WHERE 元件名称=@on AND 规格=@og;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sqlUpd, conn, tran)) { cmd.Parameters.AddWithValue("@mc", 元件名称); cmd.Parameters.AddWithValue("@gg", name); cmd.Parameters.AddWithValue("@bj", price); cmd.Parameters.AddWithValue("@xs", 系数); cmd.Parameters.AddWithValue("@pp", brand); cmd.Parameters.AddWithValue("@xl", xilie); cmd.Parameters.AddWithValue("@on", jiuMing); cmd.Parameters.AddWithValue("@og", jiuGui); 总行数 += cmd.ExecuteNonQuery(); } } tran.Commit(); } } try { if (this.InvokeRequired) this.Invoke((System.Windows.Forms.MethodInvoker)delegate { AI替换后刷新(); }); else AI替换后刷新(); } catch (Exception exUi) { OpenClawHost.写日志("[AI接口] 刷新UI失败: " + exUi.Message); } OpenClawHost.写日志("[AI接口] 网页式替换: " + jiuMing + "/" + jiuGui + " → " + 元件名称 + "/" + name + " 表价=" + price + " 系数=" + 系数 + " 影响 " + 总行数 + " 行"); if (总行数 == 0) return "{\"ok\":false,\"rows\":0,\"error\":\"库里没有匹配行(名称+规格定位不到,可能已被替换过)\"}"; return "{\"ok\":true,\"rows\":" + 总行数 + ",\"fenlei\":\"" + fenlei + "\"}"; } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 网页式替换异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } // ---------- POST /api/selectselection:AI 选中选型页的行(可视化定位) ---------- /// body=JSON:{jiu_mingcheng, jiu_guige}。在选型页汇总表里找到匹配行并选中+滚动到可见, /// 自然触发选中联动(下方明细表加载该元件分布)。返回 {ok,row}。 private string AI接口_选中选型行(string bodyJson) { try { var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } string jiuMing = 取参(p, "jiu_mingcheng").Trim(); string jiuGui = 取参(p, "jiu_guige").Trim(); if (!p.ContainsKey("jiu_guige")) return "{\"ok\":false,\"error\":\"缺 jiu_guige(名称+规格是联合定位键,名称空传空串)\"}"; var tcs = new System.Threading.Tasks.TaskCompletionSource(); this.BeginInvoke((System.Windows.Forms.MethodInvoker)delegate { try { // 选型页未加载过(启动后没切到过)则先加载 if (_selectionSummaryTable == null) LoadSelectionSummary(); int found = -1; if (_selectionSummaryTable != null) { for (int i = 0; i < _selectionSummaryTable.Rows.Count; i++) { DataRow r = _selectionSummaryTable.Rows[i]; string n = (r["元件名称"] ?? "").ToString().Trim(); string g = (r["规格"] ?? "").ToString().Trim(); if (string.Equals(n, jiuMing, StringComparison.Ordinal) && string.Equals(g, jiuGui, StringComparison.Ordinal)) { found = i; break; } } } if (found < 0) { tcs.TrySetResult("{\"ok\":false,\"error\":\"选型页没有该行(名称+规格与清单不一致)\"}"); return; } var dgv = this.advancedDataGridView1; if (dgv == null) { tcs.TrySetResult("{\"ok\":false,\"error\":\"选型页表格不可用\"}"); return; } if (dgv.Rows.Count > found) { try { dgv.FirstDisplayedScrollingRowIndex = Math.Max(0, found - 3); } catch { } dgv.CurrentCell = dgv.Rows[found].Cells.Count > 0 ? dgv.Rows[found].Cells[0] : null; } OpenClawHost.写日志("[AI接口] 已选中选型页第 " + (found + 1) + " 行: " + jiuMing + "/" + jiuGui); tcs.TrySetResult("{\"ok\":true,\"row\":" + found + "}"); } catch (Exception exUi) { tcs.TrySetResult("{\"ok\":false,\"error\":\"" + Json转义(exUi.Message) + "\"}"); } }); if (!tcs.Task.Wait(5000)) return "{\"ok\":false,\"error\":\"选中操作超时\"}"; return tcs.Task.Result; } catch (Exception ex) { return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } // ---------- POST /api/editselection:走选型页编辑链路替换(零 SQL,复刻手改单元格) ---------- /// body=JSON:{jiu_mingcheng, jiu_guige(定位), xin_mingcheng?, xin_guige, xin_biaojia, /// xin_xishu, xin_pinpai?, xin_xilie?}。复刻"用户在选型页手改单元格"的完整业务链: /// 内存表行赋值 → RecalcSelectionRow 行内重算 → 置脏 → SyncSelectionChangesToDb()(现有方法, /// 按名称+规格全项目同步,SET 天然不含数量列)→ RefreshQuotePageAfterSelectionEdit。 /// 全程不写任何新 SQL;数量列绝不触碰。 private string AI接口_编辑选型行(string bodyJson) { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"当前没有打开项目\"}"; var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } string jiuMing = 取参(p, "jiu_mingcheng").Trim(); string jiuGui = 取参(p, "jiu_guige").Trim(); string xinMing = 取参(p, "xin_mingcheng").Trim(); string xinGui = 取参(p, "xin_guige").Trim(); double xinBiaojia; if (!double.TryParse(取参(p, "xin_biaojia"), out xinBiaojia)) xinBiaojia = 0; double xinXishu; if (!double.TryParse(取参(p, "xin_xishu"), out xinXishu) || xinXishu <= 0) xinXishu = 1; string xinPinpai = 取参(p, "xin_pinpai").Trim(); string xinXilie = 取参(p, "xin_xilie").Trim(); // ★ 元件名称规则=元件库页面点击替换同款:品名(xin_pinming)优先 → 系列名清理 // (去"XX系列"前缀) → xin_mingcheng → 旧值兜底——AI 不传品名也能得到正确元件名称 string 新元件名称 = 取参(p, "xin_pinming").Trim(); if (新元件名称.Length == 0 && xinXilie.Length > 0) 新元件名称 = 清理系列字样(xinXilie); if (新元件名称.Length == 0) 新元件名称 = xinMing; if (新元件名称.Length == 0) 新元件名称 = jiuMing; if (!p.ContainsKey("jiu_guige") || (xinMing.Length == 0 && xinGui.Length == 0)) return "{\"ok\":false,\"error\":\"缺定位键或新值(jiu_guige 必传;xin_mingcheng/xin_guige 至少一项)\"}"; if (取参(p, "xin_shuliang").Length > 0 || 取参(p, "shuliang").Length > 0) return "{\"ok\":false,\"error\":\"★禁止传数量字段:数量列绝对不允许改动\"}"; // 分类防线:目标行必须是塑壳/微型断路器(选型页未加载时先在 UI 线程加载再查) string leixing = ""; bool 行存在 = false; System.Threading.Tasks.Task waitLoad = System.Threading.Tasks.Task.Factory.StartNew(delegate { this.Invoke((System.Windows.Forms.MethodInvoker)delegate { if (_selectionSummaryTable == null) LoadSelectionSummary(); }); }); try { waitLoad.Wait(15000); } catch { } if (_selectionSummaryTable != null) { foreach (DataRow r in _selectionSummaryTable.Rows) { if (string.Equals((r["元件名称"] ?? "").ToString().Trim(), jiuMing, StringComparison.Ordinal) && string.Equals((r["规格"] ?? "").ToString().Trim(), jiuGui, StringComparison.Ordinal)) { 行存在 = true; leixing = (r["类型"] ?? "").ToString(); break; } } } if (!行存在) return "{\"ok\":false,\"error\":\"选型页没有该行(名称+规格与清单不一致)\"}"; string fenlei = 分类断路器(jiuMing, jiuGui, leixing); if (string.Equals(fenlei, "排除")) return "{\"ok\":false,\"error\":\"目标行是明确非断路器类(隔离开关/万能式/熔断器等),按规则不替换\"}"; if (!string.Equals(fenlei, "塑壳") && !string.Equals(fenlei, "微型")) { // 旧行识别不到(打错的型号如 DZ4/PLE)→ 用 AI 纠错后的新型号再验(DZ47sLE→微型 ✓ 放行) string 新类 = 分类断路器(xinMing, xinGui, leixing); if (!string.Equals(新类, "塑壳") && !string.Equals(新类, "微型")) return "{\"ok\":false,\"error\":\"旧行与纠错后型号都无法识别为塑壳/微型断路器(请先纠错成规范型号再替换)\"}"; fenlei = 新类; } // ★ 附件码语法域校验+表价防线(2026-09-25) if (xinGui.Length > 0) { string 码误 = 校验断路器附件码(xinGui); if (码误 != null) return "{\"ok\":false,\"error\":\"" + Json转义(码误) + "\"}"; } if (xinBiaojia <= 0) return "{\"ok\":false,\"error\":\"xin_biaojia 必须是大于0的数字(表价须来自元件库全维度命中行)\"}"; var tcs = new System.Threading.Tasks.TaskCompletionSource(); this.BeginInvoke((System.Windows.Forms.MethodInvoker)delegate { try { int found = -1; if (_selectionSummaryTable != null) { for (int i = 0; i < _selectionSummaryTable.Rows.Count; i++) { DataRow r = _selectionSummaryTable.Rows[i]; if (string.Equals((r["元件名称"] ?? "").ToString().Trim(), jiuMing, StringComparison.Ordinal) && string.Equals((r["规格"] ?? "").ToString().Trim(), jiuGui, StringComparison.Ordinal)) { found = i; break; } } } if (found < 0) { tcs.TrySetResult("{\"ok\":false,\"error\":\"选型页没有该行(名称+规格与清单不一致)\"}"); return; } // ★ 显示层:选型页汇总表行赋新值(数量列不碰)→ 行内重算(用户看到的就是改后的值) DataRow row = _selectionSummaryTable.Rows[found]; if (新元件名称.Length > 0) row["元件名称"] = 新元件名称; if (xinGui.Length > 0) row["规格"] = xinGui; row["表价"] = xinBiaojia; row["采购系数"] = xinXishu; if (xinPinpai.Length > 0) row["品牌"] = xinPinpai; if (xinXilie.Length > 0) row["系列"] = xinXilie; RecalcSelectionRow(found); // ★ 落库层:走软件"元件表编辑直写管线"(与手敲单元格落库同一条链,UPDATE WHERE rowid)—— // 不裸写 SQL;SyncSelectionChangesToDb 按"新名称+规格"匹配旧行永远 0 行(其边界=只能改价格类),故不用。 string dbPath2 = GetQuoteDbPath(_currentProjectId); int 总行数2 = 0; string newMing = 新元件名称.Length > 0 ? 新元件名称 : jiuMing; string newGui = xinGui.Length > 0 ? xinGui : jiuGui; // ★ 只枚举活元件表(图号/箱号仍在):孤儿残留表不参与直写管线替换 var tblNames2 = new List(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath2))) { conn.Open(); tblNames2 = 活元件表列表(conn); // 逐表找匹配行(参数化),投喂软件直写队列(列值含派生价,按各行原数量/报出系数算;数量列绝不入队) foreach (string tbl in tblNames2) { var hits = new List(); using (var cmdF = new System.Data.SQLite.SQLiteCommand( "SELECT rowid, 数量, 报出系数 FROM \"" + tbl.Replace("\"", "") + "\" WHERE 元件名称=@n AND 规格=@s;", conn)) { cmdF.Parameters.AddWithValue("@n", jiuMing); cmdF.Parameters.AddWithValue("@s", jiuGui); using (var rdF = cmdF.ExecuteReader()) { while (rdF.Read()) { long rid = Convert.ToInt64(rdF["rowid"]); double cnt, bcx; double.TryParse((rdF["数量"] ?? "0").ToString(), out cnt); double.TryParse((rdF["报出系数"] ?? "1").ToString(), out bcx); if (bcx <= 0) bcx = 1; hits.Add(new object[] { rid, cnt, bcx }); } } } foreach (object[] h in hits) { long rid = (long)h[0]; double cnt = (double)h[1], bcx = (double)h[2]; lock (_待写项) { Action 塞 = delegate(string col, string val) { _待写项[tbl + "|" + rid + "|" + col] = new[] { tbl, rid.ToString(), col, val }; }; 塞("元件名称", newMing); 塞("规格", newGui); 塞("表价", xinBiaojia.ToString(System.Globalization.CultureInfo.InvariantCulture)); 塞("采购系数", xinXishu.ToString(System.Globalization.CultureInfo.InvariantCulture)); if (xinPinpai.Length > 0) 塞("品牌", xinPinpai); if (xinXilie.Length > 0) 塞("系列", xinXilie); double dj = xinBiaojia * xinXishu; 塞("采购单价", dj.ToString(System.Globalization.CultureInfo.InvariantCulture)); 塞("采购价", (dj * cnt).ToString(System.Globalization.CultureInfo.InvariantCulture)); 塞("报出单价", (dj * bcx).ToString(System.Globalization.CultureInfo.InvariantCulture)); 塞("报出价", (dj * bcx * cnt).ToString(System.Globalization.CultureInfo.InvariantCulture)); } 总行数2++; } } } if (总行数2 == 0) { tcs.TrySetResult("{\"ok\":false,\"error\":\"库里没有匹配行(名称+规格定位不到,可能已被替换过)\"}"); return; } 确保直写Timer().Stop(); 直写队列强制落库(); // 软件直写管线同步提交(事务 UPDATE WHERE rowid+读回+刷新项目汇总) RefreshQuotePageAfterSelectionEdit(); // 选型页保持新值显示 LoadSelectionSummary(); string newMing2 = newMing; string newGui2 = newGui; if (this.advancedDataGridView1 != null && _selectionSummaryTable != null) { for (int k = 0; k < _selectionSummaryTable.Rows.Count; k++) { DataRow rk = _selectionSummaryTable.Rows[k]; if (string.Equals((rk["元件名称"] ?? "").ToString().Trim(), newMing2, StringComparison.Ordinal) && string.Equals((rk["规格"] ?? "").ToString().Trim(), newGui2, StringComparison.Ordinal)) { try { this.advancedDataGridView1.FirstDisplayedScrollingRowIndex = Math.Max(0, k - 3); } catch { } if (this.advancedDataGridView1.Rows.Count > k && this.advancedDataGridView1.Rows[k].Cells.Count > 0) this.advancedDataGridView1.CurrentCell = this.advancedDataGridView1.Rows[k].Cells[0]; break; } } } OpenClawHost.写日志("[AI接口] 选型页编辑替换(直写管线): " + jiuMing + "/" + jiuGui + " → " + newMing + "/" + newGui + " 表价=" + xinBiaojia + " 系数=" + xinXishu + " 影响 " + 总行数2 + " 行"); tcs.TrySetResult("{\"ok\":true,\"rows\":" + 总行数2 + ",\"fenlei\":\"" + fenlei + "\"}"); } catch (Exception exUi) { tcs.TrySetResult("{\"ok\":false,\"error\":\"" + Json转义(exUi.Message) + "\"}"); } }); if (!tcs.Task.Wait(30000)) return "{\"ok\":false,\"error\":\"选型页编辑超时(30 秒)\"}"; return tcs.Task.Result; } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 选型页编辑异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } /// 替换落库后的界面刷新(UI 线程执行):先重算报价页/项目汇总(重型链), /// 最后重载选型页并强制重绘,末尾写日志便于核验。 private void AI替换后刷新() { try { _xianghaoCache.Clear(); _yuanjianCache.Clear(); RefreshQuotePageAfterSelectionEdit(); // 报价页重算+重载+项目汇总 LoadSelectionSummary(); // 选型页重载(放最后,绑定不被后续重载链干扰) if (this.advancedDataGridView1 != null) { this.advancedDataGridView1.Refresh(); // 强制重绘 } OpenClawHost.写日志("[AI接口] 界面已刷新: 选型页 " + (_selectionSummaryTable != null ? _selectionSummaryTable.Rows.Count + " 行" : "空")); } catch (Exception ex) { OpenClawHost.写日志("[AI接口] AI替换后刷新异常: " + ex.Message); } } // ---------- POST /api/importquote:AI 拖入甲方报价 xlsx → 走软件"导入报价"功能 ---------- /// body=JSON:{path:"xlsx 绝对路径", confirm:false|true, mapping:{...可选}}。 /// ★mapping(推荐,AI 先调 /api/excelgrid 看表后确定,适应任意表样): /// {summarySheet:"汇总sheet名", detailSheet:"分项sheet名", /// summary:{seq,cabinet,name,model,spec,unit,qty}, detail:{seq,name,spec,unit,qty,price,amount,vendor}} /// (0 基列号;spec=-1 或省略=无该列;省略 mapping=软件自动识别表头,回退标准布局) /// confirm 缺省/false=只解析不落库,返回图号/箱号/元件计数与分组预览(AI 先给用户确认); /// confirm=true=解析后事务导入当前项目库,并在 UI 线程执行与"导入报价"按钮完全同款的 /// 保存→清缓存→EnterProjectCore 完整刷新。解析/落库零弹窗复用 导入报价.AI无界面执行。 private string AI接口_导入报价(string bodyJson) { try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"当前没有打开项目,请先在项目列表双击进入项目再导入报价\"}"; var js = new System.Web.Script.Serialization.JavaScriptSerializer(); Dictionary p; try { p = js.Deserialize>(bodyJson ?? ""); } catch { return "{\"ok\":false,\"error\":\"请求体不是合法 JSON\"}"; } if (p == null) return "{\"ok\":false,\"error\":\"请求体为空\"}"; string xlsxPath = 取参(p, "path").Trim(); if (xlsxPath.Length == 0) return "{\"ok\":false,\"error\":\"缺 path 字段(甲方报价 xlsx 的绝对路径;AI 收到拖入文件后用其本地路径)\"}"; bool confirm = false; object cf; if (p.TryGetValue("confirm", out cf) && cf != null) { if (cf is bool) confirm = (bool)cf; else { string s = Convert.ToString(cf).Trim().ToLowerInvariant(); confirm = (s == "true" || s == "1" || s == "yes"); } } if (!File.Exists(xlsxPath)) return "{\"ok\":false,\"error\":\"文件不存在: " + Json转义(xlsxPath) + "\"}"; string ext = Path.GetExtension(xlsxPath).ToLowerInvariant(); if (ext != ".xlsx") return "{\"ok\":false,\"error\":\"仅支持 .xlsx 报价文件(当前是 " + Json转义(ext) + ";.xls 老格式请先用 Excel 另存为 .xlsx)\"}"; string dbPath = GetQuoteDbPath(_currentProjectId); if (!File.Exists(dbPath)) return "{\"ok\":false,\"error\":\"项目库文件不存在\"}"; // 落库前:UI 线程保存报价页未完成编辑(与"导入报价"按钮点击时同款,防编辑丢失) if (confirm) { try { if (this.InvokeRequired) this.Invoke((System.Windows.Forms.MethodInvoker)delegate { SaveAllQuoteTablesIfDirty(false); }); else SaveAllQuoteTablesIfDirty(false); } catch (Exception exSv) { OpenClawHost.写日志("[AI接口] 导入前保存异常(继续导入): " + exSv.Message); } } // 解析(+confirm 时落库):复用导入报价窗体的无界面管线,接口线程执行,界面不卡顿 Dictionary 映射 = null; object mv; if (p.TryGetValue("mapping", out mv) && mv is Dictionary) { 映射 = (Dictionary)mv; } // ★执行顺序铁律:先只解析→查重→通过后才落库。 // 否则"重复拦截"返回时数据已经写进库,防重等于失效(还会让用户误以为没导成功)。 导入报价.AI导入结果 r; if (confirm) { // 第一步:只解析不落库 r = 导入报价.AI无界面执行(_currentProjectId, dbPath, xlsxPath, false, 映射); if (!r.成功) return "{\"ok\":false,\"error\":\"" + Json转义(r.错误 ?? "解析失败") + "\"}"; List 重复 = 查项目同名图号(dbPath, r); bool 允许重复 = false; object ad; if (p.TryGetValue("allow_duplicate", out ad) && ad != null) { string ads = Convert.ToString(ad).Trim().ToLowerInvariant(); 允许重复 = (ads == "true" || ads == "1" || ads == "yes"); } if (重复.Count > 0 && !允许重复) { OpenClawHost.写日志("[AI接口] AI导入报价拦截(重复,未落库): " + Path.GetFileName(xlsxPath) + " 项目已有同名图号 " + 重复.Count + " 个"); return "{\"ok\":false,\"duplicate\":true,\"tuhao\":" + r.图号数 + ",\"xianghao\":" + r.箱号数 + ",\"yuanjian\":" + r.元件数 + ",\"existing\":[" + string.Join(",", 重复.Select(t => "\"" + Json转义(t) + "\"")) + "],\"error\":\"项目里已存在同名图号 " + 重复.Count + " 个(" + Json转义(重复[0]) + (重复.Count > 1 ? " 等" : "") + ")。本次文件导入是追加式,再导会产生双份数据。" + "请向用户说明:若是同一份报价重复导入,就不要再导;确属不同报价可改在软件中删除旧图号后再导," + "或用户明确同意重复后带 allow_duplicate:true 重试。\"}"; } // 第二步:查重通过,真正落库 var r2 = 导入报价.AI无界面执行(_currentProjectId, dbPath, xlsxPath, true, 映射); if (!r2.成功) return "{\"ok\":false,\"error\":\"" + Json转义(r2.错误 ?? "导入失败") + "\"}"; r = r2; } else { // 预览:只解析不落库 r = 导入报价.AI无界面执行(_currentProjectId, dbPath, xlsxPath, false, 映射); if (!r.成功) return "{\"ok\":false,\"error\":\"" + Json转义(r.错误 ?? "解析/导入失败") + "\"}"; } // 预览模式附带 existing 供 AI 提前预警(落库后的 confirm 响应不需要) List 已存在图号 = confirm ? new List() : 查项目同名图号(dbPath, r); if (confirm) { // 落库后:UI 线程完整刷新(与 button18_Click 成功分支逐行同款: // 清缓存+清内存表,再 EnterProjectCore 重算汇总+全表重载+选中联动) Exception uiErr = null; try { if (this.InvokeRequired) this.Invoke((System.Windows.Forms.MethodInvoker)delegate { try { _isReloading = true; try { _xianghaoCache.Clear(); _yuanjianCache.Clear(); if (_tuhaoTable != null) { _tuhaoTable.Clear(); _tuhaoTable.AcceptChanges(); } if (_xianghaoTable != null) { _xianghaoTable.Clear(); _xianghaoTable.AcceptChanges(); } if (_yuanjianTable != null) { _yuanjianTable.Clear(); _yuanjianTable.AcceptChanges(); } } finally { _isReloading = false; } EnterProjectCore(_currentProjectId); AI导入后强制重绘(); // ★防"报价页刚切换布局未完成导致 Invalidate 落空" } catch (Exception exInner) { uiErr = exInner; } }); else { _isReloading = true; try { _xianghaoCache.Clear(); _yuanjianCache.Clear(); if (_tuhaoTable != null) { _tuhaoTable.Clear(); _tuhaoTable.AcceptChanges(); } if (_xianghaoTable != null) { _xianghaoTable.Clear(); _xianghaoTable.AcceptChanges(); } if (_yuanjianTable != null) { _yuanjianTable.Clear(); _yuanjianTable.AcceptChanges(); } } finally { _isReloading = false; } EnterProjectCore(_currentProjectId); AI导入后强制重绘(); } } catch (Exception exInv) { uiErr = exInv; } OpenClawHost.写日志("[AI接口] AI导入报价: " + Path.GetFileName(xlsxPath) + " 图号" + r.图号数 + " 箱号" + r.箱号数 + " 元件" + r.元件数 + (uiErr != null ? " (界面刷新异常: " + uiErr.Message + ")" : "")); if (uiErr != null) return "{\"ok\":true,\"confirmed\":true,\"tuhao\":" + r.图号数 + ",\"xianghao\":" + r.箱号数 + ",\"yuanjian\":" + r.元件数 + ",\"warning\":\"数据已导入,但界面刷新异常: " + Json转义(uiErr.Message) + "\"}"; return "{\"ok\":true,\"confirmed\":true,\"tuhao\":" + r.图号数 + ",\"xianghao\":" + r.箱号数 + ",\"yuanjian\":" + r.元件数 + "}"; } // 预览模式:返回分组明细供 AI 向用户汇报 OpenClawHost.写日志("[AI接口] AI导入报价预览: " + Path.GetFileName(xlsxPath) + " 图号" + r.图号数 + " 箱号" + r.箱号数 + " 元件" + r.元件数 + (已存在图号.Count > 0 ? " [项目已有同名图号" + 已存在图号.Count + "个]" : "")); var root = new Dictionary(); root["ok"] = true; root["confirmed"] = false; root["file"] = xlsxPath; root["tuhao"] = r.图号数; root["xianghao"] = r.箱号数; root["yuanjian"] = r.元件数; root["existing"] = 已存在图号; root["groups"] = r.分组预览; return js.Serialize(root); } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 导入报价异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } /// ★AI 导入后界面补刷:EnterProjectCore 已把三表数据读进内存并切到报价页, /// 但 AI 导入时用户往往正停在 AI 面板——tabPage4 刚被切换、布局/句柄尚未就绪, /// Reload 链里的 Invalidate() 可能落在控件真正显示之前而被丢弃(症状:报价页空白, /// 手动切出再切回才显示)。这里延迟一拍(排队到 tab 切换/布局消息之后), /// 用内存表重设行数并强制三个虚拟模式 grid 立即重绘;不重查数据库。 private void AI导入后强制重绘() { this.BeginInvoke((System.Windows.Forms.MethodInvoker)delegate { try { Action 刷 = (grid, dt) => { if (grid == null || dt == null) return; int n = dt.Rows.Count; if (grid.RowCount != n) grid.RowCount = n; grid.Invalidate(); grid.Refresh(); // Invalidate+Update,强制立即重绘,触发 CellValueNeeded 重新供数 }; 刷(this.advancedDataGridView9, _tuhaoTable); 刷(this.advancedDataGridView4, _xianghaoTable); 刷(this.advancedDataGridView5, _yuanjianTable); OpenClawHost.写日志("[AI接口] 导入后补刷: 图号grid行=" + (this.advancedDataGridView9 == null ? -1 : this.advancedDataGridView9.RowCount) + " 可见=" + (this.advancedDataGridView9 != null && this.advancedDataGridView9.Visible) + " 句柄=" + (this.advancedDataGridView9 != null && this.advancedDataGridView9.IsHandleCreated)); } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 导入后补刷异常: " + ex.Message); } }); } /// 查当前项目图号表(advancedDataGridView9Table)里与本次解析结果同名的图号(防重复导入)。只读,异常时返回空表。 private List 查项目同名图号(string dbPath, 导入报价.AI导入结果 r) { var res = new List(); try { var names = new HashSet(); foreach (var g in r.分组预览) { string n = Convert.ToString(g["tuhao"]) ?? ""; n = n.Trim(); if (n.Length > 0) names.Add(n); } if (names.Count == 0) return res; using (var cn = new System.Data.SQLite.SQLiteConnection("Data Source=" + dbPath + ";Version=3;")) { cn.Open(); using (var cmd = cn.CreateCommand()) { cmd.CommandText = "SELECT 图号 FROM advancedDataGridView9Table"; using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { if (rd.IsDBNull(0)) continue; string t = (Convert.ToString(rd.GetValue(0)) ?? "").Trim(); if (t.Length > 0 && names.Contains(t) && !res.Contains(t)) res.Add(t); } } } } } catch (Exception ex) { OpenClawHost.写日志("[AI接口] 查同名图号异常: " + ex.Message); } return res; } // ---------- POST /api/excelgrid:把 xlsx 表头区原样吐给 AI(LLM) 看表定列 ---------- /// body=JSON:{path:"xlsx 绝对路径", sheet:"sheet名(可选)", rows:30, cols:12}。 /// 不带 sheet → 返回 {ok, sheets:[{name,rows}]}(工作表清单); /// 带 sheet → 返回 {ok, sheet, rows, grid:[[每行各列文本...]]}(公式取缓存结果)。 /// AI 看网格判断哪列是柜号/名称/型号/单位/数量后,把 mapping 交给 /api/importquote。 private string AI接口_Excel网格(string bodyJson) { var js = new System.Web.Script.Serialization.JavaScriptSerializer(); try { if (string.IsNullOrWhiteSpace(_currentProjectId)) return "{\"ok\":false,\"error\":\"尚未打开任何项目,请先在软件中进入一个项目\"}"; var p = js.Deserialize>(bodyJson ?? "{}"); if (p == null) return "{\"ok\":false,\"error\":\"无效的 JSON 请求体\"}"; string 路径 = 取参(p, "path"); if (string.IsNullOrWhiteSpace(路径)) return "{\"ok\":false,\"error\":\"缺少 path 参数(xlsx 绝对路径)\"}"; if (!File.Exists(路径)) return "{\"ok\":false,\"error\":\"文件不存在: " + Json转义(路径) + "\"}"; if (!string.Equals(Path.GetExtension(路径), ".xlsx", StringComparison.OrdinalIgnoreCase)) return "{\"ok\":false,\"error\":\"只支持 .xlsx 格式\"}"; string sheet名 = 取参(p, "sheet"); int 最大行 = 30, 最大列 = 12; object v; if (p.TryGetValue("rows", out v)) { try { 最大行 = Math.Min(Convert.ToInt32(v), 200); } catch { } } if (p.TryGetValue("cols", out v)) { try { 最大列 = Math.Min(Convert.ToInt32(v), 30); } catch { } } string err; object data = 导入报价.AI网格数据(路径, sheet名, 最大行, 最大列, out err); if (data == null) return "{\"ok\":false,\"error\":\"" + Json转义(err ?? "读取失败") + "\"}"; var root = (Dictionary)data; root["ok"] = true; root["file"] = 路径; return js.Serialize(root); } catch (Exception ex) { OpenClawHost.写日志("[AI接口] Excel网格异常: " + ex.Message); return "{\"ok\":false,\"error\":\"" + Json转义(ex.Message) + "\"}"; } } // ---------- 断路器分类 ---------- /// 断路器分类(只认塑壳/微型两类可替换): /// 依据 类型列 → 名称关键词 → 规格前缀(国标类组+各品牌主流系列全家), /// 返回 "塑壳" / "微型" / "排除"(明确非断路器:隔离开关/熔断器/转换开关/万能式) / null(识别不到)。 /// 识别不到(null)的行由 AI(LLM) 纠错出规范型号后再验一次——见 AI接口分类放行。 internal static string 分类断路器(string 名称, string 规格, string 类型) { string t = (类型 ?? "") + "|" + (名称 ?? ""); string g = (规格 ?? "").Trim().ToUpperInvariant().Replace(" ", ""); // 0) ★明确排除类:隔离开关/熔断器/转换开关/双电源/接触器/万能式——绝不允许替换 string[] 排除词 = { "隔离", "熔断", "转换开关", "双电源", "ATSE", "PC级", "CB级", "接触器", "HGL", "HG-", "HR3", "HR5", "HR6", "QSA", "GLR", "LG-30", "NH40" }; string[] 万能前缀 = { "DW15", "DW16", "DW17", "DW45", "DW450", "CW1", "CW2", "CW3", "NA1", "NA2", "HA1", "HSW1", "CW-", "DW-" }; // 1) 类型/名称列明示(优先) if (t.Contains("塑壳")) return "塑壳"; if (t.Contains("微型") || t.Contains("小型")) return "微型"; if (t.Contains("万能式") || t.Contains("框架")) return "排除"; foreach (string k in 排除词) if (t.Contains(k)) return "排除"; // 2) 规格含排除词/万能前缀(明确不换) foreach (string k in 排除词) if (g.Contains(k)) return "排除"; foreach (string k in 万能前缀) if (g.StartsWith(k, StringComparison.Ordinal)) return "排除"; // 3) 规格前缀:微型(终端/小型断路器,各品牌全家) string[] 微型前缀 = { // 国标类组 "DZ47", "DZ26", "DZ158", "DZ30", "DZL18", // 正泰/德力西 "NB1", "NB2", "NB3", "NB7", "NB8", "NBH", "NXB", "NBHM", "NXBLE", "DZ47S", // 施耐德 "IC65", "IEC65", "EA9", "DPN", "ISDP", "ISD", "C65", "ACT9", "K37", // ABB "SH2", "S2", "S20", "SN201", "E219", "E419", // 西门子/伊顿/常熟等 "5SU", "5SJ", "5SL", "5SY", "5SB", "PZM", "PZ30", "XHB1", "TM30", "SB-", "3VU", // 人民/良信/天正 "RDB", "NM8LE63", "NB1LE", "TXB1", "TIB1", "TDB1" }; // 4) 规格前缀:塑壳(各品牌全家) string[] 塑壳前缀 = { // 正泰 "NM1", "NM8", "NM10", "NM6", "NM7", "NM8S", "NM8L", "NM8N", "NM8NL", "NM8NDC", "NHM8", "NXM", "NXMS", "NXMF", "NXMN", // 德力西 "CDM1", "CDM2", "CDM3", "CDM6", "CDK1", "CDW1", "DZ15", "DZ20", "DZ108", "DZL-", "DZ153", // 施耐德 "NS", "NSX", "NSE", "NS2", "CVS", "EZD", "NSC", // ABB "XT1", "XT2", "XT4", "XT5", "XT6", "XT7", "TMAX", "ISOMAX", "SACE", // 西门子 "3VA", "3VL", "3VT", "3PQ", // 常熟/人民/良信/天正/环宇 "CM1", "CM2", "CM3", "CMZ", "RMM1", "RMM2", "RMW", "NDM2", "NDM3", "TGM", "TGM1", "HUM8", "HUM18", "HSM1", "SM1", "SM30", "XKM", "HSW-", "GSM1" }; foreach (string k in 微型前缀) if (g.StartsWith(k, StringComparison.Ordinal)) return "微型"; foreach (string k in 塑壳前缀) if (g.StartsWith(k, StringComparison.Ordinal)) return "塑壳"; // 5) 兜底:名称含断路器字样的再判一次 if (t.Contains("断路器")) { foreach (string k in 微型前缀) if (g.StartsWith(k, StringComparison.Ordinal)) return "微型"; foreach (string k in 塑壳前缀) if (g.StartsWith(k, StringComparison.Ordinal)) return "塑壳"; } return null; // 识别不到 → 交给 AI 纠错后用新型号再验(见 AI接口分类放行) } /// AI 替换的分类放行(双通道):旧行本地分类 或 AI 纠错后的新型号本地分类 /// 任一 ∈ {塑壳, 微型} 即放行;旧行命中"排除"类(隔离开关/万能式等)则坚决拒绝。 /// 这样打错的型号(如 DZ4/PLE)由 AI 纠错成规范型号(DZ47sLE…)后能过防线,非断路器仍拦得住。 private static string AI接口分类放行(string jiuMing, string jiuGui, string leixing, string xinMing, string xinGui, out string 拒绝原因) { 拒绝原因 = ""; string 旧 = 分类断路器(jiuMing, jiuGui, leixing); if (string.Equals(旧, "排除")) { 拒绝原因 = "目标行是明确非断路器类(隔离开关/万能式/熔断器等),按规则不替换"; return null; } if (string.Equals(旧, "塑壳") || string.Equals(旧, "微型")) return 旧; // 旧行识别不到(null,可能是打错的型号)→ 用 AI 纠错后的新型号再验 string 新 = 分类断路器(xinMing, xinGui, leixing); if (string.Equals(新, "排除")) { 拒绝原因 = "替换目标型号是明确非断路器类,拒绝"; return null; } if (string.Equals(新, "塑壳") || string.Equals(新, "微型")) return 新 + "(按纠错型号)"; 拒绝原因 = "旧行与纠错后型号都无法识别为塑壳/微型断路器(请先把规格纠错成规范型号再替换)"; return null; } // ---------- 附件码语法域校验(2026-09-25 用户要求:不存在的型号必须拒绝,如 32008) ---------- private static readonly HashSet 标准电流档位表 = new HashSet { 6, 10, 16, 20, 25, 32, 40, 50, 60, 63, 80, 100, 125, 140, 150, 160, 180, 200, 225, 250, 315, 350, 400, 500, 630, 700, 800, 1000, 1250, 1600, 2000, 2500 }; /// 校验新规格里的附件码语法域。只拦"确证非法"的: /// 4位码各位值域(极数2/3/4、脱扣2/3、附件十位0-7、个位0/8)、 /// 5位码第5位用途代号仅可为2(★用户确认:33002末位2=电动机保护,32008必不存在)、 /// 6位以上数字码。1/2/3位码与字母码(老NM1/NM2/NM8等系列制不同)交给技能侧系列字典判定,C#不硬拦。 /// 返回 null=通过;否则=拒绝原因。 internal static string 校验断路器附件码(string 规格) { string g = (规格 ?? "").Trim().ToUpperInvariant(); int slash = g.IndexOf('/'); if (slash < 0) return null; var m = System.Text.RegularExpressions.Regex.Match(g.Substring(slash + 1), @"^[0-9A-Z]+"); if (!m.Success) return null; string tok = m.Value; if (!System.Text.RegularExpressions.Regex.IsMatch(tok, @"^\d+A?$")) return null; // 混合段(1P/C16等微型写法)不拦 bool glued = tok.EndsWith("A"); string digits = glued ? tok.Substring(0, tok.Length - 1) : tok; if (!glued) return 数字码校验(digits); // 尾部粘连电流(如 3300200A=码+电流):任一切分(码合法 且 电流∈标准档位)通过即可 if (数字码校验(digits) == null) return null; for (int cl = 1; cl < digits.Length; cl++) { int cur; if (!int.TryParse(digits.Substring(cl), out cur) || cur <= 0) continue; if (!标准电流档位表.Contains(cur)) continue; if (数字码校验(digits.Substring(0, cl)) == null) return null; } return "规格\"" + 规格 + "\"的码段无法解析成合法(附件码+标准档位电流)组合,型号不存在"; } /// 4/5位及超长数字码的语法域判定(与技能知识库同源)。返回 null=通过。 private static string 数字码校验(string code) { if (code.Length >= 6) return "附件代号" + code + "非法:位数超过5位,不是合法附件码"; if (code.Length == 5) { if (code[4] != '2') return "附件代号" + code + "非法:第5位用途代号仅可为空(配电保护)或2(电动机保护)——型号不存在"; code = code.Substring(0, 4); } if (code.Length == 4) { if ("234".IndexOf(code[0]) < 0) return "附件代号" + code + "非法:第1位极数须为2/3/4"; if ("23".IndexOf(code[1]) < 0) return "附件代号" + code + "非法:第2位脱扣器须为2(电磁式)/3(热磁式)"; if ("01234567".IndexOf(code[2]) < 0) return "附件代号" + code + "非法:第3位附件十位须为0-7"; if ("08".IndexOf(code[3]) < 0) return "附件代号" + code + "非法:第4位附件个位须为0(无)/8(报警触头)"; return null; } return null; // 1/2/3位码:系列制不同(老NM1/NM2等),交技能侧系列字典判定 } // ---------- 小工具 ---------- private static string 取串(System.Data.SQLite.SQLiteDataReader rd, string 列) { try { object o = rd[列]; return o == null || o == DBNull.Value ? "" : o.ToString().Trim(); } catch { return ""; } } private static double 取数(System.Data.SQLite.SQLiteDataReader rd, string 列) { try { object o = rd[列]; if (o == null || o == DBNull.Value) return 0; double d; double.TryParse(o.ToString(), out d); return d; } catch { return 0; } } private static string 取参(Dictionary p, string key) { object o; if (p != null && p.TryGetValue(key, out o) && o != null) return o.ToString(); return ""; } private static string Json转义(string s) { if (string.IsNullOrEmpty(s)) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", " ").Replace("\n", " "); } } }