using System; using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Drawing; using System.IO; using System.Windows.Forms; using Microsoft.Win32; using Smart.CustomComponents; namespace 成套报价软件 { /// 主窗口 拆分文件:由 主窗口.cs 按模块拆出,同一 partial class。 public partial class MainForm { // ============================================================ // 公式实时重算(对应 Excel 的三级联动公式) // 元件(底层,每行独立) → 箱号(聚合该箱号所有元件) → 图号(聚合该图号所有箱号) // ============================================================ /// 把单元格值解析为 decimal;DBNull/空串/非数字返回 0。 private static decimal ParseDecimal(object v) { if (v == null || v == DBNull.Value) return 0m; decimal d; if (decimal.TryParse(v.ToString().Trim(), out d)) return d; return 0m; } /// 把计算结果写回到行某列;为 0 写空串(保持界面整洁)。 /// ★ 虚拟模式:写内存 DataTable 行(按行号)+刷新该行,不再写 Cells.Value(虚拟模式下无效)。 private void SetCell(DataGridViewRow row, string colName, decimal value) { if (row == null || row.DataGridView == null) return; DataGridViewColumn col = FindColumn(row.DataGridView, colName); if (col == null) return; // 写 double(ValueType=double,Format 才能格式化显示) object v = value == 0m ? 0d : (double)value; string realName = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : colName; 写单元格(row.DataGridView, row.Index, realName, v); } // ============================================================ // 价格显示格式化(用 DefaultCellStyle.Format,只改显示不改值) // - 这是 DataGridView 原生的“只改显示”机制,不干扰用户输入 // - 仅作用于公式计算列(见 s_公式列集合);用户输入列(表价/数量/系数)不参与 // ============================================================ // ============================================================ // ★ 虚拟模式供数/回写:表格不存数据,由内存 DataTable 按需提供 // ============================================================ /// 虚拟模式:控件要显示某单元格时从内存表取值。 private void Dgv_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { var dgv = sender as DataGridView; if (dgv == null) return; var table = 取dgv数据表(dgv); if (table == null || e.RowIndex < 0 || e.RowIndex >= table.Rows.Count) { e.Value = ""; return; } if (e.ColumnIndex < 0 || e.ColumnIndex >= dgv.Columns.Count) { e.Value = ""; return; } string colName = dgv.Columns[e.ColumnIndex].DataPropertyName; if (string.IsNullOrEmpty(colName) || !table.Columns.Contains(colName)) { e.Value = ""; return; } // ★ 合并视图序号显示(2026-09-30 多选联动): // 箱号合并(元件表):显示"箱号-N"(如 AH4-1),用户可分辨每行归属(仅显示层,不写库) // 图号合并(箱号表):显示连续行号 1..N if (colName == "序号" && string.IsNullOrEmpty(table.TableName) && table.Columns.Contains("_来源表名_")) { if (table.Columns.Contains("_归属箱号_") && table.Columns.Contains("_段内序号_")) { string xhName = Convert.ToString(table.Rows[e.RowIndex]["_归属箱号_"]); int seqIn = 0; try { seqIn = Convert.ToInt32(table.Rows[e.RowIndex]["_段内序号_"]); } catch { } e.Value = (string.IsNullOrWhiteSpace(xhName) ? "" : xhName + "-") + seqIn.ToString(); } else { e.Value = (e.RowIndex + 1).ToString(); } return; } object v = table.Rows[e.RowIndex][colName]; // 公式列输出数值类型(DefaultCellStyle.Format 只对数值生效) if (v != null && v != DBNull.Value && v is string && s_公式列集合.Contains(colName)) { double d; string s = v.ToString().Trim(); if (s.Length > 0 && double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out d)) { e.Value = d; return; } if (s.Length == 0) { e.Value = 0d; return; } } e.Value = (v == null || v == DBNull.Value) ? "" : v; } /// 虚拟模式:用户编辑单元格提交时写回内存表(读缓存),并排队直写数据库。 private void Dgv_CellValuePushed(object sender, DataGridViewCellValueEventArgs e) { var dgv = sender as DataGridView; if (dgv == null) return; var table = 取dgv数据表(dgv); if (table == null || e.RowIndex < 0 || e.RowIndex >= table.Rows.Count) return; if (e.ColumnIndex < 0 || e.ColumnIndex >= dgv.Columns.Count) return; string colName = dgv.Columns[e.ColumnIndex].DataPropertyName; if (string.IsNullOrEmpty(colName) || !table.Columns.Contains(colName)) return; object 新值 = e.Value; // ★ 图号/箱号重名自动加后缀:改成同表已有名时自动变"名(2)"/"名(3)"(不弹窗不打断; // 元件名称不查重——元件名本来就常重复)。后缀版写回内存表并刷新该行显示。 if ((dgv == this.advancedDataGridView9 && colName == "图号") || (dgv == this.advancedDataGridView4 && colName == "箱号")) { 新值 = 重名自动后缀(table, e.RowIndex, colName, 新值); } table.Rows[e.RowIndex][colName] = (新值 == null || 新值 == DBNull.Value) ? DBNull.Value : 新值; // ★ 直接操作数据库:编辑即排队 UPDATE 库(防抖) 入队直写库(dgv, e.RowIndex, colName, 新值); if (!Equals(新值, e.Value)) { // 后缀生效:刷新该行显示(虚拟模式整表刷新一次供数) try { dgv.InvalidateRow(e.RowIndex); } catch { } Log("[重名] " + colName + " 重名自动改名: " + e.Value + " → " + 新值); } // ★★ 一劳永逸方案(2026-09-30 定稿):编辑提交后【只算本行元件公式】, // 箱号行/图号行的聚合价格【一律不在编辑时更新内存显示】—— // 统一交给"防抖全库重算"(编辑停止 600ms 后): // RecalcAllFromDatabase 按箱号逐个聚合 UPDATE 回库(库内计算,天然按归属) → // 刷新箱号表显示从库(按内部ID逐行对齐) 一次跳到最终正确值。 // 单一更新路径 + 归属定位,结构上杜绝:①价格跳好几次中间值 ②显示到别的箱号。 // (旧的多条内存聚合/回填路径全部废弃——它们是跳动与错位的来源) _isRecalculating = true; _isReloading = true; try { if (dgv == this.advancedDataGridView5) { // 本行元件公式(采购单价/采购价/报出单价/报出价)——单行数据自洽,即时显示 var yjRow = dgv.Rows[e.RowIndex]; if (yjRow != null && !yjRow.IsNewRow) RecalcYuanjianRow(yjRow); // ★ B2(2026-10-01):记录本次编辑影响到的元件物理表(合并视图=行级来源表, // 单箱号=整表表名),防抖到点后反查(图号,箱号)做增量重算——大项目编辑不再全库重算 try { bool mergedYj = string.IsNullOrEmpty(table.TableName) && table.Columns.Contains("_来源表名_"); string srcTbl = (mergedYj ? Convert.ToString(table.Rows[e.RowIndex]["_来源表名_"]) : table.TableName) ?? ""; srcTbl = srcTbl.Trim().Trim('"'); if (!string.IsNullOrWhiteSpace(srcTbl)) { lock (_待重算元件表) { _待重算元件表.Add(srcTbl); } } } catch { } } // 箱号表(dgv4)/图号表(dgv9)编辑:自身派生列依赖下级聚合,同样交给防抖重算 // (dgv4/dgv9 不收集范围→防抖到点走全量,安全优先) // ★ 清缓存:切回单选/切图号时 Reload 读库新值 _yuanjianCache.Clear(); _xianghaoCache.Clear(); 合并视图防抖重算(); } finally { _isRecalculating = false; _isReloading = false; } // ★ 项目页实时同步(800ms 防抖):图号汇总→项目列表库+项目页网格刷新 if (dgv == this.advancedDataGridView5 || dgv == this.advancedDataGridView4 || dgv == this.advancedDataGridView9) 启动项目页同步(); } /// ★ 合并视图编辑后的防抖全库重算(2026-09-30): /// 连续编辑合并触发一次(600ms 安静期),RecalcAllFromDatabase 纯库操作—— /// 按图号/箱号逐个聚合 UPDATE 回库,保证箱号行/图号行/项目汇总始终最新, /// 且不会把"多箱号合计"写进单个箱号行。 /// ★ B2(2026-10-01):编辑收集过影响元件表时改为【增量重算】(只重算受影响箱号), /// 未收集(dgv4/dgv9编辑等)走全量兜底。 private Timer _合并重算Timer; /// B2:编辑影响到的元件物理表集合(防抖窗口内合并),Tick 时取走并反查(图号,箱号)。 private readonly System.Collections.Generic.HashSet _待重算元件表 = new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase); /// B2:元件物理表名集合 → 反查(图号\n箱号内部ID)范围集合。 private System.Collections.Generic.HashSet 反查重算范围(System.Collections.Generic.HashSet 表名s) { var scope = new System.Collections.Generic.HashSet(); if (表名s == null || 表名s.Count == 0 || string.IsNullOrWhiteSpace(_currentProjectId)) return scope; string dbPath = GetQuoteDbPath(_currentProjectId); if (!System.IO.File.Exists(dbPath)) return scope; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); foreach (string t in 表名s) { using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT 图号, 箱号 FROM 表索引 WHERE 类型='元件' AND 实际表名=@t;", conn)) { sel.Parameters.AddWithValue("@t", t); using (var rd = sel.ExecuteReader()) { while (rd.Read()) { string th = rd.IsDBNull(0) ? "" : rd.GetValue(0).ToString().Trim(); string xh = rd.IsDBNull(1) ? "" : rd.GetValue(1).ToString().Trim(); if (!string.IsNullOrWhiteSpace(th) && !string.IsNullOrWhiteSpace(xh)) scope.Add(th + "\n" + xh); } } } } } } catch (Exception exQ) { Log("[重算·增量] 反查范围异常: " + exQ.Message); } return scope; } private void 合并视图防抖重算() { if (_合并重算Timer == null) { _合并重算Timer = new Timer(); _合并重算Timer.Interval = 600; _合并重算Timer.Tick += (s, e) => { _合并重算Timer.Stop(); try { // ★ 修复(2026-10-01):重算读库前先把直写队列强制落库—— // 避免直写(300ms)与重算(600ms)时序竞争导致重算读到旧值 直写队列强制落库(); // ★ B2:先取走编辑收集的影响表 → 反查范围;非空走增量,空走全量 string[] tbls; lock (_待重算元件表) { tbls = new string[_待重算元件表.Count]; _待重算元件表.CopyTo(tbls); _待重算元件表.Clear(); } var scope = 反查重算范围( new System.Collections.Generic.HashSet(tbls)); RecalcAllFromDatabase(_currentProjectId, scope.Count > 0 ? scope : null); // ★ 显示保险(2026-09-30):库聚合完成后,按内部ID对齐从库刷新箱号表 // 价格列显示(不动行序/选中)——库是权威,彻底杜绝"改AH4显示到AH3"的错位 刷新箱号表显示从库(); // ★ 费用行实时联动(2026-10-01):百分比费用的表价已按最新基数在库里重写, // 这里把元件表中费用行的显示值同步为新库值(普通行不动,不打断编辑) 刷新元件表费用行显示从库(); } catch (Exception exR) { Log("[合并重算] 异常: " + exR.Message); } }; } _合并重算Timer.Stop(); _合并重算Timer.Start(); } /// ★ 从库刷新箱号表/图号表价格列显示(2026-09-30):按"内部ID"对齐更新内存行, /// 不动行序与选中。合并视图(TableName 空)时跳过——由切回单选时重载。 private void 刷新箱号表显示从库() { try { 刷新表价格列从库(this.advancedDataGridView4, _xianghaoTable, "单台成本, 成本价, 单台报出, 报出价, 利润, 利润率, 毛利率"); 刷新表价格列从库(this.advancedDataGridView9, _tuhaoTable, "箱柜数量, 单价, 总价, 成本总价, 毛利, 毛利率"); } catch (Exception exF) { Log("[显示同步] 刷新异常: " + exF.Message); } } /// /// ★ 费用行实时联动的显示同步(2026-10-01):重算后库里的费用行表价/四价已按最新基数重写, /// 这里按内部ID对齐把元件表(内存表)中 类型='费用' 的行同步为新库值(普通行不动,不打断编辑)。 /// 合并视图按各行 _来源表名_ 读对应物理表;单箱号视图读整表。 /// private void 刷新元件表费用行显示从库() { try { var tbl = _yuanjianTable; var dgv = this.advancedDataGridView5; if (dgv == null || tbl == null || string.IsNullOrWhiteSpace(_currentProjectId)) return; if (!tbl.Columns.Contains("内部ID") || !tbl.Columns.Contains("类型")) return; string dbPath = GetQuoteDbPath(_currentProjectId); if (!System.IO.File.Exists(dbPath)) return; // 内存表费用行(按内部ID) var 费用行s = new System.Collections.Generic.List(); foreach (DataRow r in tbl.Rows) if (Convert.ToString(r["类型"]).Trim() == "费用") 费用行s.Add(r); if (费用行s.Count == 0) return; bool merged = string.IsNullOrEmpty(tbl.TableName) && tbl.Columns.Contains("_来源表名_"); // 来源物理表集合(合并=行级来源;单箱号=整表) var srcTables = new System.Collections.Generic.List(); if (merged) { foreach (DataRow r in tbl.Rows) { string s = Convert.ToString(r["_来源表名_"]).Trim(); if (!string.IsNullOrWhiteSpace(s) && !srcTables.Contains(s)) srcTables.Add(s); } } else if (!string.IsNullOrWhiteSpace(tbl.TableName)) { srcTables.Add(tbl.TableName); } if (srcTables.Count == 0) return; // 库值:内部ID → [备注,规格,表价,采购单价,采购价,报出单价,报出价] var 库值 = new System.Collections.Generic.Dictionary>(); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); foreach (string s in srcTables) { try { using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID, 备注, 规格, 表价, 采购单价, 采购价, 报出单价, 报出价 FROM \"" + s.Replace("\"", "") + "\" WHERE 类型='费用';", conn)) using (var r = sel.ExecuteReader()) { while (r.Read()) { string nid = r.IsDBNull(0) ? "" : r.GetValue(0).ToString().Trim(); if (string.IsNullOrEmpty(nid)) continue; var vals = new System.Collections.Generic.List(); for (int i = 1; i < r.FieldCount; i++) vals.Add(r.IsDBNull(i) ? (object)DBNull.Value : r.GetValue(i)); 库值[nid] = vals; } } } catch { } } } // 对齐写回内存行(公式列保持 double 类型;规格/备注串同步——旧格式自愈后显示干净) bool any = false; string[] 列s = { "备注", "规格", "表价", "采购单价", "采购价", "报出单价", "报出价" }; foreach (DataRow row in 费用行s) { System.Collections.Generic.List v; string nid = Convert.ToString(row["内部ID"]).Trim(); if (string.IsNullOrEmpty(nid) || !库值.TryGetValue(nid, out v)) continue; for (int i = 0; i < 列s.Length; i++) { if (!tbl.Columns.Contains(列s[i])) continue; object 新值 = v[i]; object 旧值 = row[列s[i]] == DBNull.Value ? (object)"" : row[列s[i]]; if (新值 == DBNull.Value) { if (string.IsNullOrEmpty(Convert.ToString(旧值))) continue; row[列s[i]] = DBNull.Value; any = true; continue; } if (tbl.Columns[列s[i]].DataType == typeof(double)) { double dd; double.TryParse(Convert.ToString(新值), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out dd); 新值 = dd; } if (Convert.ToString(新值) == Convert.ToString(旧值)) continue; row[列s[i]] = 新值; any = true; } } if (any) { dgv.Invalidate(); Log("[显示同步] 元件表费用行已按库刷新(实时联动)"); } } catch (Exception exF) { Log("[显示同步] 费用行刷新异常: " + exF.Message); } } /// 按内部ID对齐,从库重刷某表的价格列显示(通用)。 /// ★ 合并视图支持(2026-10-01):箱号合并表(有_来源表名_)按来源表逐表读库; /// 图号合并表(有_来源图号_)统一读图号主表——删除/编辑后价格列照常刷新。 private void 刷新表价格列从库(DataGridView dgv, DataTable tbl, string 价列csv) { try { if (dgv == null || tbl == null) return; // 合并视图判定(TableName 空但带来源列) bool isMerged = string.IsNullOrEmpty(tbl.TableName) && (tbl.Columns.Contains("_来源表名_") || tbl.Columns.Contains("_来源图号_")); if (string.IsNullOrEmpty(tbl.TableName) && !isMerged) return; // 无名表且非合并视图跳过 if (!tbl.Columns.Contains("内部ID")) return; if (string.IsNullOrWhiteSpace(_currentProjectId)) return; string dbPath = GetQuoteDbPath(_currentProjectId); if (!System.IO.File.Exists(dbPath)) return; // 读哪些物理表:箱号合并表按来源表逐表;图号合并表统一读图号主表;普通表读自身 var 读表s = new System.Collections.Generic.List(); if (isMerged && tbl.Columns.Contains("_来源表名_")) { foreach (DataRow dr in tbl.Rows) { string src = Convert.ToString(dr["_来源表名_"]).Trim(); if (!string.IsNullOrWhiteSpace(src) && !读表s.Contains(src)) 读表s.Add(src); } } else if (isMerged) { 读表s.Add(TuhaoTableName); } else { 读表s.Add(tbl.TableName); } var 库值 = new System.Collections.Generic.Dictionary>( System.StringComparer.OrdinalIgnoreCase); using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); foreach (string srcTbl in 读表s) { try { using (var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID, " + 价列csv + " FROM \"" + srcTbl.Replace("\"", "") + "\";", conn)) using (var r = cmd.ExecuteReader()) { while (r.Read()) { string iid = r.IsDBNull(0) ? "" : r.GetString(0).Trim(); if (string.IsNullOrEmpty(iid)) continue; var d = new System.Collections.Generic.Dictionary(); for (int i = 1; i < r.FieldCount; i++) d[r.GetName(i)] = r.IsDBNull(i) ? (object)DBNull.Value : r.GetValue(i); 库值[iid] = d; } } } catch (Exception exSrc) { Log("[显示同步] 读来源表 " + srcTbl + " 异常: " + exSrc.Message); } } } string[] 价列 = 价列csv.Split(','); bool any = false; foreach (DataRow dr in tbl.Rows) { string iid = Convert.ToString(dr["内部ID"]).Trim(); System.Collections.Generic.Dictionary d; if (string.IsNullOrEmpty(iid) || !库值.TryGetValue(iid, out d)) continue; foreach (string cRaw in 价列) { string c = cRaw.Trim(); if (!tbl.Columns.Contains(c) || !d.ContainsKey(c)) continue; object v = d[c]; // 公式列保持数值类型(Format 依赖) if (v != DBNull.Value && tbl.Columns[c].DataType == typeof(double)) { double dd; v = double.TryParse(Convert.ToString(v), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out dd) ? dd : (object)0d; } dr[c] = v; any = true; } } if (any) { dgv.Invalidate(); Log("[显示同步] " + (string.IsNullOrEmpty(tbl.TableName) ? "(合并视图)" : tbl.TableName) + " 价格列已按库刷新(内部ID对齐)"); } } catch (Exception exF) { Log("[显示同步] 刷新 " + (tbl != null ? tbl.TableName : "?") + " 异常: " + exF.Message); } } /// 图号/箱号重名自动后缀:值与同表其他行(除自身行)重复时返回 "名(2)"/"名(3)"…; /// 不重复返回原值。空值不处理。 private static object 重名自动后缀(System.Data.DataTable table, int 自身行, string colName, object 新值) { if (新值 == null || 新值 == DBNull.Value) return 新值; string v = 新值.ToString().Trim(); if (v.Length == 0) return 新值; bool 撞 = false; foreach (System.Data.DataRow r in table.Rows) { if (r.RowState == System.Data.DataRowState.Deleted) continue; if (table.Rows.IndexOf(r) == 自身行) continue; if (string.Equals((r[colName] == null || r[colName] == DBNull.Value) ? "" : r[colName].ToString().Trim(), v, System.StringComparison.Ordinal)) { 撞 = true; break; } } if (!撞) return 新值; for (int n = 2; n < 1000; n++) { string 候 = v + "(" + n + ")"; bool 再撞 = false; foreach (System.Data.DataRow r in table.Rows) { if (r.RowState == System.Data.DataRowState.Deleted) continue; if (string.Equals((r[colName] == null || r[colName] == DBNull.Value) ? "" : r[colName].ToString().Trim(), 候, System.StringComparison.Ordinal)) { 再撞 = true; break; } } if (!再撞) return 候; } return 新值; // 找不到空位(几乎不可能)就用原值 } // ============================================================ // 价格显示格式化(用 DefaultCellStyle.Format,只改显示不改值) // - 这是 DataGridView 原生的“只改显示”机制,不干扰用户输入 // - 仅作用于公式计算列(见 s_公式列集合);用户输入列(表价/数量/系数)不参与 // ============================================================ /// 公式计算列集合(只包含程序算出来的价格列,不包含用户输入的表价)。 /// ★ 口径(2026-09-29):毛利率(百分数值 4.76)入集合获得小数位格式化; /// 利润率(小数 0.05)不入集合,保持原样显示。 private static readonly System.Collections.Generic.HashSet s_公式列集合 = new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase) { // 元件层(表价是用户输入,也需要格式化显示) "表价", "采购单价", "采购价", "报出单价", "报出价", // 箱号层公式列 "单台成本", "成本价", "单台报出", "利润", "毛利率", // 图号层公式列 "单价", "总价", "成本总价", "毛利", // 项目层公式列 "成本总价", "报出总价", "利润" }; /// /// 把小数位配置(“保留整数”/“保留2位小数”等)转换成 .NET 格式字符串。 /// 例:“保留整数” → "F0";“保留2位小数” → "F2”。 /// 用 F 而不是 N(不要千分位逗号)。 /// private static string ToFormatString(string 小数位文本) { int n = 价格格式化.解析小数位(小数位文本); if (n < 0) n = 2; // 未配置默认 2 位 return "F" + n.ToString(); } /// /// 老库兼容:把 DataTable 中公式列的类型从 string 转为 double。 /// 新库建表时公式列是 REAL(Fill 后自动是 double);老库可能是 TEXT(string), /// string 值的 DefaultCellStyle.Format 不生效,需要转换。 /// 如果公式列已经是 double 则原样返回。 /// private static DataTable ConvertFormulaColumnsToDouble(DataTable table) { if (table == null || table.Columns.Count == 0) return table; // 检查是否有公式列是 string 类型(老库) bool needsConvert = false; foreach (DataColumn dc in table.Columns) { if (s_公式列集合.Contains(dc.ColumnName) && dc.DataType == typeof(string)) { needsConvert = true; break; } } if (!needsConvert) return table; // 新建 DataTable,公式列用 double,其他列保持原类型 var newTable = new DataTable(); foreach (DataColumn dc in table.Columns) { Type t = s_公式列集合.Contains(dc.ColumnName) ? typeof(double) : dc.DataType; newTable.Columns.Add(dc.ColumnName, t); } // 复制数据,公式列从 string 解析为 double foreach (DataRow row in table.Rows) { var newRow = newTable.NewRow(); foreach (DataColumn dc in table.Columns) { if (s_公式列集合.Contains(dc.ColumnName)) { object v = row[dc]; if (v == null || v == DBNull.Value) newRow[dc.ColumnName] = 0d; else if (v is double) newRow[dc.ColumnName] = v; else { double d; newRow[dc.ColumnName] = double.TryParse(v.ToString().Trim(), out d) ? d : 0d; } } else { newRow[dc.ColumnName] = row[dc]; } } newTable.Rows.Add(newRow); } newTable.TableName = table.TableName; return newTable; } /// /// 给指定 DataGridView 的所有公式计算列应用小数位格式。 /// 只设 DefaultCellStyle.Format,不设 ValueType(避免 DataError 导致单元格不显示)。 /// ★ 同一表格+同一格式只应用一次(每次 Reload 重跑是重复开销);小数位配置变化时由 /// ApplyPriceFormatsToAllGrids 清缓存强制重刷。 /// private static readonly System.Collections.Generic.HashSet _已应用格式 = new System.Collections.Generic.HashSet(); private void ApplyPriceFormat(DataGridView dgv, string 小数位文本) { if (dgv == null || dgv.Columns == null) return; string fmt = ToFormatString(小数位文本); string key = dgv.Name + "|" + fmt; lock (_已应用格式) { if (!_已应用格式.Add(key)) return; // 已应用过,跳过 } // 1) 设 Format + ValueType foreach (DataGridViewColumn col in dgv.Columns) { if (col == null) continue; string name = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.HeaderText; if (string.IsNullOrEmpty(name)) continue; if (s_公式列集合.Contains(name)) { // ★ 毛利率带 % 显示(2026-09-29):值已是百分数值(4.76), // 用字面量 % 格式(不能用 P 格式,那会再乘100) if (string.Equals(name, "毛利率", System.StringComparison.OrdinalIgnoreCase)) col.DefaultCellStyle.Format = "0.##\"%\""; else col.DefaultCellStyle.Format = fmt; col.DefaultCellStyle.NullValue = ""; col.ValueType = typeof(double); } } // 2) 非虚拟模式的表格(如项目表):保留原"遍历行把公式列 string 转 double"的兼容逻辑。 // 虚拟模式的三表(9/4/5)跳过——写 cell.Value 无效,数值化在读通道 Dgv_CellValueNeeded 处理。 if (dgv.VirtualMode) return; if (dgv.Rows != null) { foreach (DataGridViewRow row in dgv.Rows) { if (row == null || row.IsNewRow) continue; foreach (DataGridViewColumn col in dgv.Columns) { if (col == null) continue; string name = !string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.HeaderText; if (!s_公式列集合.Contains(name)) continue; var cell = row.Cells[col.Index]; if (cell == null || cell.Value == null) continue; if (cell.Value is double) continue; if (cell.Value is decimal) { cell.Value = (double)(decimal)cell.Value; continue; } string s = cell.Value.ToString().Trim(); double d; if (double.TryParse(s, out d)) { cell.Value = d; } else if (string.IsNullOrEmpty(s)) { cell.Value = 0d; } } } } } /// /// 母排元件行不受项目"小数位/取整方式"设置限制:价格列按自然值显示 /// (0.########## = 有几位小数显几位、不四舍五入、无尾零),其余行仍走列级设置格式。 /// 仅元件表挂接;虚拟模式行号与内存表 1:1(同 Dgv_CellValueNeeded 的取数通道), /// 经"类型"列=="母排"判定(母排工具写入的行见 主窗口.母排与备份.cs)。 /// private void Grid5_母排CellFormatting(object sender, System.Windows.Forms.DataGridViewCellFormattingEventArgs e) { try { var dgv = sender as System.Windows.Forms.DataGridView; if (dgv == null || e.ColumnIndex < 0 || e.RowIndex < 0) return; var col = dgv.Columns[e.ColumnIndex]; if (col == null) return; string colName = col.DataPropertyName; if (string.IsNullOrEmpty(colName) || !s_公式列集合.Contains(colName)) return; var table = 取dgv数据表(dgv); if (table == null || e.RowIndex >= table.Rows.Count || !table.Columns.Contains("类型")) return; object t = table.Rows[e.RowIndex]["类型"]; if (t != null && t != DBNull.Value && t.ToString().Trim() == "母排") e.CellStyle.Format = "0.##########"; } catch { } } /// /// 进入项目后,按当前项目的价格格式化配置,应用 DefaultCellStyle.Format 到 4 个表格的公式列。 /// 同时把 DataTable 里公式列的值从 string 转成 decimal(Format 只对数值类型生效)。 /// private void ApplyPriceFormatsToAllGrids() { lock (_已应用格式) { _已应用格式.Clear(); } // 小数位配置变化,强制全部重刷 // 单台层:元件表/箱号表/图号表 ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本); ApplyPriceFormat(this.advancedDataGridView9, _单台小数位文本); // 项目层:项目表 ApplyPriceFormat(this.advancedDataGridView6, _项目小数位文本); } /// 判断当前表格是不是项目表(advancedDataGridView6)。项目表用项目层配置。 private static bool IsProjectGrid(DataGridView dgv) { // advancedDataGridView6 是项目表;其他三个(9/4/5)是单台层 // 用名称匹配,避免依赖字段顺序 return dgv != null && dgv.Name != null && dgv.Name.IndexOf("6", System.StringComparison.Ordinal) >= 0 && dgv.Name.IndexOf("advancedDataGridView", System.StringComparison.OrdinalIgnoreCase) >= 0; } /// /// 从报价库的「项目信息」表读取当前项目的 4 个价格格式化配置字段, /// 更新到内存配置变量。库/表/字段不存在时保持默认值(保留2位小数/不取整)。 /// private void LoadPriceFormatConfig(string projectId) { // 先重置为默认值,避免上一个项目的配置串过来 _单台小数位文本 = "保留2位小数"; _单台取整方式 = "不取整"; _项目小数位文本 = "保留2位小数"; _项目取整方式 = "不取整"; try { var info = GetProjectInfoFromQuoteDb(projectId); if (info == null || info.Count == 0) return; string v; if (info.TryGetValue("单台小数位", out v) && !string.IsNullOrWhiteSpace(v)) _单台小数位文本 = v; if (info.TryGetValue("单台取整方式", out v) && !string.IsNullOrWhiteSpace(v)) _单台取整方式 = v; if (info.TryGetValue("项目小数位", out v) && !string.IsNullOrWhiteSpace(v)) _项目小数位文本 = v; if (info.TryGetValue("项目取整方式", out v) && !string.IsNullOrWhiteSpace(v)) _项目取整方式 = v; } catch { // 读不到不阻断,沿用默认值 } // 配置加载完毕,统一应用到 4 个表格的公式列(DefaultCellStyle.Format) ApplyPriceFormatsToAllGrids(); } /// 按 DataPropertyName 或 HeaderText 找列(因为 UI 隐藏了部分列)。 private static DataGridViewColumn FindColumn(DataGridView dgv, string name) { if (dgv == null || dgv.Columns == null) return null; foreach (DataGridViewColumn c in dgv.Columns) { if (c == null) continue; if (c.DataPropertyName == name || c.HeaderText == name || c.Name == name) return c; } return null; } /// 取某行某列的字符串值(按 DataPropertyName/HeaderText/Name 匹配)。 private static string GetCellString(DataGridViewRow row, string name) { if (row == null || row.DataGridView == null) return ""; DataGridViewColumn col = FindColumn(row.DataGridView, name); if (col == null) return ""; object v = row.Cells[col.Index].Value; return v == null ? "" : v.ToString().Trim(); } /// 取某行某列的 decimal 值。 private static decimal GetCellDecimal(DataGridViewRow row, string name) { return ParseDecimal(GetCellString(row, name)); } // ---------- 元件表重算(每行独立) ---------- /// /// 重算单行元件的派生列(对应 advancedDataGridView5Table Excel 公式): /// 采购单价(I) = 表价(H) × 采购系数(J) /// 采购价(K) = 采购单价(I) × 数量(G) /// 报出单价(L) = 采购单价(I) × 报出系数(M) /// 报出价(N) = 报出单价(L) × 数量(G) /// private void RecalcYuanjianRow(DataGridViewRow row) { if (row == null) return; // 按 advancedDataGridView5Table Excel 公式: // 采购单价(I) = 表价(H) × 采购系数(J) // 采购价(K) = 采购单价(I) × 数量(G) // 报出单价(L) = 采购单价(I) × 报出系数(M) // 报出价(N) = 报出单价(L) × 数量(G) decimal biaoJia = GetCellDecimal(row, "表价"); decimal caiGouXiShu = GetCellDecimal(row, "采购系数"); decimal baoChuXiShu = GetCellDecimal(row, "报出系数"); decimal shuLiang = GetCellDecimal(row, "数量"); // ★ A2(2026-09-30):公式统一调 报价公式(单一来源,三处重算同源) decimal caiGouDanJia, caiGouJia, baoChuDanJia, baoChuJia; 报价公式.元件(biaoJia, caiGouXiShu, baoChuXiShu, shuLiang, out caiGouDanJia, out caiGouJia, out baoChuDanJia, out baoChuJia); SetCell(row, "采购单价", caiGouDanJia); SetCell(row, "采购价", caiGouJia); SetCell(row, "报出单价", baoChuDanJia); SetCell(row, "报出价", baoChuJia); } // ---------- 箱号表重算(聚合当前箱号所有元件) ---------- /// /// 聚合当前箱号(_currentTuhao + _currentXianghao)的所有元件, /// 重算箱号表当前行的派生列(对应 Excel 箱号表公式,合计行引用): /// 单台成本 = SUM(该箱号元件.采购价) /// 成本价 = 单台成本 × 数量 /// 单台报出 = SUM(该箱号元件.报出价) /// 报出价 = 单台报出 × 数量 /// 利润 = 报出价 − 成本价 /// 利润率 = 利润 / 成本价 (小数,0.05=5%) /// 毛利率 = 利润 / 报出价 × 100 (百分数值,4.76=4.76%) /// private void RecalcXianghaoRowFromYuanjian(DataGridViewRow xianghaoRow) { if (xianghaoRow == null) return; // 聚合当前箱号下所有元件的 采购价/报出价 decimal sumCaiGouJia = 0m; decimal sumBaoChuJia = 0m; DataGridView dgv5 = this.advancedDataGridView5; if (dgv5 != null && dgv5.Rows != null) { foreach (DataGridViewRow yj in dgv5.Rows) { if (yj == null || yj.IsNewRow) continue; sumCaiGouJia += GetCellDecimal(yj, "采购价"); sumBaoChuJia += GetCellDecimal(yj, "报出价"); } } decimal shuLiang = GetCellDecimal(xianghaoRow, "数量"); // ★ A2(2026-09-30):公式统一调 报价公式(口径:利润率小数,毛利率百分数值) decimal danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, liRun, liRunLv, maoLiLv; 报价公式.箱号(sumCaiGouJia, sumBaoChuJia, shuLiang, out danTaiChengBen, out chengBenJia, out danTaiBaoChu, out baoChuJia, out liRun, out liRunLv, out maoLiLv); SetCell(xianghaoRow, "单台成本", danTaiChengBen); SetCell(xianghaoRow, "成本价", chengBenJia); SetCell(xianghaoRow, "单台报出", danTaiBaoChu); SetCell(xianghaoRow, "报出价", baoChuJia); SetCell(xianghaoRow, "利润", liRun); SetCell(xianghaoRow, "利润率", liRunLv); SetCell(xianghaoRow, "毛利率", maoLiLv); } // ---------- 图号表重算(聚合当前图号所有箱号) ---------- /// /// 聚合当前图号(_currentTuhao)的所有箱号, /// 重算图号表当前行的派生列(对应 Excel 图号表公式,合计行引用): /// 箱柜数量 = SUM(该图号箱号.数量) /// 单价 = SUM(该图号箱号.报出价) /// 总价 = 单价 × 台数 /// 成本总价 = SUM(该图号箱号.成本价) × 台数 /// 毛利 = 总价 − 成本总价 /// 毛利率 = 毛利 / 总价 × 100 (百分数值,与项目页/方案页口径一致) /// private void RecalcTuhaoRowFromXianghao(DataGridViewRow tuhaoRow) { if (tuhaoRow == null) return; // 从数据库重新读取该图号所有箱号的聚合(因为其他箱号可能不在当前表格视图里) // 当前 advancedDataGridView4 只显示当前图号的箱号,所以可以直接聚合表格行 decimal sumShuLiang = 0m; decimal sumBaoChuJia = 0m; decimal sumChengBenJia = 0m; DataGridView dgv4 = this.advancedDataGridView4; if (dgv4 != null && dgv4.Rows != null) { foreach (DataGridViewRow xh in dgv4.Rows) { if (xh == null || xh.IsNewRow) continue; sumShuLiang += GetCellDecimal(xh, "数量"); sumBaoChuJia += GetCellDecimal(xh, "报出价"); sumChengBenJia += GetCellDecimal(xh, "成本价"); } } decimal taiShu = GetCellDecimal(tuhaoRow, "台数"); // ★ A2(2026-09-30):公式统一调 报价公式(总价=单价×台数,不乘箱柜数量——单价已是全部箱号报出价合计) decimal xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv; 报价公式.图号(sumShuLiang, sumBaoChuJia, sumChengBenJia, taiShu, out xiangGuiShuLiang, out danJia, out zongJia, out chengBenZongJia, out maoLi, out maoLiLv); // ★ 排障:图号重算读值对照(grid 读值 vs 内存表值),定位"报出价读到成本值"的分裂点 try { var xhTable = 取dgv数据表(dgv4); string trace = ""; foreach (DataGridViewRow xh in dgv4.Rows) { if (xh == null || xh.IsNewRow) continue; string g报价 = GetCellString(xh, "报出价"), g成本 = GetCellString(xh, "成本价"); string m报价 = (xhTable != null && xh.Index < xhTable.Rows.Count && xhTable.Columns.Contains("报出价")) ? Convert.ToString(xhTable.Rows[xh.Index]["报出价"]) : "(无表)"; trace += "[行" + xh.Index + " grid报出=" + g报价 + " 表报出=" + m报价 + " grid成本=" + g成本 + "] "; } Log("[图号重算trace] 台数=" + taiShu + " sum报出=" + sumBaoChuJia + " sum成本=" + sumChengBenJia + " " + trace); } catch { } SetCell(tuhaoRow, "箱柜数量", xiangGuiShuLiang); SetCell(tuhaoRow, "单价", danJia); SetCell(tuhaoRow, "总价", zongJia); SetCell(tuhaoRow, "成本总价", chengBenZongJia); SetCell(tuhaoRow, "毛利", maoLi); SetCell(tuhaoRow, "毛利率", maoLiLv); // 图号重算完,联动重算项目表(第 4 级聚合)并立即存库 RecalcProjectRowFromTuhao(); } /// /// 数据加载(Reload)后的重算:让新建/删除后受影响的行立即显示计算结果。 /// 注意:箱号行/图号行的重算依赖“该箱号/图号自己名下的子数据”, /// 而 UI 表格一次只显示当前选中层的数据,所以这里只能重算: /// - 所有元件行(底层,每行自洽) /// - 当前选中的箱号行(聚合当前元件表) /// - 当前选中的图号行(聚合当前箱号表) /// 不能遍历箱号/图号表所有行去重算(否则会把所有行算成当前层的数据,串数据)。 /// 重算后存库 + AcceptChanges,避免下次 Reload 重复写入。 /// private void RecalcAllAfterReload() { // 没有项目时无需重算 if (string.IsNullOrWhiteSpace(_currentProjectId)) return; // 双 guard:既挡 CellValueChanged/Grid_DirtyStateChanged,也挡 SelectionChanged, // 防止 Save 里的 EndEdit 触发联动刷新形成死循环。 _isRecalculating = true; _isReloading = true; try { // 1) 重算所有元件行(每行独立,数据自洽) DataGridView dgv5 = this.advancedDataGridView5; if (dgv5 != null && dgv5.Rows != null) { foreach (DataGridViewRow yj in dgv5.Rows) { if (yj == null || yj.IsNewRow) continue; RecalcYuanjianRow(yj); } } // 2) 只重算当前选中的箱号行(它聚合的是当前元件表的数据) var xhRow = GetFirstSelectedRow(this.advancedDataGridView4); if (xhRow != null) { RecalcXianghaoRowFromYuanjian(xhRow); } // 3) 只重算当前选中的图号行(它聚合的是当前箱号表的数据) var thRow = GetFirstSelectedRow(this.advancedDataGridView9); if (thRow != null) { RecalcTuhaoRowFromXianghao(thRow); } // 重算改了单元格值,现在把完整内存数据存库,然后 AcceptChanges。 // 顺序很重要:先 Save(删旧行+插当前所有行),再 AcceptChanges(让内存标记为干净)。 // 这样既持久化了重算结果,又避免下次 Reload 开头的 Save 重复写入(串数据 bug)。 // ★ 触发器已退役(改为应用层集中刷新),派生值不会自动落库,必须显式 Save。 // 否则粘贴/插入后 RecalcAllAfterReload 重算的 采购单价/采购价/报出单价/报出价 等 // 只在内存,刷新(Reload)后从库读到的是 INSERT 时的原始值,重算结果丢失。 try { if (_yuanjianTable != null && _yuanjianTable.Rows.Count > 0 && !string.IsNullOrWhiteSpace(_yuanjianTable.TableName)) SaveTableToDatabase(_yuanjianTable, _currentProjectId, "\"" + _yuanjianTable.TableName + "\"", null, null, true); if (_xianghaoTable != null && _xianghaoTable.Rows.Count > 0 && !string.IsNullOrWhiteSpace(_xianghaoTable.TableName)) SaveTableToDatabase(_xianghaoTable, _currentProjectId, "\"" + _xianghaoTable.TableName + "\"", null, null, true); if (_tuhaoTable != null && _tuhaoTable.Rows.Count > 0) SaveTableToDatabase(_tuhaoTable, _currentProjectId, TuhaoTableName, null, null, true); } catch (Exception exSave) { Log("[RecalcAllAfterReload] 保存重算结果失败: " + exSave.Message); } if (_yuanjianTable != null) _yuanjianTable.AcceptChanges(); if (_xianghaoTable != null) _xianghaoTable.AcceptChanges(); if (_tuhaoTable != null) _tuhaoTable.AcceptChanges(); } finally { _isRecalculating = false; _lastRecalcTime = DateTime.Now; // 记录重算结束时间,Grid_DirtyStateChanged 用此过滤假变脏 _isReloading = false; 刷新状态栏(); // 状态栏:重算时间更新 } // ★ 项目页全局同步收口:几乎所有数据变更(新建图号/箱号/元件、复制粘贴、删除、 // 各工具窗口插入、编辑)最终都经过本方法——在此触发一次,同步就全局生效 // (800ms 防抖自动合并频发调用),不再只靠单元格编辑那一个入口。 启动项目页同步(); } // ---------- 全量数据库重算(不依赖内存/选中状态) ---------- /// /// 直接读数据库,把指定项目下所有图号、所有箱号的派生列重算并 UPDATE 回数据库。 /// 解决"只重算当前选中行,没点过的箱号/图号派生列永远是空"的问题。 /// 重算公式与 Excel 模板、RecalcXianghaoRowFromYuanjian、RecalcTuhaoRowFromXianghao 完全一致: /// 箱号行:单台成本=SUM(元件.采购价), 成本价=单台成本×数量, 单台报出=SUM(元件.报出价), /// 报出价=单台报出×数量, 利润=报出价−成本价, 利润率=(成本价=0?0:利润/成本价), 毛利率=(报出价=0?0:利润/报出价×100) /// 图号行:箱柜数量=SUM(箱号.数量), 单价=SUM(箱号.报出价), 总价=单价×箱柜数量×台数, /// 成本总价=SUM(箱号.成本价)×台数, 毛利=总价−成本总价, 毛利率=(总价=0?0:毛利/总价×100) /// 项目行(写回项目库索引):箱柜台数=SUM(图号.箱柜数量), 成本总价=SUM(图号.成本总价), /// 报出总价=SUM(图号.总价), 利润=报出总价−成本总价, 利润率=(成本总价=0?0:利润/成本总价) /// ★ 口径(2026-09-29):箱号/图号的毛利率存百分数值(×100,保留2位,如 4.76=4.76%); /// 利润率统一存小数(保留2位,0.05=5%) /// /// 项目编号;空则用 _currentProjectId。 /// ★ B2(2026-10-01)增量重算范围:键="图号\n箱号内部ID"。 /// null/空=全量重算(进项目/删改等低频操作); /// 非空=只重算范围内的箱号行(从元件逐行重算),其图号行聚合用"重算新值+未受影响箱号库值", /// 项目汇总用"全部图号聚合列(受影响图号新值覆盖)",大项目编辑不再全库重算。 private void RecalcAllFromDatabase(string projectId, System.Collections.Generic.HashSet scope = null) { if (string.IsNullOrWhiteSpace(projectId)) projectId = _currentProjectId; if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetQuoteDbPath(projectId); if (!System.IO.File.Exists(dbPath)) return; // ★ B3(2026-10-01):重算耗时观测(全量/增量分别统计,日志可对比) var sw = System.Diagnostics.Stopwatch.StartNew(); bool incremental = scope != null && scope.Count > 0; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); // ★ B2(2026-10-01):全部派生列/聚合 UPDATE 聚成单事务提交—— // 原先每条 UPDATE 自动提交(一次提交=一次锁竞争+日志),箱号多时放大耗时; // DEFERRED 事务首个写操作才上写锁,读阶段不阻塞直写队列;异常自动回滚(全有或全无)。 var recalcTx = conn.BeginTransaction(); // ★ B2 增量分支:只重算受影响箱号,图号聚合混合新值+库值,项目汇总读全部图号聚合列 // scope 按图号分组: 图号 → 箱号内部ID集合 var thGroups = new System.Collections.Generic.Dictionary>(); if (incremental) { foreach (var k in scope) { if (string.IsNullOrEmpty(k)) continue; int sp = k.IndexOf('\n'); if (sp <= 0 || sp >= k.Length - 1) continue; string th = k.Substring(0, sp); string xh = k.Substring(sp + 1); System.Collections.Generic.HashSet set; if (!thGroups.TryGetValue(th, out set)) { set = new System.Collections.Generic.HashSet(); thGroups[th] = set; } set.Add(xh); } if (thGroups.Count == 0) incremental = false; // scope 键全部无效→回退全量 } if (incremental) { // 全部图号行的聚合值(内部ID→[箱柜数量,成本总价,总价];受影响图号稍后用新值覆盖) var thAgg = new System.Collections.Generic.Dictionary(); using (var selAgg = new System.Data.SQLite.SQLiteCommand( "SELECT 内部ID, 箱柜数量, 成本总价, 总价 FROM " + TuhaoTableName + ";", conn)) using (var rdAgg = selAgg.ExecuteReader()) { while (rdAgg.Read()) { string iid = rdAgg.IsDBNull(0) ? "" : rdAgg.GetValue(0).ToString().Trim(); if (string.IsNullOrEmpty(iid)) continue; thAgg[iid] = new[] { ParseDecimal(rdAgg.IsDBNull(1) ? "" : rdAgg.GetValue(1)), ParseDecimal(rdAgg.IsDBNull(2) ? "" : rdAgg.GetValue(2)), ParseDecimal(rdAgg.IsDBNull(3) ? "" : rdAgg.GetValue(3)) }; } } foreach (var g in thGroups) { string th = g.Key; try { // 图号行:rowid+台数(按内部ID) long thRid = 0; decimal taiShu = 0m; using (var selTh = new System.Data.SQLite.SQLiteCommand( "SELECT rowid, 台数 FROM " + TuhaoTableName + " WHERE 内部ID=@th;", conn)) { selTh.Parameters.AddWithValue("@th", th); using (var rdTh = selTh.ExecuteReader()) { if (rdTh.Read()) { thRid = rdTh.GetInt64(0); taiShu = ParseDecimal(rdTh.IsDBNull(1) ? "" : rdTh.GetValue(1)); } } } if (thRid <= 0) continue; // 图号已删,跳过 // 该图号全部箱号行:rowid/数量/内部ID + 聚合列(报出价/成本价——未受影响箱号直接用库值) string xhTable = GetXianghaoTableByIndex(th, conn); if (string.IsNullOrEmpty(xhTable)) { // 无箱号表:图号派生列置0 UpdateTuhaoCalc(conn, thRid, 0m, 0m, 0m, 0m, 0m, 0m); thAgg[th] = new decimal[] { 0m, 0m, 0m }; continue; } string xhQuoted = "\"" + xhTable + "\""; decimal sumShu = 0m, sumBC = 0m, sumCB = 0m; var xhList = new List(); using (var selXh = new System.Data.SQLite.SQLiteCommand( "SELECT rowid, 数量, 内部ID, 报出价, 成本价 FROM " + xhQuoted + " ORDER BY rowid;", conn)) using (var rdXh = selXh.ExecuteReader()) { while (rdXh.Read()) { var x = new XianghaoCalcRow(); x.RowId = rdXh.GetInt64(0); x.ShuLiang = ParseDecimal(rdXh.IsDBNull(1) ? "" : rdXh.GetValue(1)); x.Xianghao = rdXh.IsDBNull(2) ? "" : rdXh.GetValue(2).ToString(); x.BaoChuJia = ParseDecimal(rdXh.IsDBNull(3) ? "" : rdXh.GetValue(3)); x.ChengBenJia = ParseDecimal(rdXh.IsDBNull(4) ? "" : rdXh.GetValue(4)); xhList.Add(x); } } foreach (var x in xhList) { if (g.Value.Contains(x.Xianghao)) { // 受影响箱号:从元件逐行重算 decimal sumCaiGou = 0m, sumBaoChu = 0m; string yjTable = GetYuanjianTableByIndex(th, x.Xianghao, conn); if (!string.IsNullOrEmpty(yjTable)) { string yjQuoted = "\"" + yjTable + "\""; // ★ B2(2026-10-01):与全量同款——含费用行实时联动 重算元件表聚合含费用联动(conn, yjQuoted, out sumCaiGou, out sumBaoChu); } // ★ A2(2026-09-30):公式统一调 报价公式(利润率小数/毛利率百分数值) decimal danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, xhLiRun, xhLiRunLv, xhMaoLiLv; 报价公式.箱号(sumCaiGou, sumBaoChu, x.ShuLiang, out danTaiChengBen, out chengBenJia, out danTaiBaoChu, out baoChuJia, out xhLiRun, out xhLiRunLv, out xhMaoLiLv); UpdateXianghaoCalc(conn, xhQuoted, x.RowId, danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, xhLiRun, xhLiRunLv, xhMaoLiLv); sumShu += x.ShuLiang; sumBC += baoChuJia; sumCB += chengBenJia; } else { // 未受影响箱号:直接用库里的聚合列值参与图号聚合(之前重算已写好) sumShu += x.ShuLiang; sumBC += x.BaoChuJia; sumCB += x.ChengBenJia; } } // 图号行聚合 ★ A2:公式统一调 报价公式(总价=单价×台数) decimal xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv; 报价公式.图号(sumShu, sumBC, sumCB, taiShu, out xiangGuiShuLiang, out danJia, out zongJia, out chengBenZongJia, out maoLi, out maoLiLv); UpdateTuhaoCalc(conn, thRid, xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv); thAgg[th] = new decimal[] { xiangGuiShuLiang, chengBenZongJia, zongJia }; } catch (Exception exTh) { Log("[重算·增量] 图号[" + th + "] 异常: " + exTh.Message); } } // 项目汇总 = 全部图号(未受影响的库聚合值 + 受影响的新值) decimal sumPXG = 0m, sumPCB = 0m, sumPZJ = 0m; foreach (var kv in thAgg) { sumPXG += kv.Value[0]; sumPCB += kv.Value[1]; sumPZJ += kv.Value[2]; } decimal projLiRun = sumPZJ - sumPCB; decimal projLiRunLv = sumPCB == 0m ? 0m : Math.Round(projLiRun / sumPCB, 2); UpdateProjectQuoteTotalsById(projectId, sumPXG, sumPCB, sumPZJ, projLiRun, projLiRunLv); sw.Stop(); Log("[重算·增量] 箱号" + scope.Count + "个 图号" + thGroups.Count + "个 耗时 " + sw.ElapsedMilliseconds + "ms"); } // 全量分支(原逻辑):scope为空或解析失败;进项目/删改等低频操作走这里 if (!incremental) { // 读所有图号行(rowid 顺序)——★ 读"内部ID"列(不是"图号"显示名列), // 用户改图号名后,表索引里存的仍是内部ID,用内部ID查才能正确关联箱号表 var tuhaoRows = new List(); string selTuhao = "SELECT rowid, 台数, 内部ID FROM " + TuhaoTableName + " ORDER BY rowid;"; using (var sel = new System.Data.SQLite.SQLiteCommand(selTuhao, conn)) using (var rdr = sel.ExecuteReader()) { while (rdr.Read()) { var t = new TuhaoCalcRow(); t.RowId = rdr.GetInt64(0); t.TaiShu = ParseDecimal(rdr.IsDBNull(1) ? "" : rdr.GetValue(1)); t.Tuhao = rdr.IsDBNull(2) ? "" : rdr.GetValue(2).ToString().Trim(); tuhaoRows.Add(t); } } Log("[重算] 共读取 " + tuhaoRows.Count + " 个图号"); decimal sumProjXiangGui = 0m; // 项目:箱柜台数 decimal sumProjChengBen = 0m; // 项目:成本总价 decimal sumProjZongJia = 0m; // 项目:报出总价 // 遍历每个图号 foreach (var t in tuhaoRows) { if (string.IsNullOrWhiteSpace(t.Tuhao)) continue; try { // ★ 复用外层连接查箱号表名(不再各自开新连接) string xhTable = GetXianghaoTableByIndex(t.Tuhao, conn); if (string.IsNullOrEmpty(xhTable)) { // 没有箱号表 → 图号行的派生列全是 0/空 UpdateTuhaoCalc(conn, t.RowId, 0m, 0m, 0m, 0m, 0m, 0m); Log("[重算] 图号[" + t.Tuhao + "] 无箱号表,派生列置0"); continue; } string xhQuoted = "\"" + xhTable + "\""; decimal sumThXiangGuiShuLiang = 0m; // 图号:箱柜数量=SUM(箱号.数量) decimal sumThBaoChuJia = 0m; // 图号:单价=SUM(箱号.报出价) decimal sumThChengBenJia = 0m; // 图号:成本总价=SUM(箱号.成本价) // ★ 合并:一次 SELECT 同时读 rowid/数量/内部ID(原来分两次查同一张表) // ★ 读"内部ID"列(不是"箱号"显示名列),用户改箱号名后表索引仍是内部ID var xhRows = new List(); string selXh = "SELECT rowid, 数量, 内部ID FROM " + xhQuoted + " ORDER BY rowid;"; using (var sel = new System.Data.SQLite.SQLiteCommand(selXh, conn)) using (var rdr = sel.ExecuteReader()) { while (rdr.Read()) { var x = new XianghaoCalcRow(); x.RowId = rdr.GetInt64(0); x.ShuLiang = ParseDecimal(rdr.IsDBNull(1) ? "" : rdr.GetValue(1)); x.Xianghao = rdr.IsDBNull(2) ? "" : rdr.GetValue(2).ToString(); xhRows.Add(x); } } // 遍历每个箱号,从其元件表聚合 foreach (var x in xhRows) { try { sumThXiangGuiShuLiang += x.ShuLiang; decimal sumCaiGou = 0m; decimal sumBaoChu = 0m; // ★ 复用外层连接查元件表名 string yjTable = GetYuanjianTableByIndex(t.Tuhao, x.Xianghao, conn); if (!string.IsNullOrEmpty(yjTable)) { string yjQuoted = "\"" + yjTable + "\""; // ★ A2+B2(2026-10-01):元件表重算统一入口(含费用行实时联动)—— // 普通行按公式重算;费用行按"规格"列公式以最新基数重写表价后再算 重算元件表聚合含费用联动(conn, yjQuoted, out sumCaiGou, out sumBaoChu); } // ★ A2(2026-09-30):公式统一调 报价公式(利润率小数/毛利率百分数值) decimal danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, xhLiRun, xhLiRunLv, xhMaoLiLv; 报价公式.箱号(sumCaiGou, sumBaoChu, x.ShuLiang, out danTaiChengBen, out chengBenJia, out danTaiBaoChu, out baoChuJia, out xhLiRun, out xhLiRunLv, out xhMaoLiLv); sumThBaoChuJia += baoChuJia; sumThChengBenJia += chengBenJia; // UPDATE 箱号行派生列(WHERE rowid) UpdateXianghaoCalc(conn, xhQuoted, x.RowId, danTaiChengBen, chengBenJia, danTaiBaoChu, baoChuJia, xhLiRun, xhLiRunLv, xhMaoLiLv); } catch (Exception exXh) { Log("[重算] 图号[" + t.Tuhao + "] 箱号[" + x.Xianghao + "] 异常: " + exXh.Message); } } // 图号行聚合 ★ A2(2026-09-30):公式统一调 报价公式(总价=单价×台数) decimal xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv; 报价公式.图号(sumThXiangGuiShuLiang, sumThBaoChuJia, sumThChengBenJia, t.TaiShu, out xiangGuiShuLiang, out danJia, out zongJia, out chengBenZongJia, out maoLi, out maoLiLv); sumProjXiangGui += xiangGuiShuLiang; sumProjChengBen += chengBenZongJia; sumProjZongJia += zongJia; UpdateTuhaoCalc(conn, t.RowId, xiangGuiShuLiang, danJia, zongJia, chengBenZongJia, maoLi, maoLiLv); Log("[重算] 图号[" + t.Tuhao + "] 箱号" + xhRows.Count + "个 总价=" + zongJia.ToString("F2")); } catch (Exception exTh) { Log("[重算] 图号[" + t.Tuhao + "] 异常: " + exTh.Message); } } // 项目行汇总(写回项目库索引的 5 列) decimal projLiRun = sumProjZongJia - sumProjChengBen; // ★ 口径(2026-09-29):项目利润率存小数(保留2位,0.05=5%),与箱号表利润率口径一致 decimal projLiRunLv = sumProjChengBen == 0m ? 0m : Math.Round(projLiRun / sumProjChengBen, 2); UpdateProjectQuoteTotalsById(projectId, sumProjXiangGui, sumProjChengBen, sumProjZongJia, projLiRun, projLiRunLv); // ★ B3(2026-10-01):全量耗时观测(与增量日志对比,量化 B2 优化效果) sw.Stop(); Log("[重算·全量] 图号" + tuhaoRows.Count + "个 耗时 " + sw.ElapsedMilliseconds + "ms"); } // ★ B2(2026-10-01):重算的全部写库聚成单事务,在此一次性提交 recalcTx.Commit(); } } catch (Exception ex) { Log("RecalcAllFromDatabase 异常: " + ex.Message); } // ★ 进项目/全库重算完成后,同步刷新项目页显示(与 RecalcAllAfterReload 同款收口) 启动项目页同步(); // ★ 修复(2026-09-30):进项目走的是 RecalcAllFromDatabase(不经过 RecalcAllAfterReload), // 这里补一次状态栏刷新,否则"项目:"标签不更新(此前一直空白的根因) try { 刷新状态栏(); } catch { } } /// /// ★ 元件表重算统一入口(含费用行实时联动,2026-10-01): /// 第一遍:普通行(类型≠费用)按统一公式(表价×系数×数量)重算四价并累计基数; /// 第二遍:费用行——"规格"列存了公式(元件采购价×N% / 元件报出价×N%)的行 /// 先按【最新基数】重写表价(元件增删改后百分比费用自动跟随,无需重新应用模板), /// 再按统一公式算四价;"固定金额"行表价不动只算价。全部计入聚合。 /// private static void 重算元件表聚合含费用联动( System.Data.SQLite.SQLiteConnection conn, string yjQuoted, out decimal sumCaiGou, out decimal sumBaoChu) { sumCaiGou = 0m; sumBaoChu = 0m; // ---- 第一遍:普通行 ---- using (var sel = new System.Data.SQLite.SQLiteCommand( "SELECT rowid, 表价, 采购系数, 报出系数, 数量 FROM " + yjQuoted + " WHERE IFNULL(类型,'')<>'费用' ORDER BY rowid;", conn)) using (var sr = sel.ExecuteReader()) { while (sr.Read()) { long yjRid = sr.GetInt64(0); decimal yjBiaoJia = ParseDecimal(sr.IsDBNull(1) ? "" : sr.GetValue(1)); decimal yjCgXs = ParseDecimal(sr.IsDBNull(2) ? "" : sr.GetValue(2)); decimal yjBcXs = ParseDecimal(sr.IsDBNull(3) ? "" : sr.GetValue(3)); decimal yjSl = ParseDecimal(sr.IsDBNull(4) ? "" : sr.GetValue(4)); decimal yjCaiGouDanJia, yjCaiGouJia, yjBaoChuDanJia, yjBaoChuJia; 报价公式.元件(yjBiaoJia, yjCgXs, yjBcXs, yjSl, out yjCaiGouDanJia, out yjCaiGouJia, out yjBaoChuDanJia, out yjBaoChuJia); sumCaiGou += yjCaiGouJia; sumBaoChu += yjBaoChuJia; UpdateYuanjianCalc(conn, yjQuoted, yjRid, yjCaiGouDanJia, yjCaiGouJia, yjBaoChuDanJia, yjBaoChuJia); } } // ---- 第二遍:费用行(联动表价 + 算价 + 计入聚合) ---- // 公式存专用隐藏列"费用公式";历史数据回退链:费用公式→备注(中间版)→规格(初版),命中即自愈迁移 EnsureColumns(conn, yjQuoted, new[] { "费用公式" }); using (var selF = new System.Data.SQLite.SQLiteCommand( "SELECT rowid, 费用公式, 备注, 规格, 表价, 采购系数, 报出系数, 数量 FROM " + yjQuoted + " WHERE 类型='费用' ORDER BY rowid;", conn)) using (var srF = selF.ExecuteReader()) { while (srF.Read()) { long rid = srF.GetInt64(0); string gf = srF.IsDBNull(1) ? "" : srF.GetValue(1).ToString().Trim(); string bz = srF.IsDBNull(2) ? "" : srF.GetValue(2).ToString().Trim(); string gg = srF.IsDBNull(3) ? "" : srF.GetValue(3).ToString().Trim(); decimal biaoJia = ParseDecimal(srF.IsDBNull(4) ? "" : srF.GetValue(4)); decimal cgXs = ParseDecimal(srF.IsDBNull(5) ? "" : srF.GetValue(5)); decimal bcXs = ParseDecimal(srF.IsDBNull(6) ? "" : srF.GetValue(6)); decimal sl = ParseDecimal(srF.IsDBNull(7) ? "" : srF.GetValue(7)); // 公式源三级回退:费用公式列 → 备注 → 规格;命中旧位置则迁移到费用公式列并清掉旧位 string 公式 = gf; bool 需迁移 = false; if (公式.IndexOf('×') < 0 && bz.IndexOf('×') >= 0) { 公式 = bz; 需迁移 = true; } else if (公式.IndexOf('×') < 0 && gg.IndexOf('×') >= 0) { 公式 = gg; 需迁移 = true; } else if (公式 != "固定金额" && bz == "固定金额" && gf == "") { 公式 = bz; 需迁移 = true; } // 公式联动:按公式里的百分比与最新基数重写表价(尾段两头去%,兼容旧"×%13%") decimal 新表价 = biaoJia; bool 联动 = false; string 规范公式 = 公式; string 纯百分比 = gg; if (公式.StartsWith("元件采购价×", System.StringComparison.Ordinal)) { decimal p = ParseDecimal(公式.Substring("元件采购价×".Length).Trim('%')); 新表价 = decimal.Round(sumCaiGou * p / 100m, 2); 联动 = true; 规范公式 = "元件采购价×" + p.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture) + "%"; 纯百分比 = p.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture) + "%"; } else if (公式.StartsWith("元件报出价×", System.StringComparison.Ordinal)) { decimal p = ParseDecimal(公式.Substring("元件报出价×".Length).Trim('%')); 新表价 = decimal.Round(sumBaoChu * p / 100m, 2); 联动 = true; 规范公式 = "元件报出价×" + p.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture) + "%"; 纯百分比 = p.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture) + "%"; } // 自愈:公式归位到隐藏列"费用公式",规格改纯百分比,备注清空(备注不显示公式,用户要求) if (联动 && (需迁移 || 规范公式 != 公式 || 纯百分比 != gg || !string.IsNullOrEmpty(bz))) { using (var updG = new System.Data.SQLite.SQLiteCommand( "UPDATE " + yjQuoted + " SET 费用公式=@g, 规格=@s, 备注='' WHERE rowid=@r;", conn)) { updG.Parameters.AddWithValue("@g", 规范公式); updG.Parameters.AddWithValue("@s", 纯百分比); updG.Parameters.AddWithValue("@r", rid); updG.ExecuteNonQuery(); } } if (联动 && 新表价 != biaoJia) { using (var updB = new System.Data.SQLite.SQLiteCommand( "UPDATE " + yjQuoted + " SET 表价=@b WHERE rowid=@r;", conn)) { updB.Parameters.AddWithValue("@b", (double)新表价); updB.Parameters.AddWithValue("@r", rid); updB.ExecuteNonQuery(); } } decimal cgdj, cgj, bcdj, bcj; 报价公式.元件(新表价, cgXs, bcXs, sl, out cgdj, out cgj, out bcdj, out bcj); sumCaiGou += cgj; sumBaoChu += bcj; UpdateYuanjianCalc(conn, yjQuoted, rid, cgdj, cgj, bcdj, bcj); } } } /// 更新元件行派生列(按 rowid 定位):采购单价/采购价/报出单价/报出价。 private static void UpdateYuanjianCalc(System.Data.SQLite.SQLiteConnection conn, string yjQuoted, long rowId, decimal caiGouDanJia, decimal caiGouJia, decimal baoChuDanJia, decimal baoChuJia) { string sql = "UPDATE " + yjQuoted + " SET " + "采购单价=@a, 采购价=@b, 报出单价=@c, 报出价=@d WHERE rowid=@rid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@a", caiGouDanJia == 0m ? (object)DBNull.Value : (object)caiGouDanJia); cmd.Parameters.AddWithValue("@b", caiGouJia == 0m ? (object)DBNull.Value : (object)caiGouJia); cmd.Parameters.AddWithValue("@c", baoChuDanJia == 0m ? (object)DBNull.Value : (object)baoChuDanJia); cmd.Parameters.AddWithValue("@d", baoChuJia == 0m ? (object)DBNull.Value : (object)baoChuJia); cmd.Parameters.AddWithValue("@rid", rowId); cmd.ExecuteNonQuery(); } } /// 更新箱号行派生列(按 rowid 定位)。所有 decimal 统一转字符串写入(TEXT 列)。 private static void UpdateXianghaoCalc(System.Data.SQLite.SQLiteConnection conn, string xhQuoted, long rowId, decimal danTaiChengBen, decimal chengBenJia, decimal danTaiBaoChu, decimal baoChuJia, decimal liRun, decimal liRunLv, decimal maoLiLv) { string sql = "UPDATE " + xhQuoted + " SET " + "单台成本=@a, 成本价=@b, 单台报出=@c, 报出价=@d, " + "利润=@e, 利润率=@f, 毛利率=@g WHERE rowid=@rid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@a", danTaiChengBen == 0m ? (object)DBNull.Value : (object)danTaiChengBen); cmd.Parameters.AddWithValue("@b", chengBenJia == 0m ? (object)DBNull.Value : (object)chengBenJia); cmd.Parameters.AddWithValue("@c", danTaiBaoChu == 0m ? (object)DBNull.Value : (object)danTaiBaoChu); cmd.Parameters.AddWithValue("@d", baoChuJia == 0m ? (object)DBNull.Value : (object)baoChuJia); // ★ 利润三列为 0 是真实计算结果(如 采购系数=报出系数=1 时利润恒 0), // 写 0 而不是 NULL——写 NULL 网格显示空白,看起来像"没计算" cmd.Parameters.AddWithValue("@e", (object)liRun); cmd.Parameters.AddWithValue("@f", liRunLv.ToString()); cmd.Parameters.AddWithValue("@g", maoLiLv.ToString()); cmd.Parameters.AddWithValue("@rid", rowId); cmd.ExecuteNonQuery(); } } /// 更新图号行派生列(按 rowid 定位)。 private static void UpdateTuhaoCalc(System.Data.SQLite.SQLiteConnection conn, long rowId, decimal xiangGuiShuLiang, decimal danJia, decimal zongJia, decimal chengBenZongJia, decimal maoLi, decimal maoLiLv) { string sql = "UPDATE " + TuhaoTableName + " SET " + "箱柜数量=@a, 单价=@b, 总价=@c, 成本总价=@d, 毛利=@e, 毛利率=@f " + "WHERE rowid=@rid;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@a", xiangGuiShuLiang == 0m ? (object)DBNull.Value : (object)xiangGuiShuLiang); cmd.Parameters.AddWithValue("@b", danJia == 0m ? (object)DBNull.Value : (object)danJia); cmd.Parameters.AddWithValue("@c", zongJia == 0m ? (object)DBNull.Value : (object)zongJia); cmd.Parameters.AddWithValue("@d", chengBenZongJia == 0m ? (object)DBNull.Value : (object)chengBenZongJia); cmd.Parameters.AddWithValue("@e", maoLi == 0m ? (object)DBNull.Value : (object)maoLi); cmd.Parameters.AddWithValue("@f", maoLiLv.ToString()); cmd.Parameters.AddWithValue("@rid", rowId); cmd.ExecuteNonQuery(); } } /// 按项目编号把 5 个汇总列 UPDATE 回项目库索引(独立连接,不依赖 _currentProjectId)。 private void UpdateProjectQuoteTotalsById(string projectId, decimal xiangGuiTaiShu, decimal chengBenZongJia, decimal baoChuZongJia, decimal liRun, decimal liRunLv) { if (string.IsNullOrWhiteSpace(projectId)) return; string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "UPDATE " + ProjectsTableName + " SET " + "箱柜台数=@xgts, 成本总价=@cbzj, 报出总价=@bczj, " + "利润=@lr, 利润率=@lrl WHERE 项目编号=@id;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@xgts", xiangGuiTaiShu == 0m ? (object)DBNull.Value : (object)xiangGuiTaiShu); cmd.Parameters.AddWithValue("@cbzj", chengBenZongJia == 0m ? (object)DBNull.Value : (object)chengBenZongJia); cmd.Parameters.AddWithValue("@bczj", baoChuZongJia == 0m ? (object)DBNull.Value : (object)baoChuZongJia); cmd.Parameters.AddWithValue("@lr", liRun == 0m ? (object)DBNull.Value : (object)liRun); cmd.Parameters.AddWithValue("@lrl", liRunLv.ToString()); cmd.Parameters.AddWithValue("@id", projectId); cmd.ExecuteNonQuery(); } } } catch (Exception ex) { Log("UpdateProjectQuoteTotalsById 异常: " + ex.Message); } } // 全量重算用的临时数据结构 private class TuhaoCalcRow { public long RowId; public string Tuhao = ""; public decimal TaiShu; } private class XianghaoCalcRow { public long RowId; public string Xianghao = ""; public decimal ShuLiang; public decimal BaoChuJia; public decimal ChengBenJia; } // ---------- 项目表重算(聚合当前项目所有图号,第 4 级) ---------- /// /// 聚合当前项目(advancedDataGridView9 当前显示的所有图号行), /// 重算项目表(advancedDataGridView6)当前项目的 5 个汇总列: /// 箱柜台数 = SUM(所有图号.箱柜数量) /// 成本总价 = SUM(所有图号.成本总价) /// 报出总价 = SUM(所有图号.总价) /// 利润 = 报出总价 − 成本总价 /// 利润率 = (成本总价=0 ? 0 : 利润 / 成本总价) /// 对应 Excel 项目表公式 H2=图号表!E8、I2=图号表!H8、J2=图号表!G8、K2=J2−I2、L2=IF(I2=0,0,K2/I2)。 /// 同时立即 UPDATE 回项目数据库,保证下次启动仍显示正确。 /// private void RecalcProjectRowFromTuhao() { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; // 聚合当前项目所有图号行的 箱柜数量/成本总价/总价 decimal sumXiangGuiShuLiang = 0m; decimal sumChengBenZongJia = 0m; decimal sumZongJia = 0m; DataGridView dgv9 = this.advancedDataGridView9; if (dgv9 != null && dgv9.Rows != null) { foreach (DataGridViewRow th in dgv9.Rows) { if (th == null || th.IsNewRow) continue; sumXiangGuiShuLiang += GetCellDecimal(th, "箱柜数量"); sumChengBenZongJia += GetCellDecimal(th, "成本总价"); sumZongJia += GetCellDecimal(th, "总价"); } } decimal xiangGuiTaiShu = sumXiangGuiShuLiang; // 箱柜台数 decimal baoChuZongJia = sumZongJia; // 报出总价 decimal liRun = baoChuZongJia - sumChengBenZongJia; // 利润 // ★ 口径(2026-09-29):利润率存小数(保留2位,0.05=5%) decimal liRunLv = sumChengBenZongJia == 0m ? 0m : Math.Round(liRun / sumChengBenZongJia, 2); // 利润率 // 更新项目表显示(只改内存,不写库;留给保存按钮/离页触发统一写库) DataGridViewRow projRow = FindProjectRowById(_currentProjectId); if (projRow != null) { SetCell(projRow, "箱柜台数", xiangGuiTaiShu); SetCell(projRow, "成本总价", sumChengBenZongJia); SetCell(projRow, "报出总价", baoChuZongJia); SetCell(projRow, "利润", liRun); SetCell(projRow, "利润率", liRunLv); } // 不再立即写回项目数据库——编辑单元格只改内存, // 库里的项目汇总在点保存按钮/离开报价页时由 SaveAllToDatabase 重算写入。 } /// /// 把项目表的 5 个汇总列 UPDATE 回项目数据库。 /// (项目表没有主键,但"项目编号"可作为业务键定位单行。) /// private void UpdateProjectQuoteTotals(decimal xiangGuiTaiShu, decimal chengBenZongJia, decimal baoChuZongJia, decimal liRun, decimal liRunLv) { if (string.IsNullOrWhiteSpace(_currentProjectId)) return; string dbPath = GetDbFullPath(ProjectsDbPath); if (!System.IO.File.Exists(dbPath)) return; try { using (var conn = new System.Data.SQLite.SQLiteConnection(GetConnString(dbPath))) { conn.Open(); string sql = "UPDATE " + ProjectsTableName + " SET " + "箱柜台数 = @xgts, 成本总价 = @cbzj, 报出总价 = @bczj, " + "利润 = @lr, 利润率 = @lrl " + "WHERE 项目编号 = @id;"; using (var cmd = new System.Data.SQLite.SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@xgts", xiangGuiTaiShu.ToString()); cmd.Parameters.AddWithValue("@cbzj", chengBenZongJia.ToString()); cmd.Parameters.AddWithValue("@bczj", baoChuZongJia.ToString()); cmd.Parameters.AddWithValue("@lr", liRun.ToString()); cmd.Parameters.AddWithValue("@lrl", liRunLv.ToString()); cmd.Parameters.AddWithValue("@id", _currentProjectId); cmd.ExecuteNonQuery(); } } } catch { // 存库失败不阻断界面重算(界面值已正确更新) } } // ---------- CellValueChanged 事件处理器 ---------- /// 编辑单元格时立即提交,触发 CellValueChanged。 /// 记录最后一次重算结束的时间,用于 Grid_DirtyStateChanged 里过滤掉 /// 重算后表格刷新导致的“假变脏”。重算结束后短时间内的变脏不提交,避免清空用户输入。 private DateTime _lastRecalcTime = DateTime.MinValue; private void Grid_DirtyStateChanged(object sender, EventArgs e) { // 程序代码主动改值(重算/加载)时不提交,避免触发 CellValueChanged 形成死循环 if (_isRecalculating || _isReloading) return; // ★ 关键修复:重算刚结束后的短时间内的变脏不提交。 // 原因:重算写公式列 → 表格重绘 → 正在编辑的单元格编辑控件被重置 → 变脏 → // 提交残留值覆盖原始值 → 表价被清空 → 只能输入一个数字。 // 时间窗口设为 2 秒,足够覆盖重算+重绘的完整周期。 var msSinceRecalc = (DateTime.Now - _lastRecalcTime).TotalMilliseconds; if (msSinceRecalc < 2000) return; var dgv = sender as DataGridView; if (dgv != null && dgv.IsCurrentCellDirty) { string colName = dgv.CurrentCell.OwningColumn != null ? dgv.CurrentCell.OwningColumn.HeaderText : ""; Log("[Dirty] " + dgv.Name + " 行" + dgv.CurrentCell.RowIndex + " 列[" + colName + "] → CommitEdit (距上次重算" + (int)msSinceRecalc + "ms)"); dgv.CommitEdit(DataGridViewDataErrorContexts.Commit); // ★ 直写库架构:编辑即写库,不再有"未保存"状态 } } /// 元件表单元格变化。 /// ★★ 一劳永逸(2026-09-30 定稿):本事件的重算链已【整体废弃】—— /// 虚拟模式编辑提交会同时触发 CellValuePushed(已统一处理:本行公式+防抖全库重算+从库刷新) /// 和本事件;这里若再用 GetFirstSelectedRow(选中行)做聚合,箱号表多选残留时 /// 会把合计写到错误的箱号行——这是"价格先跳错值再纠正"的真正根因(潜伏至今)。 /// 本行公式已由 CellValuePushed 计算,这里只留诊断日志。 private void Yuanjian_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_isRecalculating || _isReloading) return; if (e.RowIndex < 0) return; var dgv = this.advancedDataGridView5; if (dgv == null || e.RowIndex >= dgv.Rows.Count) return; var row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; // 取列名和值,用于日志 string colName = ""; object oldVal = null; if (e.ColumnIndex >= 0 && e.ColumnIndex < dgv.Columns.Count) { colName = dgv.Columns[e.ColumnIndex].HeaderText; oldVal = row.Cells[e.ColumnIndex].Value; } Log("[ValueChanged] 元件表 行" + e.RowIndex + " 列[" + colName + "] 值=" + (oldVal ?? "(null)") + " (聚合已统一走防抖全库重算,本事件不再写箱号/图号)"); } /// 箱号表单元格变化。 /// ★★ 一劳永逸(2026-09-30 定稿):聚合链废弃——箱号/图号派生列统一交给 /// 防抖全库重算+从库刷新(与元件编辑同链),本事件只处理改名日志。 private void Xianghao_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_isRecalculating || _isReloading) return; if (e.RowIndex < 0) return; var dgv = this.advancedDataGridView4; if (dgv == null || e.RowIndex >= dgv.Rows.Count) return; var row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; // ★ 用户改"箱号"列(显示名):只改表格显示,不更新表索引和 _currentXianghao(用内部ID) // 这样即使两个箱号改成同名,数据库表索引里"图号+箱号(内部ID)"也不会重复 // _currentXianghao 始终是内部ID(从"内部ID"列读取),与表索引一致 DataGridViewColumn xhCol = e.ColumnIndex >= 0 ? dgv.Columns[e.ColumnIndex] : null; string xhColName = xhCol == null ? "" : (!string.IsNullOrEmpty(xhCol.DataPropertyName) ? xhCol.DataPropertyName : xhCol.HeaderText); if (xhColName == "箱号") { string newXhName = GetCellValueAsString(row, "箱号"); Log("[改名] 箱号显示名改为: " + newXhName + " (内部ID不变,表索引不变)"); // 不调用 UpdateXianghaoIndex,不更新 _currentXianghao } // 聚合(图号行)不再在此计算——统一走防抖全库重算(防选中残留错位) } /// 图号表单元格变化:仅台数列变化时重算该行(总价/成本总价/毛利依赖台数)。 private void Tuhao_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_isRecalculating || _isReloading) return; if (e.RowIndex < 0) return; var dgv = this.advancedDataGridView9; if (dgv == null || e.RowIndex >= dgv.Rows.Count) return; var row = dgv.Rows[e.RowIndex]; if (row == null || row.IsNewRow) return; DataGridViewColumn col = e.ColumnIndex >= 0 ? dgv.Columns[e.ColumnIndex] : null; string colName = col == null ? "" : (!string.IsNullOrEmpty(col.DataPropertyName) ? col.DataPropertyName : col.HeaderText); // ★ 用户改"图号"列(显示名):只改表格显示,不更新表索引和 _currentTuhao(用内部ID) // 这样即使两个图号改成同名,数据库表索引里"图号(内部ID)"也不会重复 // _currentTuhao 始终是内部ID(从"内部ID"列读取),与表索引一致 if (colName == "图号") { string newThName = GetCellValueAsString(row, "图号"); Log("[改名] 图号显示名改为: " + newThName + " (内部ID不变,表索引不变)"); // 不调用 UpdateTuhaoIndex,不更新 _currentTuhao return; } // 只在"台数"列变化时才重算 if (colName != "台数") return; // ★★ 一劳永逸(2026-09-30 定稿):图号行派生列统一交给防抖全库重算+从库刷新 // (与元件/箱号编辑同链),本事件不再直接计算(防选中残留错位) Log("[ValueChanged] 图号表 行" + e.RowIndex + " 列[" + colName + "] (聚合统一走防抖全库重算)"); 合并视图防抖重算(); } // ============================================================ // SelectionChanged 联动事件 // ============================================================ /// 图号表选中变化:更新当前图号,联动刷新箱号表。 /// ★ 多选联动(2026-09-30):多选图号时箱号表显示多图号箱号合集。 private void advancedDataGridView9_SelectionChanged(object sender, EventArgs e) { if (_isReloading) return; // ★ 联动改用"当前行"(用户最后点击的行):多选复制后残留的选择不再阻断联动, // 单击/多选状态下点击行都会刷新箱号表(用户要求点击图号自动跳转) DataGridViewRow row = this.advancedDataGridView9.CurrentRow; if (row == null || row.IsNewRow) return; string tuhao = GetBoundValueAsString(row, "内部ID"); // ★ 用内部ID,用户改名不影响 if (string.IsNullOrWhiteSpace(tuhao)) return; // ★ 多选:收集全部选中图号(行序;CurrentRow 保底) var thList = new System.Collections.Generic.List(); foreach (DataGridViewRow r in this.advancedDataGridView9.Rows) { if (r == null || r.IsNewRow || !r.Selected) continue; string iid = GetBoundValueAsString(r, "内部ID"); if (!string.IsNullOrWhiteSpace(iid) && !thList.Contains(iid)) thList.Add(iid); } if (!thList.Contains(tuhao)) thList.Insert(0, tuhao); bool changed = tuhao != _currentTuhao; if (!changed && thList.Count <= 1) return; // 没变化,不重复刷新 _currentTuhao = tuhao; // CurrentRow 语义保持(插入/新建等功能不受影响) if (thList.Count > 1) ReloadXianghaoByTuhaos(_currentProjectId, thList); // ★ 多图号合并视图 else ReloadXianghaoByTuhao(_currentProjectId, _currentTuhao); // 单选:原路径(含从合并视图切回) } /// 箱号表选中变化:更新当前箱号,联动刷新元件表。 /// ★ 多选联动(2026-09-30):多选箱号(或合并视图)时元件表显示多箱号元件合集。 private void advancedDataGridView4_SelectionChanged(object sender, EventArgs e) { if (_isReloading) return; // ★ 联动改用"当前行"(用户最后点击的行):多选复制后残留的选择不再阻断联动 DataGridViewRow row = this.advancedDataGridView4.CurrentRow; if (row == null || row.IsNewRow) return; string xh = GetBoundValueAsString(row, "内部ID"); // ★ 用内部ID,用户改名不影响 if (string.IsNullOrWhiteSpace(xh)) return; // 合并视图判定(_来源图号_ 列是合并表标记) bool mergedView = _xianghaoTable != null && string.IsNullOrEmpty(_xianghaoTable.TableName) && _xianghaoTable.Columns.Contains("_来源图号_"); // ★ 多选:收集选中行(行序去重) var rowSet = new System.Collections.Generic.SortedSet(); foreach (DataGridViewCell c in this.advancedDataGridView4.SelectedCells) { if (c != null && c.RowIndex >= 0) rowSet.Add(c.RowIndex); } if (!rowSet.Contains(row.Index)) rowSet.Add(row.Index); bool multi = rowSet.Count > 1; if (!multi && xh == _currentXianghao && !mergedView) return; // 没变化,不重复刷新 _currentXianghao = xh; // CurrentRow 语义保持 if (multi) { // ★ 三元组:来源图号 / 箱号内部ID / 箱号显示名(合并视图序号列显示"箱号-N"用) var pairs = new System.Collections.Generic.List(); foreach (int idx in rowSet) { if (_xianghaoTable == null || idx >= _xianghaoTable.Rows.Count) continue; string thSrc = mergedView ? Convert.ToString(_xianghaoTable.Rows[idx]["_来源图号_"]) : _currentTuhao; string xhId = Convert.ToString(_xianghaoTable.Rows[idx]["内部ID"]); string xhName = Convert.ToString(_xianghaoTable.Rows[idx]["箱号"]); if (!string.IsNullOrWhiteSpace(xhId)) pairs.Add(new[] { thSrc, xhId, xhName }); } ReloadYuanjianByXianghaos(_currentProjectId, pairs); // ★ 多箱号合并视图 } else if (mergedView) { // 合并视图下单选:按该行来源图号查元件(不能再用 _currentTuhao) int idx = row.Index; string thSrc = (_xianghaoTable != null && idx < _xianghaoTable.Rows.Count) ? Convert.ToString(_xianghaoTable.Rows[idx]["_来源图号_"]) : _currentTuhao; ReloadYuanjianByXianghao(_currentProjectId, thSrc, xh); } else { ReloadYuanjianByXianghao(_currentProjectId, _currentTuhao, xh); // 原路径 } } /// 元件表选中变化:仅记录,无下级可联动。 private void advancedDataGridView5_SelectionChanged(object sender, EventArgs e) { // 元件是最末级,无下级联动 } /// ★ 治本(2026-09-30):按"内部ID"在表格中找行(数据归属定位,与视觉选中解耦)。 /// 找不到返回 null。用于编辑后聚合——目标行永远是"数据所属的那一行", /// 不受多选残留/选中顺序影响。 private DataGridViewRow 找行按内部ID(DataGridView dgv, string 内部ID) { if (dgv == null || string.IsNullOrWhiteSpace(内部ID) || dgv.Rows == null) return null; foreach (DataGridViewRow r in dgv.Rows) { if (r == null || r.IsNewRow) continue; if (string.Equals(GetBoundValueAsString(r, "内部ID"), 内部ID, StringComparison.OrdinalIgnoreCase)) return r; } return null; } /// 通用:取表格当前选中第一行(排除末尾新行)。 /// ★ 修复(2026-09-30):SelectedRows/SelectedCells 集合按"选中操作逆序"排列 /// (最后点选的排最前)——原实现遍历取首个 = 最后点击的行,多选时聚合目标行不稳定 /// (如先选AH4再Ctrl加选AH3,聚合会写进AH3)。改为取行索引最小的选中行(真正的第一行)。 private static DataGridViewRow GetFirstSelectedRow(DataGridView dgv) { if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) return null; int minIdx = int.MaxValue; DataGridViewRow best = null; // 从选中单元格取最小行索引(CellSelect/RowHeaderSelect 两模式都覆盖) if (dgv.SelectedCells != null) { foreach (DataGridViewCell c in dgv.SelectedCells) { if (c == null || c.RowIndex < 0 || c.RowIndex >= minIdx) continue; var r = dgv.Rows[c.RowIndex]; if (r == null || r.IsNewRow) continue; minIdx = c.RowIndex; best = r; } } if (best != null) return best; // 兜底:没有任何选中单元格时用当前行 if (dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) { return dgv.CurrentRow; } return null; } /// /// 取表格所有选中的行(支持批量多选,CellSelect 模式下从 SelectedCells 提取去重行)。 /// 至少返回当前行(如果没有任何选中)。 /// private static System.Collections.Generic.List GetAllSelectedRows(DataGridView dgv) { var result = new System.Collections.Generic.List(); if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) return result; // 用行索引去重(CellSelect 下同一行可能有多个单元格被选中) var seenRows = new System.Collections.Generic.HashSet(); // 1) 从 SelectedRows 取(RowHeaderSelect 模式下有效) if (dgv.SelectedRows != null) { foreach (DataGridViewRow r in dgv.SelectedRows) { if (r == null || r.IsNewRow) continue; if (seenRows.Add(r.Index)) result.Add(r); } } // 2) 从 SelectedCells 提取行(CellSelect 模式下 SelectedRows 可能不全, // 必须无条件执行,不能只在 result.Count==0 时才执行) if (dgv.SelectedCells != null && dgv.SelectedCells.Count > 0) { foreach (DataGridViewCell cell in dgv.SelectedCells) { if (cell == null || cell.RowIndex < 0) continue; DataGridViewRow r = dgv.Rows[cell.RowIndex]; if (r == null || r.IsNewRow) continue; if (seenRows.Add(r.Index)) result.Add(r); } } // 3) 都没有就兜底用 CurrentRow if (result.Count == 0 && dgv.CurrentRow != null && !dgv.CurrentRow.IsNewRow) { result.Add(dgv.CurrentRow); } return result; } /// 从表格当前选中行读取某列值,并更新到外部状态字段。 private static void SyncStateFromGrid(DataGridView dgv, string colName, ref string state) { DataGridViewRow row = GetFirstSelectedRow(dgv); if (row == null) return; state = GetCellValueAsString(row, colName); } } }