Files
ctbjrj/成套报价软件/主窗口.公式重算.cs
T

2031 lines
111 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
using Microsoft.Win32;
using Smart.CustomComponents;
namespace 成套报价软件
{
/// <summary>主窗口 拆分文件:由 主窗口.cs 按模块拆出,同一 partial class。</summary>
public partial class MainForm
{
// ============================================================
// 公式实时重算(对应 Excel 的三级联动公式)
// 元件(底层,每行独立) → 箱号(聚合该箱号所有元件) → 图号(聚合该图号所有箱号)
// ============================================================
/// <summary>把单元格值解析为 decimal;DBNull/空串/非数字返回 0。</summary>
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;
}
/// <summary>把计算结果写回到行某列;为 0 写空串(保持界面整洁)。
/// ★ 虚拟模式:写内存 DataTable 行(按行号)+刷新该行,不再写 Cells.Value(虚拟模式下无效)。</summary>
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 按需提供
// ============================================================
/// <summary>虚拟模式:控件要显示某单元格时从内存表取值。</summary>
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;
}
/// <summary>虚拟模式:用户编辑单元格提交时写回内存表(读缓存),并排队直写数据库。</summary>
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)
启动项目页同步();
}
/// <summary>★ 合并视图编辑后的防抖全库重算(2026-09-30):
/// 连续编辑合并触发一次(600ms 安静期),RecalcAllFromDatabase 纯库操作——
/// 按图号/箱号逐个聚合 UPDATE 回库,保证箱号行/图号行/项目汇总始终最新,
/// 且不会把"多箱号合计"写进单个箱号行。
/// ★ B2(2026-10-01):编辑收集过影响元件表时改为【增量重算】(只重算受影响箱号),
/// 未收集(dgv4/dgv9编辑等)走全量兜底。</summary>
private Timer _合并重算Timer;
/// <summary>B2:编辑影响到的元件物理表集合(防抖窗口内合并),Tick 时取走并反查(图号,箱号)。</summary>
private readonly System.Collections.Generic.HashSet<string> _待重算元件表 =
new System.Collections.Generic.HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
/// <summary>B2:元件物理表名集合 → 反查(图号\n箱号内部ID)范围集合。</summary>
private System.Collections.Generic.HashSet<string> 反查重算范围(System.Collections.Generic.HashSet<string> 表名s)
{
var scope = new System.Collections.Generic.HashSet<string>();
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<string>(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();
}
/// <summary>★ 从库刷新箱号表/图号表价格列显示(2026-09-30):按"内部ID"对齐更新内存行,
/// 不动行序与选中。合并视图(TableName 空)时跳过——由切回单选时重载。</summary>
private void 刷新箱号表显示从库()
{
try
{
刷新表价格列从库(this.advancedDataGridView4, _xianghaoTable,
"单台成本, 成本价, 单台报出, 报出价, 利润, 利润率, 毛利率");
刷新表价格列从库(this.advancedDataGridView9, _tuhaoTable,
"箱柜数量, 单价, 总价, 成本总价, 毛利, 毛利率");
}
catch (Exception exF) { Log("[显示同步] 刷新异常: " + exF.Message); }
}
/// <summary>
/// ★ 费用行实时联动的显示同步(2026-10-01):重算后库里的费用行表价/四价已按最新基数重写,
/// 这里按内部ID对齐把元件表(内存表)中 类型='费用' 的行同步为新库值(普通行不动,不打断编辑)。
/// 合并视图按各行 _来源表名_ 读对应物理表;单箱号视图读整表。
/// </summary>
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<DataRow>();
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<string>();
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<string, System.Collections.Generic.List<object>>();
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<object>();
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<object> 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); }
}
/// <summary>按内部ID对齐,从库重刷某表的价格列显示(通用)。
/// ★ 合并视图支持(2026-10-01):箱号合并表(有_来源表名_)按来源表逐表读库;
/// 图号合并表(有_来源图号_)统一读图号主表——删除/编辑后价格列照常刷新。</summary>
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<string>();
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<string, System.Collections.Generic.Dictionary<string, object>>(
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<string, object>();
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<string, object> 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); }
}
/// <summary>图号/箱号重名自动后缀:值与同表其他行(除自身行)重复时返回 "名(2)"/"名(3)"…;
/// 不重复返回原值。空值不处理。</summary>
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_公式列集合);用户输入列(表价/数量/系数)不参与
// ============================================================
/// <summary>公式计算列集合(只包含程序算出来的价格列,不包含用户输入的表价)。
/// ★ 口径(2026-09-29):毛利率(百分数值 4.76)入集合获得小数位格式化;
/// 利润率(小数 0.05)不入集合,保持原样显示。</summary>
private static readonly System.Collections.Generic.HashSet<string> s_公式列集合 =
new System.Collections.Generic.HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
{
// 元件层(表价是用户输入,也需要格式化显示)
"表价", "采购单价", "采购价", "报出单价", "报出价",
// 箱号层公式列
"单台成本", "成本价", "单台报出", "利润", "毛利率",
// 图号层公式列
"单价", "总价", "成本总价", "毛利",
// 项目层公式列
"成本总价", "报出总价", "利润"
};
/// <summary>
/// 把小数位配置(“保留整数”/“保留2位小数”等)转换成 .NET 格式字符串。
/// 例:“保留整数” → "F0";“保留2位小数” → "F2”。
/// 用 F 而不是 N(不要千分位逗号)。
/// </summary>
private static string ToFormatString(string 小数位文本)
{
int n = 价格格式化.解析小数位(小数位文本);
if (n < 0) n = 2; // 未配置默认 2 位
return "F" + n.ToString();
}
/// <summary>
/// 老库兼容:把 DataTable 中公式列的类型从 string 转为 double。
/// 新库建表时公式列是 REAL(Fill 后自动是 double);老库可能是 TEXT(string),
/// string 值的 DefaultCellStyle.Format 不生效,需要转换。
/// 如果公式列已经是 double 则原样返回。
/// </summary>
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;
}
/// <summary>
/// 给指定 DataGridView 的所有公式计算列应用小数位格式。
/// 只设 DefaultCellStyle.Format,不设 ValueType(避免 DataError 导致单元格不显示)。
/// ★ 同一表格+同一格式只应用一次(每次 Reload 重跑是重复开销);小数位配置变化时由
/// ApplyPriceFormatsToAllGrids 清缓存强制重刷。
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> _已应用格式 =
new System.Collections.Generic.HashSet<string>();
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;
}
}
}
}
}
/// <summary>
/// 母排元件行不受项目"小数位/取整方式"设置限制:价格列按自然值显示
/// (0.########## = 有几位小数显几位、不四舍五入、无尾零),其余行仍走列级设置格式。
/// 仅元件表挂接;虚拟模式行号与内存表 1:1(同 Dgv_CellValueNeeded 的取数通道),
/// 经"类型"列=="母排"判定(母排工具写入的行见 主窗口.母排与备份.cs)。
/// </summary>
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 { }
}
/// <summary>
/// 进入项目后,按当前项目的价格格式化配置,应用 DefaultCellStyle.Format 到 4 个表格的公式列。
/// 同时把 DataTable 里公式列的值从 string 转成 decimal(Format 只对数值类型生效)。
/// </summary>
private void ApplyPriceFormatsToAllGrids()
{
lock (_已应用格式) { _已应用格式.Clear(); } // 小数位配置变化,强制全部重刷
// 单台层:元件表/箱号表/图号表
ApplyPriceFormat(this.advancedDataGridView5, _单台小数位文本);
ApplyPriceFormat(this.advancedDataGridView4, _单台小数位文本);
ApplyPriceFormat(this.advancedDataGridView9, _单台小数位文本);
// 项目层:项目表
ApplyPriceFormat(this.advancedDataGridView6, _项目小数位文本);
}
/// <summary>判断当前表格是不是项目表(advancedDataGridView6)。项目表用项目层配置。</summary>
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;
}
/// <summary>
/// 从报价库的「项目信息」表读取当前项目的 4 个价格格式化配置字段,
/// 更新到内存配置变量。库/表/字段不存在时保持默认值(保留2位小数/不取整)。
/// </summary>
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();
}
/// <summary>按 DataPropertyName 或 HeaderText 找列(因为 UI 隐藏了部分列)。</summary>
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;
}
/// <summary>取某行某列的字符串值(按 DataPropertyName/HeaderText/Name 匹配)。</summary>
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();
}
/// <summary>取某行某列的 decimal 值。</summary>
private static decimal GetCellDecimal(DataGridViewRow row, string name)
{
return ParseDecimal(GetCellString(row, name));
}
// ---------- 元件表重算(每行独立) ----------
/// <summary>
/// 重算单行元件的派生列(对应 advancedDataGridView5Table Excel 公式):
/// 采购单价(I) = 表价(H) × 采购系数(J)
/// 采购价(K) = 采购单价(I) × 数量(G)
/// 报出单价(L) = 采购单价(I) × 报出系数(M)
/// 报出价(N) = 报出单价(L) × 数量(G)
/// </summary>
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);
}
// ---------- 箱号表重算(聚合当前箱号所有元件) ----------
/// <summary>
/// 聚合当前箱号(_currentTuhao + _currentXianghao)的所有元件,
/// 重算箱号表当前行的派生列(对应 Excel 箱号表公式,合计行引用):
/// 单台成本 = SUM(该箱号元件.采购价)
/// 成本价 = 单台成本 × 数量
/// 单台报出 = SUM(该箱号元件.报出价)
/// 报出价 = 单台报出 × 数量
/// 利润 = 报出价 − 成本价
/// 利润率 = 利润 / 成本价 (小数,0.05=5%)
/// 毛利率 = 利润 / 报出价 × 100 (百分数值,4.76=4.76%)
/// </summary>
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);
}
// ---------- 图号表重算(聚合当前图号所有箱号) ----------
/// <summary>
/// 聚合当前图号(_currentTuhao)的所有箱号,
/// 重算图号表当前行的派生列(对应 Excel 图号表公式,合计行引用):
/// 箱柜数量 = SUM(该图号箱号.数量)
/// 单价 = SUM(该图号箱号.报出价)
/// 总价 = 单价 × 台数
/// 成本总价 = SUM(该图号箱号.成本价) × 台数
/// 毛利 = 总价 − 成本总价
/// 毛利率 = 毛利 / 总价 × 100 (百分数值,与项目页/方案页口径一致)
/// </summary>
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();
}
/// <summary>
/// 数据加载(Reload)后的重算:让新建/删除后受影响的行立即显示计算结果。
/// 注意:箱号行/图号行的重算依赖“该箱号/图号自己名下的子数据”,
/// 而 UI 表格一次只显示当前选中层的数据,所以这里只能重算:
/// - 所有元件行(底层,每行自洽)
/// - 当前选中的箱号行(聚合当前元件表)
/// - 当前选中的图号行(聚合当前箱号表)
/// 不能遍历箱号/图号表所有行去重算(否则会把所有行算成当前层的数据,串数据)。
/// 重算后存库 + AcceptChanges,避免下次 Reload 重复写入。
/// </summary>
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 防抖自动合并频发调用),不再只靠单元格编辑那一个入口。
启动项目页同步();
}
// ---------- 全量数据库重算(不依赖内存/选中状态) ----------
/// <summary>
/// 直接读数据库,把指定项目下所有图号、所有箱号的派生列重算并 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%)
/// </summary>
/// <param name="projectId">项目编号;空则用 _currentProjectId。</param>
/// <param name="scope">★ B2(2026-10-01)增量重算范围:键="图号\n箱号内部ID"。
/// null/空=全量重算(进项目/删改等低频操作);
/// 非空=只重算范围内的箱号行(从元件逐行重算),其图号行聚合用"重算新值+未受影响箱号库值",
/// 项目汇总用"全部图号聚合列(受影响图号新值覆盖)",大项目编辑不再全库重算。</param>
private void RecalcAllFromDatabase(string projectId,
System.Collections.Generic.HashSet<string> 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<string, System.Collections.Generic.HashSet<string>>();
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<string> set;
if (!thGroups.TryGetValue(th, out set))
{
set = new System.Collections.Generic.HashSet<string>();
thGroups[th] = set;
}
set.Add(xh);
}
if (thGroups.Count == 0) incremental = false; // scope 键全部无效→回退全量
}
if (incremental)
{
// 全部图号行的聚合值(内部ID→[箱柜数量,成本总价,总价];受影响图号稍后用新值覆盖)
var thAgg = new System.Collections.Generic.Dictionary<string, decimal[]>();
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<XianghaoCalcRow>();
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<TuhaoCalcRow>();
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<XianghaoCalcRow>();
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 { }
}
/// <summary>
/// ★ 元件表重算统一入口(含费用行实时联动,2026-10-01):
/// 第一遍:普通行(类型≠费用)按统一公式(表价×系数×数量)重算四价并累计基数;
/// 第二遍:费用行——"规格"列存了公式(元件采购价×N% / 元件报出价×N%)的行
/// 先按【最新基数】重写表价(元件增删改后百分比费用自动跟随,无需重新应用模板),
/// 再按统一公式算四价;"固定金额"行表价不动只算价。全部计入聚合。
/// </summary>
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);
}
}
}
/// <summary>更新元件行派生列(按 rowid 定位):采购单价/采购价/报出单价/报出价。</summary>
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();
}
}
/// <summary>更新箱号行派生列(按 rowid 定位)。所有 decimal 统一转字符串写入(TEXT 列)。</summary>
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();
}
}
/// <summary>更新图号行派生列(按 rowid 定位)。</summary>
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();
}
}
/// <summary>按项目编号把 5 个汇总列 UPDATE 回项目库索引(独立连接,不依赖 _currentProjectId)。</summary>
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 级) ----------
/// <summary>
/// 聚合当前项目(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 回项目数据库,保证下次启动仍显示正确。
/// </summary>
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 重算写入。
}
/// <summary>
/// 把项目表的 5 个汇总列 UPDATE 回项目数据库。
/// (项目表没有主键,但"项目编号"可作为业务键定位单行。)
/// </summary>
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 事件处理器 ----------
/// <summary>编辑单元格时立即提交,触发 CellValueChanged。</summary>
/// <summary>记录最后一次重算结束的时间,用于 Grid_DirtyStateChanged 里过滤掉
/// 重算后表格刷新导致的“假变脏”。重算结束后短时间内的变脏不提交,避免清空用户输入。</summary>
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);
// ★ 直写库架构:编辑即写库,不再有"未保存"状态
}
}
/// <summary>元件表单元格变化。
/// ★★ 一劳永逸(2026-09-30 定稿):本事件的重算链已【整体废弃】——
/// 虚拟模式编辑提交会同时触发 CellValuePushed(已统一处理:本行公式+防抖全库重算+从库刷新)
/// 和本事件;这里若再用 GetFirstSelectedRow(选中行)做聚合,箱号表多选残留时
/// 会把合计写到错误的箱号行——这是"价格先跳错值再纠正"的真正根因(潜伏至今)。
/// 本行公式已由 CellValuePushed 计算,这里只留诊断日志。</summary>
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)") +
" (聚合已统一走防抖全库重算,本事件不再写箱号/图号)");
}
/// <summary>箱号表单元格变化。
/// ★★ 一劳永逸(2026-09-30 定稿):聚合链废弃——箱号/图号派生列统一交给
/// 防抖全库重算+从库刷新(与元件编辑同链),本事件只处理改名日志。</summary>
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
}
// 聚合(图号行)不再在此计算——统一走防抖全库重算(防选中残留错位)
}
/// <summary>图号表单元格变化:仅台数列变化时重算该行(总价/成本总价/毛利依赖台数)。</summary>
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 联动事件
// ============================================================
/// <summary>图号表选中变化:更新当前图号,联动刷新箱号表。
/// ★ 多选联动(2026-09-30):多选图号时箱号表显示多图号箱号合集。</summary>
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<string>();
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); // 单选:原路径(含从合并视图切回)
}
/// <summary>箱号表选中变化:更新当前箱号,联动刷新元件表。
/// ★ 多选联动(2026-09-30):多选箱号(或合并视图)时元件表显示多箱号元件合集。</summary>
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<int>();
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<string[]>();
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); // 原路径
}
}
/// <summary>元件表选中变化:仅记录,无下级可联动。</summary>
private void advancedDataGridView5_SelectionChanged(object sender, EventArgs e)
{
// 元件是最末级,无下级联动
}
/// <summary>★ 治本(2026-09-30):按"内部ID"在表格中找行(数据归属定位,与视觉选中解耦)。
/// 找不到返回 null。用于编辑后聚合——目标行永远是"数据所属的那一行",
/// 不受多选残留/选中顺序影响。</summary>
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;
}
/// <summary>通用:取表格当前选中第一行(排除末尾新行)。
/// ★ 修复(2026-09-30):SelectedRows/SelectedCells 集合按"选中操作逆序"排列
/// (最后点选的排最前)——原实现遍历取首个 = 最后点击的行,多选时聚合目标行不稳定
/// (如先选AH4再Ctrl加选AH3,聚合会写进AH3)。改为取行索引最小的选中行(真正的第一行)。</summary>
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;
}
/// <summary>
/// 取表格所有选中的行(支持批量多选,CellSelect 模式下从 SelectedCells 提取去重行)。
/// 至少返回当前行(如果没有任何选中)。
/// </summary>
private static System.Collections.Generic.List<DataGridViewRow> GetAllSelectedRows(DataGridView dgv)
{
var result = new System.Collections.Generic.List<DataGridViewRow>();
if (dgv == null || dgv.Rows == null || dgv.Rows.Count == 0) return result;
// 用行索引去重(CellSelect 下同一行可能有多个单元格被选中)
var seenRows = new System.Collections.Generic.HashSet<int>();
// 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;
}
/// <summary>从表格当前选中行读取某列值,并更新到外部状态字段。</summary>
private static void SyncStateFromGrid(DataGridView dgv, string colName, ref string state)
{
DataGridViewRow row = GetFirstSelectedRow(dgv);
if (row == null) return;
state = GetCellValueAsString(row, colName);
}
}
}