using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Windows.Forms; using System.Data.SQLite; using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; namespace 成套报价软件 { /// /// 导入报价 的交互逻辑。 /// 从甲方客户报价表 xlsx(含"屏柜汇总表"+"屏柜分项表")自动识别图号/箱号/元件, /// 预览后导入到当前项目的报价库。 /// public partial class 导入报价 { // ====== 字段 ====== private string _projectId; private string _dbPath; private string _connString; // 解析结果:图号→箱号→元件 层级数据 private List<图号数据> _图号列表 = new List<图号数据>(); /// 汇总表读到的(图号名, (单位, 数量))列表,按顺序和分项表箱号配对。 private List>> _汇总表箱号数据; // 预览 DataTable private DataTable _图号表; private DataTable _箱号表; private DataTable _元件表; // 图号表名(与主窗口一致) private const string TuhaoTableName = "advancedDataGridView9Table"; // ====== 构造函数 ====== // SharpDevelop 设计器需要无参数构造函数;运行时由主窗口调用带参数版本。 public 导入报价() { InitializeComponent(); } public 导入报价(string projectId, string dbPath) { InitializeComponent(); _projectId = projectId; _dbPath = dbPath; _connString = "Data Source=" + dbPath + ";Version=3;"; // 绑定事件 this.button1.Click += button1_Click; this.roundedButton1.Click += roundedButton1_Click; // ★ 新增:下拉框选择变化时自动解析并预览 this.resizableComboBox1.SelectedIndexChanged += resizableComboBox1_SelectedIndexChanged; this.enhancedDataGridView1.SelectionChanged += 图号表_SelectionChanged; this.enhancedDataGridView2.SelectionChanged += 箱号表_SelectionChanged; // 用户在预览箱号表里编辑"箱名"列后,立即同步回数据模型(切换图号会重建预览行) this.enhancedDataGridView2.CellEndEdit += 箱号表_CellEndEdit; // 初始化预览 DataTable(列结构与主窗口报价表一致) InitPreviewTables(); } // ====== 事件 ====== /// 下拉框选择变化 → 自动解析选中的 xlsx → 预览表格(不导入数据库)。 private void resizableComboBox1_SelectedIndexChanged(object sender, EventArgs e) { string xlsxPath = this.resizableComboBox1.SelectedItem as string; if (string.IsNullOrWhiteSpace(xlsxPath) || !File.Exists(xlsxPath)) return; try { string parseErr; ParseAndPreview(xlsxPath, out parseErr); if (!string.IsNullOrEmpty(parseErr)) { MessageBox.Show(this, parseErr, "导入报价预览", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } catch (Exception ex) { MessageBox.Show(this, "解析报价表失败:\n" + ex.Message, "导入报价预览", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// "选择..."按钮:选择 xlsx 文件,路径加入下拉框并选中(触发解析预览)。 private void roundedButton1_Click(object sender, EventArgs e) { using (var dlg = new OpenFileDialog()) { dlg.Filter = "Excel 文件|*.xlsx|所有文件|*.*"; dlg.Title = "选择甲方客户报价表"; if (dlg.ShowDialog(this) != DialogResult.OK) return; // 避免重复添加 bool found = false; foreach (var item in this.resizableComboBox1.Items) { if (string.Equals(item as string, dlg.FileName, StringComparison.OrdinalIgnoreCase)) { found = true; break; } } if (!found) this.resizableComboBox1.Items.Add(dlg.FileName); // 设置选中项 → 自动触发 SelectedIndexChanged → 解析预览 this.resizableComboBox1.SelectedItem = dlg.FileName; } } /// "导入报价表"按钮:若预览为空则先解析,然后把预览的数据导入到项目库。 private void button1_Click(object sender, EventArgs e) { // 1) 还没解析过? 先解析预览 if (_图号列表.Count == 0) { string xlsxPath = this.resizableComboBox1.SelectedItem as string; if (string.IsNullOrWhiteSpace(xlsxPath)) { using (var dlg = new OpenFileDialog()) { dlg.Filter = "Excel 文件|*.xlsx|所有文件|*.*"; dlg.Title = "选择甲方客户报价表"; if (dlg.ShowDialog(this) != DialogResult.OK) return; xlsxPath = dlg.FileName; } } if (!File.Exists(xlsxPath)) { MessageBox.Show(this, "文件不存在:\n" + xlsxPath, "导入报价", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } string parseErr; try { ParseAndPreview(xlsxPath, out parseErr); } catch (Exception ex) { MessageBox.Show(this, "解析报价表失败:\n" + ex.Message + "\n\n" + ex.StackTrace, "导入报价", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } if (_图号列表.Count == 0) { string msg = "未能从报价表中识别到任何图号数据。\n请确认文件是甲方客户报价表(含\"屏柜汇总表\")。"; if (!string.IsNullOrEmpty(parseErr)) msg = parseErr; MessageBox.Show(this, msg, "导入报价", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } } // 2) 确认导入 int totalXh = 0, totalYj = 0; foreach (var th in _图号列表) { totalXh += th.箱号列表.Count; foreach (var xh in th.箱号列表) totalYj += xh.元件列表.Count; } string confirm = "即将导入当前预览数据到项目:\n" + " 图号 " + _图号列表.Count + " 个\n" + " 箱号 " + totalXh + " 个\n" + " 元件 " + totalYj + " 条\n\n" + "确认要导入吗?"; if (MessageBox.Show(this, confirm, "导入报价", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) return; // 3) 导入数据库 try { int thCount = 0, xhCount = 0, yjCount = 0; ImportToDatabase(out thCount, out xhCount, out yjCount); string msg = "导入完成!\n图号 " + thCount + " 个,箱号 " + xhCount + " 个,元件 " + yjCount + " 条。"; MessageBox.Show(this, msg, "导入报价", MessageBoxButtons.OK, MessageBoxIcon.Information); this.DialogResult = DialogResult.OK; this.Close(); } catch (Exception ex) { MessageBox.Show(this, "导入失败:\n" + ex.Message + "\n\n" + ex.StackTrace, "导入报价", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// AI/接口传入的映射字典 → 汇总列映射。键: seq/cabinet/name/model/spec/unit/qty(0基列号,-1=无)。 private static 汇总列映射 字典转汇总映射(Dictionary d) { var m = new 汇总列映射(); if (d == null) return m; Func 取 = (k, def) => { object v; if (!d.TryGetValue(k, out v) || v == null) return def; try { return Convert.ToInt32(v); } catch { return def; } }; m.序号 = 取("seq", m.序号); m.柜号 = 取("cabinet", m.柜号); m.名称 = 取("name", m.名称); m.型号 = 取("model", m.型号); m.规格 = 取("spec", -1); // 未明确给出=无规格列 m.单位 = 取("unit", m.单位); m.数量 = 取("qty", m.数量); return m; } /// AI/接口传入的映射字典 → 分项列映射。键: seq/name/spec/unit/qty/price/amount/vendor。 private static 分项列映射 字典转分项映射(Dictionary d) { var m = new 分项列映射(); if (d == null) return m; Func 取 = (k, def) => { object v; if (!d.TryGetValue(k, out v) || v == null) return def; try { return Convert.ToInt32(v); } catch { return def; } }; m.序号 = 取("seq", m.序号); m.元件名称 = 取("name", m.元件名称); m.型号规格 = 取("spec", m.型号规格); m.单位 = 取("unit", m.单位); m.数量 = 取("qty", m.数量); m.单价 = 取("price", m.单价); m.金额 = 取("amount", m.金额); m.厂家 = 取("vendor", m.厂家); return m; } /// /// 解析 xlsx 并填充到预览表(不导入数据库)。 /// 返回 null 表示成功,否则返回错误/警告信息。 /// ★映射参数(AI 看 /api/excelgrid 网格后确定): summarySheet/detailSheet=sheet名, /// summary/detail=列映射字典;为 null 时自动识别表头(回退标准布局)。 /// /// 导入调试日志(logs\导入调试.log)。 private static void 导入日志(string msg) { try { string dir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs"); System.IO.Directory.CreateDirectory(dir); System.IO.File.AppendAllText(System.IO.Path.Combine(dir, "导入调试.log"), DateTime.Now.ToString("HH:mm:ss.fff") + " " + msg + System.Environment.NewLine); } catch { } } private void ParseAndPreview(string xlsxPath, out string parseErr, string 汇总sheet名 = null, string 分项sheet名 = null, 汇总列映射 汇总映射 = null, 分项列映射 分项映射 = null) { parseErr = null; _图号列表.Clear(); using (var fs = new FileStream(xlsxPath, FileMode.Open, FileAccess.Read)) { IWorkbook workbook = new XSSFWorkbook(fs); // ★ 格式自动识别:正泰识图软件导出(设备汇总表+设备明细表) → 走专用解析; // 否则按甲方报价表(屏柜汇总表+屏柜分项表)解析 if (workbook.GetSheet("设备汇总表") != null || workbook.GetSheet("设备明细表") != null) { 图号名默认 = System.IO.Path.GetFileNameWithoutExtension(xlsxPath).Trim(); // ★文件名首尾空格必须去掉:带空格名会造成重复建表/查询失配 Parse设备汇总表(workbook); Parse设备明细表(workbook); } else { Parse汇总表(workbook, 汇总映射, 汇总sheet名); Parse分项表(workbook, 分项映射, 分项sheet名); } } // ★ 按柜号名匹配(双方统一去掉"柜号:"前缀再比较) if (_汇总表箱号数据 != null && _汇总表箱号数据.Count > 0) { foreach (var th in _图号列表) { foreach (var xh in th.箱号列表) { string xhKey = xh.箱号.Trim(); if (xhKey.StartsWith("柜号:")) xhKey = xhKey.Substring(3).Trim(); else if (xhKey.StartsWith("柜号:")) xhKey = xhKey.Substring(3).Trim(); foreach (var entry in _汇总表箱号数据) { if (entry.Key == xhKey) { if (!string.IsNullOrWhiteSpace(entry.Value.Key)) xh.单位 = entry.Value.Key; if (!string.IsNullOrWhiteSpace(entry.Value.Value)) xh.数量 = entry.Value.Value; break; } } } } } // 剔除没有任何箱号的空图号(通常是被误识别的非报价信息) for (int i = _图号列表.Count - 1; i >= 0; i--) { if (_图号列表[i].箱号列表.Count == 0) _图号列表.RemoveAt(i); } ShowPreview(); if (_图号列表.Count == 0) { parseErr = "未能从报价表中识别到任何图号/箱号数据。\n请确认文件是甲方客户报价表(含\"屏柜汇总表\")。"; } } // ====== 解析 xlsx ====== // 严格校验:只识别真实的报价数据行,过滤表头/说明/合计/非报价文字 // ---------- 表头驱动列识别(★不依赖固定列位,适应多种甲方报价文件) ---------- // 原则:先在 sheet 前部找表头行(含"序号"+"柜号"),按表头文字确定各列含义; // 找不到表头行才回退到标准布局(历史文件兼容)。 /// 屏柜汇总表列位映射(-1=该表没有此列)。默认=标准布局 A序号|B柜号|C箱柜名称|D箱柜型号|E规格|F单位|G数量。 private class 汇总列映射 { public int 序号 = 0, 柜号 = 1, 名称 = 2, 型号 = 3, 规格 = -1, 单位 = 4, 数量 = 5, 单价 = -1; } /// 屏柜分项表元件行列位映射。默认=标准布局 A序号|B元件名称|C型号规格|D单位|E数量|F单价|G金额|H生产厂家。 private class 分项列映射 { public int 序号 = 0, 元件名称 = 1, 型号规格 = 2, 单位 = 3, 数量 = 4, 单价 = 5, 金额 = 6, 厂家 = 7; } /// 表头单元格文本 → 汇总表列含义(返回命中列名,null=不认识)。先精确后模糊。 private static string 汇总表头命中(string t, 汇总列映射 m, int col) { // 已被更早精确命中的列不再覆盖 if (t == "序号") { m.序号 = col; return "序号"; } if (t.Contains("柜号")) { m.柜号 = col; return "柜号"; } if (t == "箱柜名称" || t == "名称" || t == "柜名") { m.名称 = col; return "名称"; } if (t == "箱柜型号" || t == "型号") { m.型号 = col; return "型号"; } if (t.Contains("规格") && !t.Contains("型号")) { m.规格 = col; return "规格"; } if (t == "单位") { m.单位 = col; return "单位"; } if (t.Contains("数量")) { m.数量 = col; return "数量"; } if (t.Contains("单价")) { m.单价 = col; return "单价"; } return null; } /// 在 sheet 前部找汇总表表头行(某行同时出现"序号"与"柜号"),按文字填充列映射。 /// 找到返回表头行号;找不到返回 -1(保持默认映射)。 private static int 识别汇总表头(ISheet sheet, 汇总列映射 m) { int 上限 = Math.Min(sheet.LastRowNum, 40); for (int i = 0; i <= 上限; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; // 该行是否同时有"序号"和"柜号"字样 bool 有序号 = false, 有柜号 = false; for (int c = row.FirstCellNum; c < row.LastCellNum; c++) { string t = GetCellString(row, c).Trim(); if (t == "序号") 有序号 = true; if (t.Contains("柜号")) 有柜号 = true; } if (!有序号 || !有柜号) continue; // 命中表头行 → 按文字逐列识别 for (int c = row.FirstCellNum; c < row.LastCellNum; c++) { string t = GetCellString(row, c).Trim(); if (t.Length == 0) continue; 汇总表头命中(t, m, c); } return i; } return -1; } /// 在 sheet 前部找分项表元件表头行(某行同时出现"序号"与"元件名称"),填充列映射。 private static int 识别分项表头(ISheet sheet, 分项列映射 m) { int 上限 = Math.Min(sheet.LastRowNum, 40); for (int i = 0; i <= 上限; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; bool 有序号 = false, 有元件名 = false; for (int c = row.FirstCellNum; c < row.LastCellNum; c++) { string t = GetCellString(row, c).Trim(); if (t == "序号") 有序号 = true; if (t.Contains("元件名称") || t == "名称") 有元件名 = true; } if (!有序号 || !有元件名) continue; for (int c = row.FirstCellNum; c < row.LastCellNum; c++) { string t = GetCellString(row, c).Trim(); if (t.Length == 0) continue; if (t == "序号") { m.序号 = c; continue; } if (t.Contains("元件名称") || t == "名称") { m.元件名称 = c; continue; } if (t.Contains("规格")) { m.型号规格 = c; continue; } // "型号规格"/"规格"都认 if (t == "单位") { m.单位 = c; continue; } if (t.Contains("数量")) { m.数量 = c; continue; } if (t.Contains("单价") || t == "表价") { m.单价 = c; continue; } if (t.Contains("金额") || t.Contains("总价")) { m.金额 = c; continue; } if (t.Contains("厂家") || t.Contains("生产") || t == "品牌") { m.厂家 = c; continue; } } return i; } return -1; } /// /// 严格识别"屏柜汇总表"的图号区块 + 箱号数据行。 /// 箱号行:A列是合法序号(数字-数字或纯数字),B列(柜号)非空且非关键字。 /// 图号行:A列非空、B列为空、且A非关键字/非描述文字。 /// ★列位优先用外部映射(AI 看表确定,/api/importquote 的 mapping 传入); /// 无外部映射时自动识别表头行;都没有=标准布局。 /// private void Parse汇总表(IWorkbook workbook, 汇总列映射 外部映射 = null, string sheet名 = null) { ISheet sheet = workbook.GetSheet(string.IsNullOrEmpty(sheet名) ? "屏柜汇总表" : sheet名); if (sheet == null) return; // ★ 列位来源优先级:AI 外部映射 > 表头自动识别 > 标准布局 汇总列映射 map; int 表头行; if (外部映射 != null) { map = 外部映射; 表头行 = -1; // AI 已给定映射,不再跳过任何行 } else { map = new 汇总列映射(); 表头行 = 识别汇总表头(sheet, map); } 导入日志("[汇总表] 表头行=" + (表头行 + 1) + " (外部映射=" + (外部映射 != null ? "是" : "否") + ") 列映射: 柜号=" + map.柜号 + " 名称=" + map.名称 + " 型号=" + map.型号 + " 规格=" + map.规格 + " 单位=" + map.单位 + " 数量=" + map.数量 + " 单价=" + map.单价); // ★ 创建公式求值器:柜号列的 =屏柜分项表!B12 这类公式需要求值才能拿到实际柜号 IFormulaEvaluator evaluator = null; try { evaluator = workbook.GetCreationHelper().CreateFormulaEvaluator(); } catch { } 图号数据 curTuhao = null; _汇总表箱号数据 = new List>>(); for (int i = 0; i <= sheet.LastRowNum; i++) { if (i == 表头行) continue; // 表头行自身不再参与数据识别 IRow row = sheet.GetRow(i); if (row == null) continue; string A = GetCellString(row, map.序号); string B = GetCellString(row, map.柜号); ICell bCell = row.GetCell(map.柜号); // 柜号列有时被读取为公式文本/缓存文本"屏柜分项表!B12",必须按引用读取真实柜号 if (string.IsNullOrWhiteSpace(B) || B.IndexOf("屏柜分项表!", StringComparison.OrdinalIgnoreCase) >= 0) { System.IO.File.AppendAllText(System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, "公式求值_诊断.log"), string.Format("行{0}: 柜号列{1}类型={2} 原值=[{3}] A=[{4}]" + System.Environment.NewLine, i + 1, map.柜号, bCell != null ? bCell.CellType.ToString() : "NULL", B, A)); // 先按文本引用解析(兼容 NPOI 把公式返回成普通字符串的情况) string refValue = ResolveFormulaReferenceText(workbook, B); if (!string.IsNullOrWhiteSpace(refValue)) B = refValue; // 如果 B 原来为空且确实是 Formula,再尝试 NPOI 公式求值 if (string.IsNullOrWhiteSpace(B) && bCell != null && bCell.CellType == CellType.Formula) { try { if (evaluator != null) { var evResult = evaluator.Evaluate(bCell); if (evResult != null && evResult.CellType == CellType.String) B = (evResult.StringValue ?? "").Trim(); } } catch (Exception ex) { System.IO.File.AppendAllText(System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, "公式求值_诊断.log"), "求值异常=" + ex.Message + System.Environment.NewLine); } } // 最后一层:从 CellFormula 解析 if (string.IsNullOrWhiteSpace(B) && bCell != null && bCell.CellType == CellType.Formula) B = ResolveFormulaReference(workbook, bCell); System.IO.File.AppendAllText(System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, "公式求值_诊断.log"), " 解析柜号=[" + B + "]" + System.Environment.NewLine); } // 空行跳过 if (string.IsNullOrEmpty(A) && string.IsNullOrEmpty(B)) continue; // ====================================================== // ① 识别:箱号数据行 // 条件1: A列是合法序号(如 "1", "1-1") 或 A列为空(部分Excel不填序号) // 条件2: B列(柜号)非空且非关键字 // 条件3: F列(单位)或G列(数量)有值(确认是数据行而非标题) // ====================================================== bool A是合法箱号序号 = IsValidXianghaoSeq(A); string F值 = map.单位 >= 0 ? GetCellString(row, map.单位) : ""; string G值 = map.数量 >= 0 ? GetCellString(row, map.数量) : ""; bool FG有数据 = !string.IsNullOrWhiteSpace(F值) || !string.IsNullOrWhiteSpace(G值); // ★ A列为"0"也是序号(部分Excel序号从0开始或公式残留),或者A空+F/G有值 bool A列可接受 = A是合法箱号序号 || string.IsNullOrWhiteSpace(A) || A.Trim() == "0"; // B列此时可能已解析为"柜号:K1/K12",去掉前缀再过滤关键字 string B去前缀 = B.Trim(); if (B去前缀.StartsWith("柜号:")) B去前缀 = B去前缀.Substring(3).Trim(); else if (B去前缀.StartsWith("柜号:")) B去前缀 = B去前缀.Substring(3).Trim(); bool 是箱号数据行 = A列可接受 && FG有数据 && !string.IsNullOrWhiteSpace(B去前缀) && !IsNonQuoteKeyword(B去前缀); if (是箱号数据行) { // 确保有图号归属 if (curTuhao == null) { curTuhao = new 图号数据 { 名称 = "未命名图号" }; _图号列表.Add(curTuhao); } // ★ 记录汇总表的(柜号名, 单位, 数量)。B列读出的是"柜号:XXX"带前缀, // 分项表解析出的箱号是"XXX"无前缀,匹配时也要统一去掉前缀 string cabinetKey = B.Trim(); if (cabinetKey.StartsWith("柜号:")) cabinetKey = cabinetKey.Substring(3).Trim(); else if (cabinetKey.StartsWith("柜号:")) cabinetKey = cabinetKey.Substring(3).Trim(); _汇总表箱号数据.Add(new KeyValuePair>( cabinetKey, new KeyValuePair(F值, G值))); var xh = new 箱号数据(); // 去掉"柜号:"/"柜号:"前缀,与分项表解析保持一致 string xhName = B.Trim(); if (xhName.StartsWith("柜号:")) xhName = xhName.Substring(3).Trim(); else if (xhName.StartsWith("柜号:")) xhName = xhName.Substring(3).Trim(); xh.箱号 = xhName; // ★ 名称列(箱柜名称/进线柜、PT柜等)= 用户要的「箱名」; // 它也可能是公式引用分项表头行,用与型号/规格相同的方式解析出真实名称 string cRaw = map.名称 >= 0 ? GetCellString(row, map.名称) : ""; ICell cCell = map.名称 >= 0 ? row.GetCell(map.名称) : null; string 箱名值 = ResolveCellOrReference(workbook, cRaw, cCell, evaluator); xh.箱名 = 箱名值; xh.箱柜名称 = 箱名值; // 沿用旧语义:落库到"箱柜类型"列 // ★ 型号/规格列也可能是公式引用分项表(读出来是"0"或引用文本), // 用与柜号列相同的方式解析:按引用去分项表读真实值 string dRaw = map.型号 >= 0 ? GetCellString(row, map.型号) : ""; string eRaw = map.规格 >= 0 ? GetCellString(row, map.规格) : ""; ICell dCell = map.型号 >= 0 ? row.GetCell(map.型号) : null; ICell eCell = map.规格 >= 0 ? row.GetCell(map.规格) : null; xh.型号 = ResolveCellOrReference(workbook, dRaw, dCell, evaluator); xh.规格 = ResolveCellOrReference(workbook, eRaw, eCell, evaluator); xh.单位 = F值; xh.数量 = G值; // 单位/数量为空时用合理默认值 if (string.IsNullOrWhiteSpace(xh.单位)) xh.单位 = "台"; if (string.IsNullOrWhiteSpace(xh.数量)) xh.数量 = "1"; // 箱号价格不识别,导入后由主窗口按元件价格自动计算 xh.单价 = 0m; xh.总价 = 0m; curTuhao.箱号列表.Add(xh); continue; } // ====================================================== // ② 严格判断:图号区块标题行 // ====================================================== if (!string.IsNullOrWhiteSpace(A) && string.IsNullOrWhiteSpace(B) && !IsNonQuoteKeyword(A)) { // 额外过滤:封面/标题行(含"公司"/"报价"/"项目"等)不作为图号 string aTrim = A.Trim(); if (aTrim.Contains("公司") || aTrim.Contains("报价(汇总)") || aTrim.Contains("报价(明细)") || aTrim.Contains("报价单") || aTrim.StartsWith("项目单位") || aTrim.StartsWith("项目名称") || aTrim.StartsWith("联系人") || aTrim.StartsWith("报价日期") || aTrim.StartsWith("文件编号")) continue; curTuhao = new 图号数据 { 名称 = aTrim }; _图号列表.Add(curTuhao); continue; } // 其他情况:表头/合计/非报价文字 → 全部跳过 } } /// /// 严格识别"屏柜分项表"的图号/箱号/元件行。 /// - 图号行:A非空B空且A非关键字 /// - 箱号行:B含"柜号"(全/半角冒号)且能提取到箱号名 /// - 元件行:A是正整数序号(如 1,2,3...)且B非空非关键字 /// ★元件行列位优先用外部映射(AI 确定)> 表头自动识别 > 标准布局。 /// private void Parse分项表(IWorkbook workbook, 分项列映射 外部映射 = null, string sheet名 = null) { ISheet sheet = workbook.GetSheet(string.IsNullOrEmpty(sheet名) ? "屏柜分项表" : sheet名); if (sheet == null) return; 分项列映射 m; if (外部映射 != null) { m = 外部映射; } else { m = new 分项列映射(); 识别分项表头(sheet, m); } 导入日志("[分项表] (外部映射=" + (外部映射 != null ? "是" : "否") + ") 列映射: 序号=" + m.序号 + " 元件名称=" + m.元件名称 + " 型号规格=" + m.型号规格 + " 单位=" + m.单位 + " 数量=" + m.数量 + " 单价=" + m.单价 + " 金额=" + m.金额 + " 厂家=" + m.厂家); 图号数据 curTuhao = null; 箱号数据 curXianghao = null; // 同名箱号按出现顺序匹配:记录每个箱号名在 curTuhao.箱号列表中下次匹配的起始索引 // 这样分项表里第二次出现的"柜号:低压联排"会匹配汇总表里第二个"低压联排" var xhMatchIdx = new Dictionary(); for (int i = 0; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; string A = GetCellString(row, m.序号); string B = GetCellString(row, m.元件名称); // 空行跳过 if (string.IsNullOrWhiteSpace(A) && string.IsNullOrWhiteSpace(B)) continue; // ====================================================== // ① 箱号标题行:B列包含"柜号"(全半角冒号都识别) // ====================================================== if (!string.IsNullOrWhiteSpace(B) && B.IndexOf("柜号") >= 0) { string xhName = B.Replace("柜号:", "").Replace("柜号:", "").Trim(); if (string.IsNullOrWhiteSpace(xhName)) continue; string xhModel = GetCellString(row, m.型号规格 >= 0 ? m.型号规格 + 1 : 3); // 箱号头行:型号标签的值通常在"型号规格列+1"(C标签→D值);无映射时=D列 string xhSpec = GetCellString(row, 8); // I列=规格 // 箱名标签行列位随表样漂移(这份表在 H=index7,老表在 G=index6):从"型号规格列+2"起向右找第一个非空 string xhLabel = ""; int 标签起 = (m.型号规格 >= 0 ? m.型号规格 + 2 : 5); for (int c = 标签起; c <= Math.Min(8, row.LastCellNum - 1); c++) { string v = GetCellString(row, c); if (!string.IsNullOrWhiteSpace(v)) { xhLabel = v; break; } } curXianghao = null; if (curTuhao != null) { // ★ 按顺序匹配同名箱号:汇总表里可能有两个"低压联排", // 分项表里两次出现"柜号:低压联排"应分别匹配第1个、第2个 int startIdx = 0; xhMatchIdx.TryGetValue(xhName, out startIdx); int foundIdx = -1; for (int xi = startIdx; xi < curTuhao.箱号列表.Count; xi++) { if (curTuhao.箱号列表[xi].箱号 == xhName) { foundIdx = xi; break; } } if (foundIdx >= 0) { curXianghao = curTuhao.箱号列表[foundIdx]; // 汇总表没读到的,用分项表头行的值补齐(只在空时补,不覆盖) if (string.IsNullOrWhiteSpace(curXianghao.箱名)) curXianghao.箱名 = xhLabel; if (string.IsNullOrWhiteSpace(curXianghao.型号)) curXianghao.型号 = xhModel; if (string.IsNullOrWhiteSpace(curXianghao.规格)) curXianghao.规格 = xhSpec; // 下次同名箱号从这之后开始找 xhMatchIdx[xhName] = foundIdx + 1; } else { // 汇总表里没找到同名箱号 → 新建一个 curXianghao = new 箱号数据(); curXianghao.箱号 = xhName; curXianghao.箱名 = xhLabel; curXianghao.箱柜名称 = xhLabel; curXianghao.型号 = xhModel; curXianghao.规格 = xhSpec; curTuhao.箱号列表.Add(curXianghao); xhMatchIdx[xhName] = curTuhao.箱号列表.Count; } } continue; } // ====================================================== // ② 图号标题行:A非空、B为空、A非关键字 // ====================================================== if (!string.IsNullOrWhiteSpace(A) && string.IsNullOrWhiteSpace(B) && !IsNonQuoteKeyword(A)) { // 额外过滤:封面/标题行(含"公司"/"报价"/"项目"等)不作为图号 string aTrim = A.Trim(); if (aTrim.Contains("公司") || aTrim.Contains("报价(汇总)") || aTrim.Contains("报价(明细)") || aTrim.Contains("报价单") || aTrim.StartsWith("项目单位") || aTrim.StartsWith("项目名称") || aTrim.StartsWith("联系人") || aTrim.StartsWith("报价日期") || aTrim.StartsWith("文件编号")) continue; curXianghao = null; curTuhao = null; foreach (var th in _图号列表) { if (th.名称 == aTrim) { curTuhao = th; break; } } if (curTuhao == null) { curTuhao = new 图号数据 { 名称 = aTrim }; _图号列表.Add(curTuhao); } // ★ 切换图号时重置同名箱号匹配索引(每个图号独立计数) xhMatchIdx.Clear(); continue; } // ====================================================== // ③ 元件数据行:A是正整数序号,B非空非关键字 // ====================================================== int yjSeq; if (int.TryParse(A, out yjSeq) && yjSeq >= 1 && !string.IsNullOrWhiteSpace(B) && !IsNonQuoteKeyword(B) && curXianghao != null) { var yj = new 元件数据(); yj.元件名称 = B.Trim(); yj.规格 = m.型号规格 >= 0 ? GetCellString(row, m.型号规格) : ""; yj.单位 = m.单位 >= 0 ? GetCellString(row, m.单位) : ""; yj.数量 = m.数量 >= 0 ? GetCellString(row, m.数量) : ""; yj.单价 = m.单价 >= 0 ? GetCellDecimal(row, m.单价) : 0m; yj.总价 = m.金额 >= 0 ? GetCellDecimal(row, m.金额) : 0m; yj.品牌 = m.厂家 >= 0 ? GetCellString(row, m.厂家) : ""; curXianghao.元件列表.Add(yj); continue; } // 其他:表头/说明文字/合计行 → 全部跳过 } } // ====== 正泰识图软件格式(设备汇总表+设备明细表) ====== /// 正泰格式无图号列,整份文件作为一个图号导入,图号名默认取文件名。 private string 图号名默认 = "导入图纸"; /// /// 解析"设备汇总表"(表头第3行:序号|柜号|箱柜名称|箱柜型号|单位|数量|备注|箱柜尺寸|安装方式)。 /// 每个柜号 = 一个箱号行;全部挂在同一个图号(图号名=文件名)下。 /// private void Parse设备汇总表(IWorkbook workbook) { ISheet sheet = workbook.GetSheet("设备汇总表"); if (sheet == null) return; var th = new 图号数据 { 名称 = 图号名默认 }; _图号列表.Add(th); 导入日志("[设备汇总] LastRowNum=" + sheet.LastRowNum + " SheetName=" + sheet.SheetName); for (int i = 0; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; string 柜号 = GetCellString(row, 1); if (i < 8) 导入日志("[设备汇总] 行" + i + " B列=[" + 柜号 + "] B格=" + (row.GetCell(1) == null ? "null" : row.GetCell(1).CellType.ToString()) + " 首格=" + (row.GetCell(0) == null ? "null" : row.GetCell(0).CellType.ToString())); if (string.IsNullOrWhiteSpace(柜号)) continue; // 跳过表头行(柜号列自身是"柜号") if (柜号.Trim() == "柜号") continue; var xh = new 箱号数据 { 箱号 = 柜号.Trim(), 箱柜名称 = GetCellString(row, 2).Trim(), // C=箱柜名称 型号 = GetCellString(row, 3).Trim(), // D=箱柜型号 单位 = GetCellString(row, 4).Trim(), // E=单位 数量 = GetCellString(row, 5).Trim(), // F=数量 规格 = GetCellString(row, 8).Trim(), // I=箱柜尺寸 → 规格列 }; if (string.IsNullOrWhiteSpace(xh.数量)) xh.数量 = "1"; if (string.IsNullOrWhiteSpace(xh.单位)) xh.单位 = "台"; th.箱号列表.Add(xh); } } /// /// 解析"设备明细表"(表头第3行:序号|柜号|元件名称|型号规格|品牌|单位|数量|表价|备注|物料编码|产地|原始型号)。 /// 柜号在组首行给出、后续行留空(合并单元格平铺);型号规格可能带中文名称后缀("CA1-100/4300B/40A 断路器")。 /// private void Parse设备明细表(IWorkbook workbook) { ISheet sheet = workbook.GetSheet("设备明细表"); if (sheet == null || _图号列表.Count == 0) return; var th = _图号列表[0]; 箱号数据 curXianghao = null; var xhMatchIdx = new Dictionary(); // 同名柜号按顺序匹配 for (int i = 0; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; string 柜号 = GetCellString(row, 1).Trim(); string 规格 = GetCellString(row, 3).Trim(); // D=型号规格 if (i < 8) 导入日志("[设备明细] 行" + i + " 柜号=[" + 柜号 + "] 规格=[" + 规格 + "]"); // 组首行:柜号非空 → 切换当前箱号(跳过表头"柜号"自身) if (!string.IsNullOrWhiteSpace(柜号) && 柜号 != "柜号") { curXianghao = null; int startIdx = 0; xhMatchIdx.TryGetValue(柜号, out startIdx); for (int xi = startIdx; xi < th.箱号列表.Count; xi++) { if (th.箱号列表[xi].箱号 == 柜号) { curXianghao = th.箱号列表[xi]; xhMatchIdx[柜号] = xi + 1; break; } } if (curXianghao == null) { // 明细表有、汇总表没有 → 补建箱号 curXianghao = new 箱号数据 { 箱号 = 柜号, 单位 = "台", 数量 = "1" }; th.箱号列表.Add(curXianghao); } } // 元件行:型号规格非空(表头行"型号规格"跳过) if (string.IsNullOrWhiteSpace(规格) || 规格 == "型号规格") continue; if (curXianghao == null) continue; string 名称; string 纯规格 = 拆规格与名称(规格, out 名称); var yj = new 元件数据 { 元件名称 = !string.IsNullOrWhiteSpace(GetCellString(row, 2).Trim()) ? GetCellString(row, 2).Trim() : 名称, 规格 = 纯规格, 品牌 = GetCellString(row, 4).Trim(), // E=品牌 单位 = GetCellString(row, 5).Trim(), // F=单位 数量 = GetCellString(row, 6).Trim(), // G=数量 单价 = ParseDecimalSafe(GetCellString(row, 7)), // H=表价 }; if (string.IsNullOrWhiteSpace(yj.单位)) yj.单位 = "只"; if (string.IsNullOrWhiteSpace(yj.数量)) yj.数量 = "1"; curXianghao.元件列表.Add(yj); } } /// /// 拆"CA1-100/4300B/40A 断路器"→ 规格"CA1-100/4300B/40A" + 名称"断路器"。 /// 规则:取字符串里连续的中文段(≥2字)作名称并从原串剔除;无中文段则整串为规格、名称空。 /// "DJMB2 220/36 0.3kVA" 这类纯型号不受影响。 /// private static string 拆规格与名称(string 原始, out string 名称) { 名称 = ""; if (string.IsNullOrWhiteSpace(原始)) return ""; // 找最长的连续中文段(允许中间夹"-"如"熔断器-RT18"不常见,保守纯中文) int bestStart = -1, bestLen = 0; int i = 0; while (i < 原始.Length) { if (原始[i] >= 0x4e00 && 原始[i] <= 0x9fa5) { int j = i; while (j < 原始.Length && 原始[j] >= 0x4e00 && 原始[j] <= 0x9fa5) j++; if (j - i > bestLen) { bestStart = i; bestLen = j - i; } i = j; } else i++; } if (bestLen >= 2) { 名称 = 原始.Substring(bestStart, bestLen); string 规格 = (原始.Substring(0, bestStart) + " " + 原始.Substring(bestStart + bestLen)) .Trim(); // 压缩多空格 + 去掉规格尾部残留分隔符 规格 = System.Text.RegularExpressions.Regex.Replace(规格, @"\s+", " ").Trim(' ', '-', '/'); return 规格; } return 原始.Trim(); } /// 安全的 decimal 解析(空/非数字返回 0)。 private static decimal ParseDecimalSafe(string s) { decimal d; if (decimal.TryParse((s ?? "").Trim(), out d)) return d; return 0; } // ====== 严格校验辅助函数 ====== /// A列是否是合法的箱号序号格式:纯数字 或 "数字-数字"(如 1-1、2-7)。 private static bool IsValidXianghaoSeq(string s) { if (string.IsNullOrWhiteSpace(s)) return false; s = s.Trim(); // 纯数字 int n; if (int.TryParse(s, out n) && n >= 1) return true; // "数字-数字" int dash = s.IndexOf('-'); if (dash > 0 && dash < s.Length - 1) { string left = s.Substring(0, dash); string right = s.Substring(dash + 1); int a, b; if (int.TryParse(left, out a) && int.TryParse(right, out b) && a >= 1 && b >= 1) return true; } return false; } /// 常见非报价关键字:表头、说明、合计、工程信息等,用于过滤。 private static bool IsNonQuoteKeyword(string s) { if (string.IsNullOrWhiteSpace(s)) return true; string k = s.Trim(); // 完全匹配 switch (k) { case "序号": case "柜号": case "箱柜名称": case "箱柜型号": case "箱柜规格": case "单位": case "数量": case "单价": case "总价": case "备注": case "元件名称": case "规格": case "表价": case "采购单价": case "采购系数": case "采购价": case "报出单价": case "报出系数": case "报出价": case "品牌": case "系列": case "类型": case "图纸规格": case "合计": case "总计": case "小计": case "单台合计": case "合计(元)": case "总计(元)": case "成套报价表": case "工程名称": case "项目编号": case "工程地点": case "日期": case "报价人": case "审核人": case "审批人": case "报价单位": case "电话": case "传真": case "地址": case "邮编": case "毛利率": case "毛利": case "利润": case "利润率": case "成本总价": case "单台成本": case "成本价": case "单台报出": case "箱柜类型": case "图纸": case "型号": case "台数": case "箱柜数量": case "成套设备报价(汇总)": case "成套设备报价(明细)": case "成套设备报价单": case "单 价": case "总 价": case "生产厂家": case "联系人": return true; } // 含关键字 if (k.StartsWith("工程名称") || k.StartsWith("项目编号") || k.StartsWith("工程地点") || k.StartsWith("日期") || k.StartsWith("报价人") || k.StartsWith("审核人") || k.StartsWith("审批人") || k.StartsWith("报价单位") || k.StartsWith("项目单位") || k.StartsWith("项目名称") || k.StartsWith("联系人") || k.StartsWith("联系电话") || k.StartsWith("报价日期") || k.StartsWith("文件编号")) return true; if (k == "图号" || k == "图名" || k == "台数") return true; return false; } // ====== 预览 ====== private void InitPreviewTables() { // 图号表 _图号表 = new DataTable(); _图号表.Columns.Add("序号", typeof(string)); _图号表.Columns.Add("图号", typeof(string)); _图号表.Columns.Add("图名", typeof(string)); _图号表.Columns.Add("台数", typeof(string)); _图号表.Columns.Add("箱柜数量", typeof(double)); _图号表.Columns.Add("单价", typeof(double)); _图号表.Columns.Add("总价", typeof(double)); _图号表.Columns.Add("成本总价", typeof(double)); _图号表.Columns.Add("毛利", typeof(double)); _图号表.Columns.Add("毛利率", typeof(double)); // 箱号表 _箱号表 = new DataTable(); _箱号表.Columns.Add("序号", typeof(string)); _箱号表.Columns.Add("箱号", typeof(string)); _箱号表.Columns.Add("箱名", typeof(string)); _箱号表.Columns.Add("型号", typeof(string)); _箱号表.Columns.Add("规格", typeof(string)); _箱号表.Columns.Add("单位", typeof(string)); _箱号表.Columns.Add("数量", typeof(string)); _箱号表.Columns.Add("单台成本", typeof(double)); _箱号表.Columns.Add("成本价", typeof(double)); _箱号表.Columns.Add("单台报出", typeof(double)); _箱号表.Columns.Add("报出价", typeof(double)); _箱号表.Columns.Add("利润", typeof(double)); _箱号表.Columns.Add("利润率", typeof(double)); _箱号表.Columns.Add("毛利率", typeof(double)); _箱号表.Columns.Add("箱柜类型", typeof(string)); _箱号表.Columns.Add("备注", typeof(string)); _箱号表.Columns.Add("图纸", typeof(string)); // 元件表 _元件表 = new DataTable(); _元件表.Columns.Add("序号", typeof(string)); _元件表.Columns.Add("元件名称", typeof(string)); _元件表.Columns.Add("规格", typeof(string)); _元件表.Columns.Add("单位", typeof(string)); _元件表.Columns.Add("数量", typeof(string)); _元件表.Columns.Add("表价", typeof(double)); _元件表.Columns.Add("采购单价", typeof(double)); _元件表.Columns.Add("采购系数", typeof(string)); _元件表.Columns.Add("采购价", typeof(double)); _元件表.Columns.Add("报出单价", typeof(double)); _元件表.Columns.Add("报出系数", typeof(string)); _元件表.Columns.Add("报出价", typeof(double)); _元件表.Columns.Add("图纸规格", typeof(string)); _元件表.Columns.Add("品牌", typeof(string)); _元件表.Columns.Add("系列", typeof(string)); _元件表.Columns.Add("类型", typeof(string)); _元件表.Columns.Add("备注", typeof(string)); // ★ 设计时列定义已在 Designer.cs 里(43列),只需关自动生成,不要覆盖 this.enhancedDataGridView1.AutoGenerateColumns = false; this.enhancedDataGridView2.AutoGenerateColumns = false; this.enhancedDataGridView3.AutoGenerateColumns = false; this.enhancedDataGridView1.DataSource = _图号表; this.enhancedDataGridView2.DataSource = _箱号表; this.enhancedDataGridView3.DataSource = _元件表; } /// 把解析结果填充到预览表(图号表显示全部,箱号/元件联动)。 private void ShowPreview() { _图号表.Rows.Clear(); _箱号表.Rows.Clear(); _元件表.Rows.Clear(); int seq = 0; foreach (var th in _图号列表) { seq++; var r = _图号表.NewRow(); r["序号"] = seq.ToString(); r["图号"] = th.名称; r["台数"] = "1"; r["箱柜数量"] = (double)th.箱号列表.Count; _图号表.Rows.Add(r); } // 默认显示第一个图号的箱号 + 第一个箱号的元件 if (_图号列表.Count > 0) { Show箱号预览(_图号列表[0]); if (_图号列表[0].箱号列表.Count > 0) { Show元件预览(_图号列表[0].箱号列表[0]); } } // ★ 诊断日志:输出解析结果的前3行,排查"单位/数量/单台成本没识别出来"问题 try { var dbg = new System.Text.StringBuilder(); dbg.AppendLine("===== 导入报价 解析诊断 ====="); dbg.AppendLine("图号数: " + _图号列表.Count); foreach (var th in _图号列表) { dbg.AppendLine("图号[" + th.名称 + "] 箱号数=" + th.箱号列表.Count); foreach (var xh in th.箱号列表) { dbg.AppendLine(" 箱号[" + xh.箱号 + "] 型号=[" + xh.型号 + "] 规格=[" + xh.规格 + "] 单位=[" + xh.单位 + "] 数量=[" + xh.数量 + "] 元件数=" + xh.元件列表.Count); foreach (var yj in xh.元件列表) dbg.AppendLine(" 元件[" + yj.元件名称 + "] 规格=[" + yj.规格 + "] 单位=[" + yj.单位 + "] 数量=[" + yj.数量 + "] 单价=" + yj.单价 + " 总价=" + yj.总价 + " 品牌=[" + yj.品牌 + "]"); } } dbg.AppendLine("===== 箱号表 DataTable ====="); foreach (DataRow r in _箱号表.Rows) { var vals = new List(); foreach (DataColumn c in _箱号表.Columns) vals.Add(c.ColumnName + "=" + r[c]); dbg.AppendLine(" " + string.Join(" | ", vals)); } dbg.AppendLine("===== 元件表 DataTable ====="); foreach (DataRow r in _元件表.Rows) { var vals = new List(); foreach (DataColumn c in _元件表.Columns) vals.Add(c.ColumnName + "=" + r[c]); dbg.AppendLine(" " + string.Join(" | ", vals)); } System.IO.File.WriteAllText(System.IO.Path.Combine( System.AppDomain.CurrentDomain.BaseDirectory, "导入报价_诊断.log"), dbg.ToString()); } catch { } } private void Show箱号预览(图号数据 th) { _箱号表.Rows.Clear(); int seq = 0; foreach (var xh in th.箱号列表) { seq++; var r = _箱号表.NewRow(); r["序号"] = seq.ToString(); r["箱号"] = xh.箱号; r["箱名"] = xh.箱名; r["型号"] = xh.型号; r["规格"] = xh.规格; r["单位"] = xh.单位; r["数量"] = xh.数量; // 箱号价格不显示,导入后由主窗口按元件价格自动计算 r["单台报出"] = 0.0; r["报出价"] = 0.0; _箱号表.Rows.Add(r); } } private void Show元件预览(箱号数据 xh) { _元件表.Rows.Clear(); int seq = 0; foreach (var yj in xh.元件列表) { seq++; var r = _元件表.NewRow(); r["序号"] = seq.ToString(); r["元件名称"] = yj.元件名称; r["规格"] = yj.规格; r["单位"] = yj.单位; r["数量"] = yj.数量; r["表价"] = (double)yj.单价; r["采购系数"] = "1"; r["报出系数"] = "1"; r["报出价"] = (double)yj.总价; r["品牌"] = yj.品牌; r["类型"] = yj.元件名称 == "母排" ? "母排" : "元件"; _元件表.Rows.Add(r); } } /// 图号表选中行变化 → 刷新箱号表预览。 private void 图号表_SelectionChanged(object sender, EventArgs e) { if (this.enhancedDataGridView1.CurrentRow == null) return; int idx = this.enhancedDataGridView1.CurrentRow.Index; if (idx < 0 || idx >= _图号列表.Count) return; Show箱号预览(_图号列表[idx]); if (_图号列表[idx].箱号列表.Count > 0) { Show元件预览(_图号列表[idx].箱号列表[0]); } else { _元件表.Rows.Clear(); } } /// 箱号表选中行变化 → 刷新元件表预览。 private void 箱号表_SelectionChanged(object sender, EventArgs e) { if (this.enhancedDataGridView1.CurrentRow == null) return; int thIdx = this.enhancedDataGridView1.CurrentRow.Index; if (thIdx < 0 || thIdx >= _图号列表.Count) return; var th = _图号列表[thIdx]; if (this.enhancedDataGridView2.CurrentRow == null) return; int xhIdx = this.enhancedDataGridView2.CurrentRow.Index; if (xhIdx < 0 || xhIdx >= th.箱号列表.Count) return; Show元件预览(th.箱号列表[xhIdx]); } /// 用户编辑预览箱号表后,把单元格值同步回数据模型(目前支持"箱名"列)。 /// 预览行按当前图号的 箱号列表 顺序生成,行索引即可定位对象;切换图号会重建行, /// 所以必须在这里立即回写,否则导入落库时丢掉用户填的箱名。 private void 箱号表_CellEndEdit(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || this.enhancedDataGridView2.CurrentRow == null) return; if (this.enhancedDataGridView1.CurrentRow == null) return; int thIdx = this.enhancedDataGridView1.CurrentRow.Index; if (thIdx < 0 || thIdx >= _图号列表.Count) return; var th = _图号列表[thIdx]; // 用触发编辑的行号(不是 CurrentRow),保证改的就是那一行的对象 int xhIdx = e.RowIndex; if (xhIdx < 0 || xhIdx >= th.箱号列表.Count) return; string colName = this.enhancedDataGridView2.Columns[e.ColumnIndex].DataPropertyName; if (string.IsNullOrEmpty(colName)) colName = this.enhancedDataGridView2.Columns[e.ColumnIndex].HeaderText; var cell = this.enhancedDataGridView2.Rows[e.RowIndex].Cells[e.ColumnIndex]; string val = (cell.Value == null || cell.Value == DBNull.Value) ? "" : cell.Value.ToString(); var xh = th.箱号列表[xhIdx]; if (colName == "箱名") xh.箱名 = val.Trim(); } // ====== 导入数据库 ====== private void ImportToDatabase(out int thCount, out int xhCount, out int yjCount) { thCount = 0; xhCount = 0; yjCount = 0; // 提交预览表格中未结束的编辑(如正在输入的箱名),确保数据模型是最新值 this.enhancedDataGridView2.EndEdit(); this.enhancedDataGridView1.EndEdit(); this.enhancedDataGridView3.EndEdit(); using (var conn = new SQLiteConnection(_connString)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { int thSeq = GetMaxSeq(conn, tx, TuhaoTableName, "序号"); foreach (var th in _图号列表) { thSeq++; // INSERT 图号 string sqlTh = "INSERT INTO " + TuhaoTableName + " (序号, 图号, 台数, 箱柜数量, 单价, 总价, 成本总价, 毛利, 毛利率) " + "VALUES (@seq, @th, @ts, @xgs, 0, 0, 0, 0, 0);"; using (var cmd = new SQLiteCommand(sqlTh, conn, tx)) { cmd.Parameters.AddWithValue("@seq", thSeq.ToString()); cmd.Parameters.AddWithValue("@th", th.名称); cmd.Parameters.AddWithValue("@ts", "1"); cmd.Parameters.AddWithValue("@xgs", (double)th.箱号列表.Count); cmd.ExecuteNonQuery(); } thCount++; // 为该图号创建箱号表 string xhTblName = GetNextTableId(conn, tx, "xh"); CreateXianghaoTable(conn, tx, xhTblName); AddTableIndex(conn, tx, "箱号", th.名称, "", xhTblName); int xhSeq = 0; foreach (var xh in th.箱号列表) { xhSeq++; string sqlXh = "INSERT INTO \"" + xhTblName + "\"" + " (序号, 箱号, 箱名, 型号, 规格, 单位, 数量," + " 单台成本, 成本价, 单台报出, 报出价, 利润, 利润率, 毛利率, 箱柜类型, 备注, 图纸) " + "VALUES (@seq, @xh, @xm, @model, @spec, @unit, @qty," + " 0, 0, 0, 0, 0, 0, 0, @type, @remark, '');"; using (var cmd = new SQLiteCommand(sqlXh, conn, tx)) { cmd.Parameters.AddWithValue("@seq", xhSeq.ToString()); cmd.Parameters.AddWithValue("@xh", xh.箱号); cmd.Parameters.AddWithValue("@xm", xh.箱名 ?? ""); cmd.Parameters.AddWithValue("@model", xh.型号); cmd.Parameters.AddWithValue("@spec", xh.规格); cmd.Parameters.AddWithValue("@unit", xh.单位); cmd.Parameters.AddWithValue("@qty", xh.数量); // 箱号价格不导入,全部置0,导入后由主窗口按元件价格自动计算 cmd.Parameters.AddWithValue("@type", xh.箱柜名称); cmd.Parameters.AddWithValue("@remark", ""); cmd.ExecuteNonQuery(); } xhCount++; // 为该箱号创建元件表 if (xh.元件列表.Count > 0) { string yjTblName = GetNextTableId(conn, tx, "yj"); CreateYuanjianTable(conn, tx, yjTblName); AddTableIndex(conn, tx, "元件", th.名称, xh.箱号, yjTblName); int yjSeq = 0; foreach (var yj in xh.元件列表) { yjSeq++; decimal 采购单价 = yj.单价; decimal 采购价 = yj.总价; string sqlYj = "INSERT INTO \"" + yjTblName + "\"" + " (序号, 元件名称, 规格, 单位, 数量," + " 表价, 采购单价, 采购系数, 采购价," + " 报出单价, 报出系数, 报出价," + " 图纸规格, 品牌, 系列, 类型, 备注) " + "VALUES (@seq, @name, @spec, @unit, @qty," + " @price, @cgdj, '1', @cgj," + " @bcdj, '1', @bcj," + " '', @brand, '', @type, '');"; using (var cmd = new SQLiteCommand(sqlYj, conn, tx)) { cmd.Parameters.AddWithValue("@seq", yjSeq.ToString()); cmd.Parameters.AddWithValue("@name", yj.元件名称); cmd.Parameters.AddWithValue("@spec", yj.规格); cmd.Parameters.AddWithValue("@unit", yj.单位); cmd.Parameters.AddWithValue("@qty", yj.数量); cmd.Parameters.AddWithValue("@price", (double)yj.单价); cmd.Parameters.AddWithValue("@cgdj", (double)采购单价); cmd.Parameters.AddWithValue("@cgj", (double)采购价); cmd.Parameters.AddWithValue("@bcdj", (double)yj.单价); cmd.Parameters.AddWithValue("@bcj", (double)yj.总价); cmd.Parameters.AddWithValue("@brand", yj.品牌); cmd.Parameters.AddWithValue("@type", yj.元件名称 == "母排" ? "母排" : "元件"); cmd.ExecuteNonQuery(); } yjCount++; } } } } tx.Commit(); } catch { try { tx.Rollback(); } catch { } throw; } } } } // ====== 数据库辅助方法 ====== /// 查某列的最大数值序号(用于图号表序号)。 private static int GetMaxSeq(SQLiteConnection conn, SQLiteTransaction tx, string tableName, string colName) { int maxN = 0; string sql = "SELECT " + colName + " FROM " + tableName + ";"; try { using (var cmd = new SQLiteCommand(sql, conn, tx)) using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { string s = reader.IsDBNull(0) ? "" : reader.GetString(0); int n; if (int.TryParse(s, out n) && n > maxN) maxN = n; } } } catch { } return maxN; } /// 生成下一个表ID(xh_N / yj_M),查表索引最大序号+1。 private static string GetNextTableId(SQLiteConnection conn, SQLiteTransaction tx, string prefix) { int maxN = 0; // 查表索引 string sql1 = "SELECT 实际表名 FROM 表索引 WHERE 实际表名 LIKE @p;"; using (var cmd1 = new SQLiteCommand(sql1, conn, tx)) { cmd1.Parameters.AddWithValue("@p", prefix + "_%"); using (var reader = cmd1.ExecuteReader()) { while (reader.Read()) { string t = reader.IsDBNull(0) ? "" : reader.GetString(0); string numPart = t.StartsWith(prefix + "_") ? t.Substring(prefix.Length + 1) : ""; int n; if (int.TryParse(numPart, out n) && n > maxN) maxN = n; } } } // 查物理表 string sql2 = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE @p;"; using (var cmd2 = new SQLiteCommand(sql2, conn, tx)) { cmd2.Parameters.AddWithValue("@p", prefix + "_%"); using (var reader = cmd2.ExecuteReader()) { while (reader.Read()) { string t = reader.IsDBNull(0) ? "" : reader.GetString(0); string numPart = t.StartsWith(prefix + "_") ? t.Substring(prefix.Length + 1) : ""; int n; if (int.TryParse(numPart, out n) && n > maxN) maxN = n; } } } return prefix + "_" + (maxN + 1).ToString(); } private static void CreateXianghaoTable(SQLiteConnection conn, SQLiteTransaction tx, string realTableName) { string sql = "CREATE TABLE \"" + realTableName + "\" (" + " 序号 TEXT, 箱号 TEXT, 箱名 TEXT, 型号 TEXT, 规格 TEXT, 单位 TEXT, 数量 TEXT," + " 单台成本 REAL, 成本价 REAL, 单台报出 REAL, 报出价 REAL," + " 利润 REAL, 利润率 REAL, 毛利率 REAL, 箱柜类型 TEXT, 备注 TEXT, 图纸 TEXT" + ");"; using (var cmd = new SQLiteCommand(sql, conn, tx)) { cmd.ExecuteNonQuery(); } } private static void CreateYuanjianTable(SQLiteConnection conn, SQLiteTransaction tx, string realTableName) { string sql = "CREATE TABLE \"" + realTableName + "\" (" + " 序号 TEXT, 元件名称 TEXT, 规格 TEXT, 单位 TEXT, 数量 TEXT," + " 表价 REAL, 采购单价 REAL, 采购系数 TEXT, 采购价 REAL," + " 报出单价 REAL, 报出系数 TEXT, 报出价 REAL," + " 图纸规格 TEXT, 品牌 TEXT, 系列 TEXT, 类型 TEXT, 备注 TEXT" + ");"; using (var cmd = new SQLiteCommand(sql, conn, tx)) { cmd.ExecuteNonQuery(); } } private static void AddTableIndex(SQLiteConnection conn, SQLiteTransaction tx, string type, string tuhao, string xianghao, string realTableName) { string sql = "INSERT INTO 表索引 (类型, 图号, 箱号, 实际表名) VALUES (@t, @th, @xh, @real);"; using (var cmd = new SQLiteCommand(sql, conn, tx)) { cmd.Parameters.AddWithValue("@t", type); cmd.Parameters.AddWithValue("@th", tuhao ?? ""); cmd.Parameters.AddWithValue("@xh", xianghao ?? ""); cmd.Parameters.AddWithValue("@real", realTableName); cmd.ExecuteNonQuery(); } } // ====== NPOI 单元格读取辅助 ====== /// 手动解析公式引用:从 "=屏柜分项表!B12" 提取工作表名和单元格地址,去目标表读值。 private static string ResolveFormulaReference(IWorkbook workbook, ICell formulaCell) { try { string formula = formulaCell.CellFormula; if (string.IsNullOrEmpty(formula)) return ""; // 去掉 = 号 formula = formula.TrimStart('='); // 找到工作表名!单元格地址 int bang = formula.IndexOf('!'); if (bang < 0) return ""; string sheetName = formula.Substring(0, bang).Replace("'", "").Replace("\"", ""); string cellAddr = formula.Substring(bang + 1).Replace("'", "").Replace("\"", "").Replace("$", ""); if (string.IsNullOrEmpty(sheetName) || string.IsNullOrEmpty(cellAddr)) return ""; ISheet refSheet = workbook.GetSheet(sheetName); if (refSheet == null) return ""; // 解析行号列号 var addr = new NPOI.SS.Util.CellReference(cellAddr); IRow refRow = refSheet.GetRow(addr.Row); if (refRow == null) return ""; ICell refCell = refRow.GetCell(addr.Col); if (refCell == null) return ""; return GetCellValueString(refCell); } catch { return ""; } } /// 通用解析:D/E 等列的公式引用(读出"0"或引用文本时按引用取真实值)。 private static string ResolveCellOrReference(IWorkbook workbook, string raw, NPOI.SS.UserModel.ICell cell, NPOI.SS.UserModel.IFormulaEvaluator evaluator) { // 已经有正常文本就直接用 if (!string.IsNullOrWhiteSpace(raw) && raw != "0" && raw.IndexOf("屏柜分项表!", StringComparison.OrdinalIgnoreCase) < 0) return raw; // 文本引用 → 手动解析 string byRef = ResolveFormulaReferenceText(workbook, raw); if (!string.IsNullOrWhiteSpace(byRef) && byRef != "0") return byRef; // Formula 类型 → NPOI 求值 if (cell != null && cell.CellType == CellType.Formula && evaluator != null) { try { var r = evaluator.Evaluate(cell); if (r != null && r.CellType == CellType.String) return (r.StringValue ?? "").Trim(); if (r != null && r.CellType == CellType.Numeric) return r.NumberValue.ToString("0.####"); } catch { } // 求值失败 → 从 CellFormula 手动解析 try { string byF = ResolveFormulaReferenceText(workbook, cell.CellFormula); if (!string.IsNullOrWhiteSpace(byF) && byF != "0") return byF; } catch { } } return raw == "0" ? "" : raw; } /// 按公式文本读取工作表引用,例如“屏柜分项表!B12”。 private static string ResolveFormulaReferenceText(IWorkbook workbook, string text) { try { if (string.IsNullOrWhiteSpace(text)) return ""; string formula = text.Trim(); if (formula.StartsWith("=")) formula = formula.Substring(1); int bang = formula.IndexOf('!'); if (bang < 0) return ""; string sheetName = formula.Substring(0, bang).Trim().Trim('\'').Trim('"'); string cellAddr = formula.Substring(bang + 1).Trim().Trim('\'').Trim('"').Replace("$", ""); ISheet sheet = workbook.GetSheet(sheetName); if (sheet == null) return ""; var addr = new NPOI.SS.Util.CellReference(cellAddr); IRow r = sheet.GetRow(addr.Row); return r == null ? "" : GetCellValueString(r.GetCell(addr.Col)); } catch { return ""; } } /// 读取单元格字符串值(自动处理数值/公式/空值)。 private static string GetCellString(IRow row, int col) { if (row == null) return ""; ICell cell = row.GetCell(col); if (cell == null) return ""; return GetCellValueString(cell); } private static string GetCellValueString(ICell cell) { if (cell == null) return ""; try { switch (cell.CellType) { case CellType.String: return (cell.StringCellValue ?? "").Trim(); case CellType.Numeric: double d = cell.NumericCellValue; if (d == Math.Floor(d) && !double.IsInfinity(d)) return ((long)d).ToString(); return d.ToString("0.####"); case CellType.Boolean: return cell.BooleanCellValue.ToString(); case CellType.Formula: try { if (cell.CachedFormulaResultType == CellType.String) return (cell.StringCellValue ?? "").Trim(); if (cell.CachedFormulaResultType == CellType.Numeric) { double fd = cell.NumericCellValue; if (fd == Math.Floor(fd)) return ((long)fd).ToString(); return fd.ToString("0.####"); } } catch { } return ""; default: return ""; } } catch { return ""; } } /// 读取单元格 decimal 值(非数字返回 0)。 private static decimal GetCellDecimal(IRow row, int col) { if (row == null) return 0m; ICell cell = row.GetCell(col); if (cell == null) return 0m; try { if (cell.CellType == CellType.Numeric) return (decimal)cell.NumericCellValue; if (cell.CellType == CellType.String) { decimal v; if (decimal.TryParse((cell.StringCellValue ?? "").Trim(), out v)) return v; } if (cell.CellType == CellType.Formula) { try { if (cell.CachedFormulaResultType == CellType.Numeric) return (decimal)cell.NumericCellValue; } catch { } } } catch { } return 0m; } // ====== AI 无界面入口(AI助手接口 /api/importquote 专用,零弹窗复用解析+落库管线) ====== /// AI 无界面导入结果(成功标志+计数+分组预览+错误信息)。 internal class AI导入结果 { public bool 成功; public string 错误; public int 图号数; public int 箱号数; public int 元件数; /// 分组预览:每个元素 {tuhao, xianghaoshu, xianghaos:[{xianghao,xiangming,xinghao,guige,danwei,shuliang,yuanjian}]}。 public List> 分组预览 = new List>(); } /// /// AI 无界面执行入口:new 一个永不 Show 的本窗体实例,完整复用 ParseAndPreview 解析管线与 /// ImportToDatabase 事务落库(不重复任何 SQL/识别规则)。控件全程不创建窗口句柄—— /// DataGridView 只做 DataSource 绑定、行数据全部走 DataTable,因此可在接口后台线程调用。 /// 落库=false:仅解析并返回分组预览(不落库);落库=true:解析后事务导入项目库。 /// ★映射(AI 看网格后确定的列位,null=软件自动识别表头): /// {summarySheet:"屏柜汇总表", detailSheet:"屏柜分项表", /// summary:{seq,cabinet,name,model,spec,unit,qty}, detail:{seq,name,spec,unit,qty,price,amount,vendor}} /// ★调用方(MainForm)负责落库成功后在 UI 线程做缓存清理+EnterProjectCore 完整刷新。 /// internal static AI导入结果 AI无界面执行(string projectId, string dbPath, string xlsxPath, bool 落库, Dictionary 映射 = null) { var 结果 = new AI导入结果(); 导入报价 隐藏窗体 = null; try { // 拆映射:sheet 名 + 两种列映射 string 汇总sheet = null, 分项sheet = null; 汇总列映射 汇总映射 = null; 分项列映射 分项映射 = null; if (映射 != null) { object v; if (映射.TryGetValue("summarySheet", out v) && v is string) 汇总sheet = (string)v; if (映射.TryGetValue("detailSheet", out v) && v is string) 分项sheet = (string)v; if (映射.TryGetValue("summary", out v) && v is Dictionary) 汇总映射 = 字典转汇总映射((Dictionary)v); if (映射.TryGetValue("detail", out v) && v is Dictionary) 分项映射 = 字典转分项映射((Dictionary)v); } 隐藏窗体 = new 导入报价(projectId, dbPath); string parseErr; 隐藏窗体.ParseAndPreview(xlsxPath, out parseErr, 汇总sheet, 分项sheet, 汇总映射, 分项映射); if (隐藏窗体._图号列表.Count == 0) { 结果.成功 = false; 结果.错误 = string.IsNullOrEmpty(parseErr) ? "未能从报价表中识别到任何图号/箱号数据(请确认是含\"屏柜汇总表/屏柜分项表\"或\"设备汇总表/设备明细表\"的 xlsx)" : parseErr; return 结果; } // 组装分组预览(无论是否落库都生成,供 AI 先预览后确认) int totalXh = 0, totalYj = 0; foreach (var th in 隐藏窗体._图号列表) { var g = new Dictionary(); g["tuhao"] = th.名称; g["xianghaoshu"] = th.箱号列表.Count; var xhs = new List>(); foreach (var xh in th.箱号列表) { totalXh++; totalYj += xh.元件列表.Count; var x = new Dictionary(); x["xianghao"] = xh.箱号 ?? ""; x["xiangming"] = xh.箱名 ?? ""; x["xinghao"] = xh.型号 ?? ""; x["danwei"] = xh.单位 ?? ""; x["shuliang"] = xh.数量 ?? ""; x["yuanjian"] = xh.元件列表.Count; xhs.Add(x); } g["xianghaos"] = xhs; 结果.分组预览.Add(g); } 结果.图号数 = 隐藏窗体._图号列表.Count; 结果.箱号数 = totalXh; 结果.元件数 = totalYj; if (落库) { int thCount, xhCount, yjCount; 隐藏窗体.ImportToDatabase(out thCount, out xhCount, out yjCount); 结果.图号数 = thCount; 结果.箱号数 = xhCount; 结果.元件数 = yjCount; } 结果.成功 = true; return 结果; } catch (Exception ex) { 结果.成功 = false; 结果.错误 = ex.Message; return 结果; } finally { // 控件未建句柄,同线程 Dispose 不弹任何 UI;防止每次导入泄漏一个隐藏窗体 if (隐藏窗体 != null) { try { 隐藏窗体.Dispose(); } catch { } } } } /// /// AI 看表用(/api/excelgrid):读 xlsx 指定 sheet 前 N 行×M 列文本网格(公式取缓存结果), /// 供 AI(LLM) 判断表结构并给出列映射。sheetName 空=只返回 sheet 清单(名+行数)。 /// 返回对象直接由接口层序列化为 JSON;失败时 错误 非 null。 /// internal static object AI网格数据(string xlsxPath, string sheetName, int 最大行, int 最大列, out string 错误) { 错误 = null; try { using (var fs = new FileStream(xlsxPath, FileMode.Open, FileAccess.Read)) { IWorkbook workbook = new XSSFWorkbook(fs); if (string.IsNullOrEmpty(sheetName)) { var sheets = new List(); for (int i = 0; i < workbook.NumberOfSheets; i++) { ISheet s = workbook.GetSheetAt(i); sheets.Add(new Dictionary { { "name", s.SheetName }, { "rows", s.LastRowNum + 1 } }); } return new Dictionary { { "sheets", sheets } }; } ISheet sheet = workbook.GetSheet(sheetName); if (sheet == null) { 错误 = "找不到工作表: " + sheetName; return null; } int 行上 = Math.Min(sheet.LastRowNum, Math.Max(1, 最大行) - 1); var grid = new List(); for (int r = 0; r <= 行上; r++) { IRow row = sheet.GetRow(r); var cells = new List(); for (int c = 0; c < 最大列; c++) cells.Add(row == null ? "" : AI单元格文本(row.GetCell(c))); grid.Add(cells); } return new Dictionary { { "sheet", sheetName }, { "rows", sheet.LastRowNum + 1 }, { "grid", grid } }; } } catch (Exception ex) { 错误 = ex.Message; return null; } } /// 单元格文本化:字符串原样、数值去尾零、公式取缓存结果(取不到给公式原文)。 private static string AI单元格文本(ICell cell) { if (cell == null) return ""; try { switch (cell.CellType) { case CellType.String: return cell.StringCellValue ?? ""; case CellType.Numeric: return cell.NumericCellValue.ToString("0.####"); case CellType.Boolean: return cell.BooleanCellValue ? "TRUE" : "FALSE"; case CellType.Formula: try { switch (cell.CachedFormulaResultType) { case CellType.String: return cell.StringCellValue ?? ""; case CellType.Numeric: return cell.NumericCellValue.ToString("0.####"); case CellType.Boolean: return cell.BooleanCellValue ? "TRUE" : "FALSE"; default: return cell.CellFormula ?? ""; } } catch { return cell.CellFormula ?? ""; } default: return ""; } } catch { return ""; } } // ====== 内部数据结构 ====== private class 图号数据 { public string 名称; public List<箱号数据> 箱号列表 = new List<箱号数据>(); } private class 箱号数据 { public string 箱号; public string 箱名; public string 箱柜名称; public string 型号; public string 规格; public string 单位; public string 数量; public decimal 单价; public decimal 总价; public List<元件数据> 元件列表 = new List<元件数据>(); } private class 元件数据 { public string 元件名称; public string 规格; public string 单位; public string 数量; public decimal 单价; public decimal 总价; public string 品牌; } /// 从柜号名推算台数:"K1/K12"=2, "K3/K5-K8/K10"=6, "K6"=1。 private static int CountCabinetsFromName(string name) { if (string.IsNullOrWhiteSpace(name)) return 1; int count = 0; // 按 / 分隔:K1/K5-K8/K10 → ["K1", "K5-K8", "K10"] foreach (var seg in name.Split('/')) { var s2 = seg.Trim(); if (string.IsNullOrEmpty(s2)) continue; int dash = s2.IndexOf('-'); if (dash > 0 && dash < s2.Length - 1) { // "K5-K8" → 取数字部分 5 到 8 = 4 台 string left = "", right = ""; for (int i = 0; i < s2.Length; i++) { if (char.IsDigit(s2[i])) { if (i < dash) left += s2[i]; else right += s2[i]; } } int a, b; if (int.TryParse(left, out a) && int.TryParse(right, out b) && b >= a) count += b - a + 1; else count += 1; } else { count += 1; } } return count > 0 ? count : 1; } } }