From fc1fee54cd46ed63e70fe4e242a2cedce5d5ac08 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Mar 2026 17:24:43 -0400 Subject: [PATCH] feat: add BomItem model and BomReader Excel parser --- OpenNest.IO/Bom/BomItem.cs | 35 +++++++++++++ OpenNest.IO/Bom/BomReader.cs | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 OpenNest.IO/Bom/BomItem.cs create mode 100644 OpenNest.IO/Bom/BomReader.cs diff --git a/OpenNest.IO/Bom/BomItem.cs b/OpenNest.IO/Bom/BomItem.cs new file mode 100644 index 0000000..0ba0116 --- /dev/null +++ b/OpenNest.IO/Bom/BomItem.cs @@ -0,0 +1,35 @@ +namespace OpenNest.IO.Bom +{ + public class BomItem + { + [Column("Item #", "Item Number", "Item Num")] + public int? ItemNum { get; set; } + + [Column("File Name")] + public string FileName { get; set; } + + [Column("Qty", "Quantity")] + public int? Qty { get; set; } + + [Column("Description")] + public string Description { get; set; } + + [Column("Part", "Part Name")] + public string PartName { get; set; } + + [Column("Config", "Configuration")] + public string ConfigurationName { get; set; } + + [Column("Thickness")] + public double? Thickness { get; set; } + + [Column("Material")] + public string Material { get; set; } + + [Column("K-Factor")] + public double? KFactor { get; set; } + + [Column("Default Bend Radius")] + public double? DefaultBendRadius { get; set; } + } +} diff --git a/OpenNest.IO/Bom/BomReader.cs b/OpenNest.IO/Bom/BomReader.cs new file mode 100644 index 0000000..2351fb4 --- /dev/null +++ b/OpenNest.IO/Bom/BomReader.cs @@ -0,0 +1,99 @@ +using ClosedXML.Excel; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace OpenNest.IO.Bom +{ + public class BomReader : IDisposable + { + private readonly XLWorkbook workbook; + private Dictionary columnNameIndexDict; + + public BomReader(string file) + { + workbook = new XLWorkbook(file); + columnNameIndexDict = new Dictionary(); + } + + private IXLWorksheet GetPartsWorksheet() + { + if (!workbook.TryGetWorksheet("Parts", out var worksheet)) + throw new InvalidOperationException("BOM file does not contain a 'Parts' worksheet."); + return worksheet; + } + + private void FindColumnIndexes(IXLWorksheet worksheet) + { + var lastColumn = worksheet.LastColumnUsed()?.ColumnNumber() ?? 0; + var properties = typeof(BomItem).GetProperties(); + + foreach (var property in properties) + { + var column = property.GetCustomAttribute(); + + if (column == null) + continue; + + var classColumnNames = column.Names.Select(n => n.ToUpper()); + + for (var columnIndex = 1; columnIndex <= lastColumn; columnIndex++) + { + var cell = worksheet.Cell(1, columnIndex); + if (cell.IsEmpty()) continue; + + var excelColumnName = cell.GetString().ToUpper(); + var isMatch = classColumnNames.Any(n => n == excelColumnName); + + if (!isMatch) + continue; + + columnNameIndexDict.Add(property, columnIndex); + break; + } + } + } + + public List GetItems() + { + var worksheet = GetPartsWorksheet(); + + FindColumnIndexes(worksheet); + + var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1; + var items = new List(); + + for (var rowIndex = 2; rowIndex <= lastRow; rowIndex++) + { + var item = new BomItem(); + + foreach (var dictItem in columnNameIndexDict) + { + var property = dictItem.Key; + var excelColumnIndex = dictItem.Value; + var cell = worksheet.Cell(rowIndex, excelColumnIndex); + var type = property.PropertyType; + + if (type == typeof(int?)) + property.SetValue(item, cell.ToIntOrNull()); + else if (type == typeof(string)) + property.SetValue(item, cell.IsEmpty() ? null : cell.GetString()); + else if (type == typeof(double?)) + property.SetValue(item, cell.ToDoubleOrNull()); + else + throw new NotImplementedException($"Unsupported property type: {type}"); + } + + items.Add(item); + } + + return items; + } + + public void Dispose() + { + workbook?.Dispose(); + } + } +}