Files

1755 lines
78 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 成套报价软件
{
/// <summary>
/// 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)。
/// </summary>
public partial class MainForm
{
private System.Net.HttpListener _aiHttp;
private Thread _aiHttpThread;
private int _aiHttpPort = 18790;
// ---------- 生命周期 ----------
/// <summary>启动本地接口(MainForm.Load 挂调;后台线程,失败仅记日志不弹窗)开。summary 标签见上。</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);
}
}
/// <summary>停止本地接口(FormClosing 挂调)。</summary>
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:选型页元件清单(含分类) ----------
/// <summary>照 LoadSelectionSummary 的查询段重新读库聚合(不碰 UI 内存表,线程安全),
/// 输出 JSON:{ok, project, rows:[{mingcheng,guige,danwei,shuliang,biaojia,caigouxishu,baochuxishu,pinpai,xilie,leixing,fenlei,xianghaoshu}]}。</summary>
/// <summary>枚举"活"元件表(2026-09-21 删除残留根治配套):只返回图号表里仍存在、且所属箱号行
/// 仍存在的元件表——删除图号/箱号历史残留的孤儿表(表索引行+物理表没清干净)不再进入
/// AI 清单/替换范围。活键判定兼容两套登记:内部ID(主窗口)与显示名(扒图 Form3 老数据)。</summary>
private static List<string> 活元件表列表(System.Data.SQLite.SQLiteConnection conn)
{
var result = new List<string>();
try
{
// 1) 活图号键集合(内部ID+显示名都收)
var liveTh = new HashSet<string>(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<string, HashSet<string>>(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<string>(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<string> 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<string, Dictionary<string, object>>(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<string, object> row;
if (!merged.TryGetValue(key, out row))
{
row = new Dictionary<string, object>();
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<string>();
((HashSet<string>)row["_biaoge"]).Add(realTbl);
merged[key] = row;
}
else
{
row["shuliang"] = Convert.ToDouble(row["shuliang"]) + sl;
((HashSet<string>)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<string, object>();
root["ok"] = true;
root["project"] = _currentProjectId;
var list = new List<Dictionary<string, object>>();
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<string, object>();
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<string>)r["_biaoge"]).Count; // 影响箱号数
list.Add(out2);
}
root["rows"] = list;
root["count"] = list.Count;
return js.Serialize(root);
}
// ---------- POST /api/replace:替换元件(数量铁律) ----------
/// <summary>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}。</summary>
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<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string>();
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 用户要求) ----------
/// <summary>批量替换:body={"items":[{jiu_mingcheng,jiu_guige,xin_pinming,xin_guige,xin_biaojia,
/// xin_xishu,xin_pinpai,xin_xilie},...]}。单事务逐项按 /api/replace 完全同款规则
/// (品名链/分类防线/活表过滤/派生价重算,数量绝不改)UPDATE,分类不过的项跳过并回报原因,
/// 全部完成只做一次 UI 刷新——比逐行 selectselection+editselection 快一个量级。</summary>
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<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<Dictionary<string, object>>();
foreach (object o in (System.Collections.IEnumerable)p["items"])
{
var d = o as Dictionary<string, object>;
if (d != null) items.Add(d);
}
if (items.Count == 0) return "{\"ok\":false,\"error\":\"items 为空\"}";
var tblNames = new List<string>();
var 结果 = new List<string>();
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(点击/读值) ----------
/// <summary>body=JSON:{js:"(async()=>{...})()"}。在元件库窗口(w4)的 WebView2 页面里执行脚本,
/// 返回 {ok, result}(result=页面返回值的 JSON 串)。AI 用它像人一样点选系列/壳架/极数/电流并读
/// 页面真实渲染的型号(.p-name)/价格/品名——所见即所得,不再从价格表拼。</summary>
private string AI接口_执行页面脚本(string bodyJson)
{
try
{
var js = new System.Web.Script.Serialization.JavaScriptSerializer();
Dictionary<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string>();
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<string> 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:网页"替换"按钮同款协议 → 全项目替换(数量铁律) ----------
/// <summary>body=JSON:{jiu_mingcheng, jiu_guige(定位旧行), name(页面完整型号→规格),
/// price(页面实算价→表价), brand, xishu(折扣→采购系数), xilie(系列), pinming(品名→元件名称)}。
/// 字段语义与页面"替换"按钮的 postMessage 协议完全同款(WebView21_WebMessageReceived→替换选中元件),
/// 但按旧规格替换全项目所有箱号的匹配行,且 ★数量列绝不写(页面按钮会覆盖数量,故替换动作用本接口而非真点按钮)。
/// 分类防线:目标行必须是塑壳/微型断路器。</summary>
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<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string>();
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 选中选型页的行(可视化定位) ----------
/// <summary>body=JSON:{jiu_mingcheng, jiu_guige}。在选型页汇总表里找到匹配行并选中+滚动到可见,
/// 自然触发选中联动(下方明细表加载该元件分布)。返回 {ok,row}。</summary>
private string AI接口_选中选型行(string bodyJson)
{
try
{
var js = new System.Web.Script.Serialization.JavaScriptSerializer();
Dictionary<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string>();
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,复刻手改单元格) ----------
/// <summary>body=JSON:{jiu_mingcheng, jiu_guige(定位), xin_mingcheng?, xin_guige, xin_biaojia,
/// xin_xishu, xin_pinpai?, xin_xilie?}。复刻"用户在选型页手改单元格"的完整业务链:
/// 内存表行赋值 → RecalcSelectionRow 行内重算 → 置脏 → SyncSelectionChangesToDb()(现有方法,
/// 按名称+规格全项目同步,SET 天然不含数量列)→ RefreshQuotePageAfterSelectionEdit。
/// 全程不写任何新 SQL;数量列绝不触碰。</summary>
private string AI接口_编辑选型行(string bodyJson)
{
try
{
if (string.IsNullOrWhiteSpace(_currentProjectId))
return "{\"ok\":false,\"error\":\"当前没有打开项目\"}";
var js = new System.Web.Script.Serialization.JavaScriptSerializer();
Dictionary<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string>();
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<string>();
using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath2)))
{
conn.Open();
tblNames2 = 活元件表列表(conn);
// 逐表找匹配行(参数化),投喂软件直写队列(列值含派生价,按各行原数量/报出系数算;数量列绝不入队)
foreach (string tbl in tblNames2)
{
var hits = new List<object[]>();
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<string, string> 塞 = 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) + "\"}";
}
}
/// <summary>替换落库后的界面刷新(UI 线程执行):先重算报价页/项目汇总(重型链),
/// 最后重载选型页并强制重绘,末尾写日志便于核验。</summary>
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 → 走软件"导入报价"功能 ----------
/// <summary>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无界面执行。</summary>
private string AI接口_导入报价(string bodyJson)
{
try
{
if (string.IsNullOrWhiteSpace(_currentProjectId))
return "{\"ok\":false,\"error\":\"当前没有打开项目,请先在项目列表双击进入项目再导入报价\"}";
var js = new System.Web.Script.Serialization.JavaScriptSerializer();
Dictionary<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string, object> 映射 = null;
object mv;
if (p.TryGetValue("mapping", out mv) && mv is Dictionary<string, object>)
{
映射 = (Dictionary<string, object>)mv;
}
// ★执行顺序铁律:先只解析→查重→通过后才落库。
// 否则"重复拦截"返回时数据已经写进库,防重等于失效(还会让用户误以为没导成功)。
导入报价.AI导入结果 r;
if (confirm)
{
// 第一步:只解析不落库
r = 导入报价.AI无界面执行(_currentProjectId, dbPath, xlsxPath, false, 映射);
if (!r.成功)
return "{\"ok\":false,\"error\":\"" + Json转义(r.错误 ?? "解析失败") + "\"}";
List<string> 重复 = 查项目同名图号(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<string> 已存在图号 = confirm ? new List<string>() : 查项目同名图号(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<string, object>();
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) + "\"}";
}
}
/// <summary>★AI 导入后界面补刷:EnterProjectCore 已把三表数据读进内存并切到报价页,
/// 但 AI 导入时用户往往正停在 AI 面板——tabPage4 刚被切换、布局/句柄尚未就绪,
/// Reload 链里的 Invalidate() 可能落在控件真正显示之前而被丢弃(症状:报价页空白,
/// 手动切出再切回才显示)。这里延迟一拍(排队到 tab 切换/布局消息之后),
/// 用内存表重设行数并强制三个虚拟模式 grid 立即重绘;不重查数据库。</summary>
private void AI导入后强制重绘()
{
this.BeginInvoke((System.Windows.Forms.MethodInvoker)delegate
{
try
{
Action<System.Windows.Forms.DataGridView, System.Data.DataTable> 刷 = (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); }
});
}
/// <summary>查当前项目图号表(advancedDataGridView9Table)里与本次解析结果同名的图号(防重复导入)。只读,异常时返回空表。</summary>
private List<string> 查项目同名图号(string dbPath, 导入报价.AI导入结果 r)
{
var res = new List<string>();
try
{
var names = new HashSet<string>();
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) 看表定列 ----------
/// <summary>body=JSON:{path:"xlsx 绝对路径", sheet:"sheet名(可选)", rows:30, cols:12}。
/// 不带 sheet → 返回 {ok, sheets:[{name,rows}]}(工作表清单);
/// 带 sheet → 返回 {ok, sheet, rows, grid:[[每行各列文本...]]}(公式取缓存结果)。
/// AI 看网格判断哪列是柜号/名称/型号/单位/数量后,把 mapping 交给 /api/importquote。</summary>
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<Dictionary<string, object>>(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<string, object>)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) + "\"}";
}
}
// ---------- 断路器分类 ----------
/// <summary>断路器分类(只认塑壳/微型两类可替换):
/// 依据 类型列 → 名称关键词 → 规格前缀(国标类组+各品牌主流系列全家),
/// 返回 "塑壳" / "微型" / "排除"(明确非断路器:隔离开关/熔断器/转换开关/万能式) / null(识别不到)。
/// 识别不到(null)的行由 AI(LLM) 纠错出规范型号后再验一次——见 AI接口分类放行。</summary>
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接口分类放行)
}
/// <summary>AI 替换的分类放行(双通道):旧行本地分类 或 AI 纠错后的新型号本地分类
/// 任一 ∈ {塑壳, 微型} 即放行;旧行命中"排除"类(隔离开关/万能式等)则坚决拒绝。
/// 这样打错的型号(如 DZ4/PLE)由 AI 纠错成规范型号(DZ47sLE…)后能过防线,非断路器仍拦得住。</summary>
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<int> 标准电流档位表 = new HashSet<int> {
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 };
/// <summary>校验新规格里的附件码语法域。只拦"确证非法"的:
/// 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=通过;否则=拒绝原因。</summary>
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 "规格\"" + 规格 + "\"的码段无法解析成合法(附件码+标准档位电流)组合,型号不存在";
}
/// <summary>4/5位及超长数字码的语法域判定(与技能知识库同源)。返回 null=通过。</summary>
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<string, object> 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", " ");
}
}
}