Migrate ExcelIO from ClosedXML to NPOI

This commit is contained in:
Joseph Doherty
2026-02-06 17:27:09 -05:00
parent 070d915b12
commit dd18a05408
26 changed files with 3034 additions and 2805 deletions
+213 -234
View File
@@ -1,234 +1,213 @@
using ClosedXML.Excel;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Mapping;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
// Use Core's SearchModel for the public interface
using CoreSearchModel = JdeScoping.Core.Models.SearchResults.SearchModel;
// Use ExcelIO's SearchModel which contains criteria filter properties for CriteriaSheetGenerator
using ExcelSearchModel = JdeScoping.ExcelIO.Models.Reporting.SearchModel;
namespace JdeScoping.ExcelIO;
/// <summary>
/// Service for generating Excel export files from search results.
/// </summary>
public class ExcelExportService : IExcelExportService
{
private readonly ILogger<ExcelExportService> _logger;
private readonly IOptions<ExcelExportOptions> _options;
private readonly CriteriaSheetGenerator _criteriaGenerator;
private readonly FluentTableWriter _tableWriter;
private readonly ExcelMapRegistry _registry;
/// <summary>
/// Initializes a new instance of the ExcelExportService class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="options">Excel export options.</param>
/// <param name="criteriaGenerator">Criteria sheet generator.</param>
/// <param name="tableWriter">Fluent table writer.</param>
/// <param name="registry">Excel map registry.</param>
public ExcelExportService(
ILogger<ExcelExportService> logger,
IOptions<ExcelExportOptions> options,
CriteriaSheetGenerator criteriaGenerator,
FluentTableWriter tableWriter,
ExcelMapRegistry registry)
{
_logger = logger;
_options = options;
_criteriaGenerator = criteriaGenerator;
_tableWriter = tableWriter;
_registry = registry;
}
/// <inheritdoc />
public async Task<byte[]> GenerateAsync(CoreSearchModel search, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(search);
// Map Core SearchModel to ExcelIO SearchModel for internal processing
var excelModel = MapToExcelModel(search);
return await GenerateInternalAsync(excelModel, cancellationToken);
}
/// <summary>
/// Maps Core SearchModel to ExcelIO SearchModel for Excel generation.
/// </summary>
private static ExcelSearchModel MapToExcelModel(CoreSearchModel source)
{
return new ExcelSearchModel
{
Id = source.Id,
UserName = source.UserName,
Name = source.Name,
SubmitDt = source.SubmitDt,
StartDt = source.StartDt,
EndDt = source.EndDt,
ExtractMisData = source.ExtractMisData,
Results = source.Results,
MisResults = source.MisResults,
MisNonMatchResults = source.MisNonMatchResults
};
}
/// <summary>
/// Internal method that generates an Excel file from the ExcelIO search model.
/// </summary>
/// <param name="search">ExcelIO search model with criteria and results.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Excel file as byte array.</returns>
private async Task<byte[]> GenerateInternalAsync(ExcelSearchModel search, CancellationToken cancellationToken = default)
{
using var scope = _logger.BeginScope(new Dictionary<string, object>
{
["SearchId"] = search.Id,
["SearchName"] = search.Name
});
_logger.LogInformation("Starting Excel export generation");
// ClosedXML operations are synchronous, wrap in Task.Run for non-blocking
return await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var workbook = new XLWorkbook();
// 1. Always generate Search Criteria sheet (first tab)
_logger.LogDebug("Generating Search Criteria sheet");
_criteriaGenerator.Generate(workbook, search);
cancellationToken.ThrowIfCancellationRequested();
// 2. Always generate Search Results sheet (second tab)
_logger.LogDebug("Generating Search Results sheet");
GenerateResultsSheet(workbook, search.Results);
cancellationToken.ThrowIfCancellationRequested();
// 3. Conditionally generate MIS Info sheet
if (search.ExtractMisData && search.MisResults != null && search.MisResults.Count > 0)
{
_logger.LogDebug("Generating MIS Info sheet with {Count} records", search.MisResults.Count);
GenerateMisInfoSheet(workbook, search.MisResults);
}
else if (search.ExtractMisData)
{
_logger.LogWarning("ExtractMisData is true but MisResults is null or empty");
}
cancellationToken.ThrowIfCancellationRequested();
// 4. Conditionally generate Investigation sheet
if (search.ExtractMisData && search.MisNonMatchResults != null && search.MisNonMatchResults.Count > 0)
{
_logger.LogDebug("Generating Investigation sheet with {Count} records", search.MisNonMatchResults.Count);
GenerateInvestigationSheet(workbook, search.MisNonMatchResults);
}
cancellationToken.ThrowIfCancellationRequested();
// Save to byte array
using var stream = new MemoryStream();
workbook.SaveAs(stream);
var result = stream.ToArray();
_logger.LogInformation("Excel export generation completed. Size: {Size} bytes", result.Length);
// Optional: write debug copy to disk
if (_options.Value.DebugWriteToFile)
{
WriteDebugCopy(search.Id, result);
}
return result;
}, cancellationToken);
}
private void GenerateResultsSheet(XLWorkbook workbook, List<SearchResult> results)
{
var map = _registry.GetMap<SearchResult>();
var tabName = map.TabName ?? "Search Results";
var worksheet = workbook.Worksheets.Add(tabName);
var table = _tableWriter.WriteTable(worksheet, 1, 1, results);
if (table != null)
{
// Apply protection with editable extension area
var lastRow = table.RangeAddress.LastAddress.RowNumber;
var lastCol = table.RangeAddress.LastAddress.ColumnNumber;
WorksheetProtector.UnlockExtensionArea(worksheet, lastRow, lastCol);
WorksheetProtector.ApplyProtection(worksheet, _options.Value.DataSheetPassword);
}
}
private void GenerateMisInfoSheet(XLWorkbook workbook, List<MisSearchResult> misResults)
{
var map = _registry.GetMap<MisSearchResult>();
var tabName = map.TabName ?? "MIS Info";
var worksheet = workbook.Worksheets.Add(tabName);
var table = _tableWriter.WriteTable(worksheet, 1, 1, misResults);
if (table != null)
{
// Apply protection with editable extension area
var lastRow = table.RangeAddress.LastAddress.RowNumber;
var lastCol = table.RangeAddress.LastAddress.ColumnNumber;
WorksheetProtector.UnlockExtensionArea(worksheet, lastRow, lastCol);
WorksheetProtector.ApplyProtection(worksheet, _options.Value.DataSheetPassword);
}
}
private void GenerateInvestigationSheet(XLWorkbook workbook, List<MisNonMatchSearchResult> misNonMatchResults)
{
var map = _registry.GetMap<MisNonMatchSearchResult>();
var tabName = map.TabName ?? "Investigation";
var worksheet = workbook.Worksheets.Add(tabName);
var table = _tableWriter.WriteTable(worksheet, 1, 1, misNonMatchResults);
if (table != null)
{
// Apply protection with editable extension area
var lastRow = table.RangeAddress.LastAddress.RowNumber;
var lastCol = table.RangeAddress.LastAddress.ColumnNumber;
WorksheetProtector.UnlockExtensionArea(worksheet, lastRow, lastCol);
WorksheetProtector.ApplyProtection(worksheet, _options.Value.DataSheetPassword);
}
}
private void WriteDebugCopy(int searchId, byte[] result)
{
try
{
var directory = _options.Value.DebugOutputDirectory;
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var debugPath = Path.Combine(
directory,
$"Search_{searchId}_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx");
File.WriteAllBytes(debugPath, result);
_logger.LogDebug("Debug copy written to {Path}", debugPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to write debug copy");
}
}
}
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Mapping;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using CoreSearchModel = JdeScoping.Core.Models.SearchResults.SearchModel;
using ExcelSearchModel = JdeScoping.ExcelIO.Models.Reporting.SearchModel;
namespace JdeScoping.ExcelIO;
/// <summary>
/// Service for generating Excel export files from search results.
/// </summary>
public class ExcelExportService : IExcelExportService
{
private readonly ILogger<ExcelExportService> _logger;
private readonly IOptions<ExcelExportOptions> _options;
private readonly CriteriaSheetGenerator _criteriaGenerator;
private readonly FluentTableWriter _tableWriter;
private readonly ExcelMapRegistry _registry;
/// <summary>
/// Initializes a new instance of the ExcelExportService class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="options">Excel export options.</param>
/// <param name="criteriaGenerator">Criteria sheet generator.</param>
/// <param name="tableWriter">Fluent table writer.</param>
/// <param name="registry">Excel map registry.</param>
public ExcelExportService(
ILogger<ExcelExportService> logger,
IOptions<ExcelExportOptions> options,
CriteriaSheetGenerator criteriaGenerator,
FluentTableWriter tableWriter,
ExcelMapRegistry registry)
{
_logger = logger;
_options = options;
_criteriaGenerator = criteriaGenerator;
_tableWriter = tableWriter;
_registry = registry;
}
/// <inheritdoc />
public async Task<byte[]> GenerateAsync(CoreSearchModel search, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(search);
var excelModel = MapToExcelModel(search);
return await GenerateInternalAsync(excelModel, cancellationToken);
}
/// <summary>
/// Maps Core SearchModel to ExcelIO SearchModel for Excel generation.
/// </summary>
private static ExcelSearchModel MapToExcelModel(CoreSearchModel source)
{
return new ExcelSearchModel
{
Id = source.Id,
UserName = source.UserName,
Name = source.Name,
SubmitDt = source.SubmitDt,
StartDt = source.StartDt,
EndDt = source.EndDt,
ExtractMisData = source.ExtractMisData,
Results = source.Results,
MisResults = source.MisResults,
MisNonMatchResults = source.MisNonMatchResults
};
}
/// <summary>
/// Internal method that generates an Excel file from the ExcelIO search model.
/// </summary>
/// <param name="search">ExcelIO search model with criteria and results.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Excel file as byte array.</returns>
private async Task<byte[]> GenerateInternalAsync(ExcelSearchModel search, CancellationToken cancellationToken = default)
{
using var scope = _logger.BeginScope(new Dictionary<string, object>
{
["SearchId"] = search.Id,
["SearchName"] = search.Name
});
_logger.LogInformation("Starting Excel export generation");
// NPOI operations are synchronous, wrap in Task.Run for non-blocking.
return await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using IWorkbook workbook = new XSSFWorkbook();
_logger.LogDebug("Generating Search Criteria sheet");
_criteriaGenerator.Generate(workbook, search);
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Generating Search Results sheet");
GenerateResultsSheet(workbook, search.Results);
cancellationToken.ThrowIfCancellationRequested();
if (search.ExtractMisData && search.MisResults != null && search.MisResults.Count > 0)
{
_logger.LogDebug("Generating MIS Info sheet with {Count} records", search.MisResults.Count);
GenerateMisInfoSheet(workbook, search.MisResults);
}
else if (search.ExtractMisData)
{
_logger.LogWarning("ExtractMisData is true but MisResults is null or empty");
}
cancellationToken.ThrowIfCancellationRequested();
if (search.ExtractMisData && search.MisNonMatchResults != null && search.MisNonMatchResults.Count > 0)
{
_logger.LogDebug("Generating Investigation sheet with {Count} records", search.MisNonMatchResults.Count);
GenerateInvestigationSheet(workbook, search.MisNonMatchResults);
}
cancellationToken.ThrowIfCancellationRequested();
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
var result = stream.ToArray();
_logger.LogInformation("Excel export generation completed. Size: {Size} bytes", result.Length);
if (_options.Value.DebugWriteToFile)
{
WriteDebugCopy(search.Id, result);
}
return result;
}, cancellationToken);
}
private void GenerateResultsSheet(IWorkbook workbook, List<SearchResult> results)
{
var map = _registry.GetMap<SearchResult>();
var tabName = map.TabName ?? "Search Results";
var worksheet = workbook.CreateSheet(tabName);
var table = _tableWriter.WriteTable(worksheet, 1, 1, results);
if (table != null)
{
WorksheetProtector.UnlockExtensionArea(worksheet, table.LastRow, table.LastCol);
WorksheetProtector.ApplyProtection(worksheet, _options.Value.DataSheetPassword);
}
}
private void GenerateMisInfoSheet(IWorkbook workbook, List<MisSearchResult> misResults)
{
var map = _registry.GetMap<MisSearchResult>();
var tabName = map.TabName ?? "MIS Info";
var worksheet = workbook.CreateSheet(tabName);
var table = _tableWriter.WriteTable(worksheet, 1, 1, misResults);
if (table != null)
{
WorksheetProtector.UnlockExtensionArea(worksheet, table.LastRow, table.LastCol);
WorksheetProtector.ApplyProtection(worksheet, _options.Value.DataSheetPassword);
}
}
private void GenerateInvestigationSheet(IWorkbook workbook, List<MisNonMatchSearchResult> misNonMatchResults)
{
var map = _registry.GetMap<MisNonMatchSearchResult>();
var tabName = map.TabName ?? "Investigation";
var worksheet = workbook.CreateSheet(tabName);
var table = _tableWriter.WriteTable(worksheet, 1, 1, misNonMatchResults);
if (table != null)
{
WorksheetProtector.UnlockExtensionArea(worksheet, table.LastRow, table.LastCol);
WorksheetProtector.ApplyProtection(worksheet, _options.Value.DataSheetPassword);
}
}
private void WriteDebugCopy(int searchId, byte[] result)
{
try
{
var directory = _options.Value.DebugOutputDirectory;
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var debugPath = Path.Combine(
directory,
$"Search_{searchId}_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx");
File.WriteAllBytes(debugPath, result);
_logger.LogDebug("Debug copy written to {Path}", debugPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to write debug copy");
}
}
}
@@ -1,49 +1,90 @@
using ClosedXML.Excel;
namespace JdeScoping.ExcelIO.Formatting;
/// <summary>
/// Header cell formatting utilities.
/// </summary>
public static class HeaderFormatter
{
/// <summary>
/// Applies header formatting to a cell.
/// </summary>
/// <param name="cell">The cell to format.</param>
/// <param name="text">Optional text to set in the cell.</param>
public static void ApplyHeaderFormat(IXLCell cell, string? text = null)
{
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.Gainsboro;
if (!string.IsNullOrEmpty(text))
{
cell.Value = text;
}
}
/// <summary>
/// Applies header formatting to a range.
/// </summary>
/// <param name="range">The range to format.</param>
/// <param name="text">Optional text to set in the first cell.</param>
/// <param name="merge">Whether to merge the range.</param>
public static void ApplyHeaderFormat(IXLRange range, string? text = null, bool merge = false)
{
range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
range.Style.Font.Bold = true;
range.Style.Fill.BackgroundColor = XLColor.Gainsboro;
if (merge)
{
range.Merge();
}
if (!string.IsNullOrEmpty(text))
{
range.FirstCell().Value = text;
}
}
}
using NPOI.SS.UserModel;
using NPOI.SS.Util;
using NPOI.XSSF.UserModel;
namespace JdeScoping.ExcelIO.Formatting;
/// <summary>
/// Header cell formatting utilities.
/// </summary>
public static class HeaderFormatter
{
private static readonly byte[] GainsboroRgb = [0xDC, 0xDC, 0xDC];
/// <summary>
/// Applies header formatting to a cell.
/// </summary>
/// <param name="cell">The cell to format.</param>
/// <param name="text">Optional text to set in the cell.</param>
public static void ApplyHeaderFormat(ICell cell, string? text = null)
{
var workbook = cell.Sheet.Workbook;
var style = workbook.CreateCellStyle();
style.Alignment = HorizontalAlignment.Center;
style.FillPattern = FillPattern.SolidForeground;
if (style is XSSFCellStyle xssfStyle)
{
var color = new XSSFColor();
color.SetRgb(GainsboroRgb);
xssfStyle.SetFillForegroundColor(color);
}
else
{
style.FillForegroundColor = IndexedColors.Grey25Percent.Index;
}
var font = workbook.CreateFont();
font.IsBold = true;
style.SetFont(font);
cell.CellStyle = style;
if (!string.IsNullOrEmpty(text))
{
cell.SetCellValue(text);
}
}
/// <summary>
/// Applies header formatting to a range.
/// </summary>
/// <param name="sheet">The worksheet containing the range.</param>
/// <param name="firstRow">First row number (1-based).</param>
/// <param name="firstCol">First column number (1-based).</param>
/// <param name="lastRow">Last row number (1-based).</param>
/// <param name="lastCol">Last column number (1-based).</param>
/// <param name="text">Optional text to set in the first cell.</param>
/// <param name="merge">Whether to merge the range.</param>
public static void ApplyHeaderFormat(
ISheet sheet,
int firstRow,
int firstCol,
int lastRow,
int lastCol,
string? text = null,
bool merge = false)
{
for (var row = firstRow; row <= lastRow; row++)
{
var npoiRow = sheet.GetRow(row - 1) ?? sheet.CreateRow(row - 1);
for (var col = firstCol; col <= lastCol; col++)
{
var cell = npoiRow.GetCell(col - 1) ?? npoiRow.CreateCell(col - 1);
ApplyHeaderFormat(cell);
}
}
if (merge)
{
sheet.AddMergedRegion(new CellRangeAddress(firstRow - 1, lastRow - 1, firstCol - 1, lastCol - 1));
}
if (!string.IsNullOrEmpty(text))
{
var row = sheet.GetRow(firstRow - 1) ?? sheet.CreateRow(firstRow - 1);
var cell = row.GetCell(firstCol - 1) ?? row.CreateCell(firstCol - 1);
cell.SetCellValue(text);
}
}
}
@@ -1,62 +1,81 @@
using ClosedXML.Excel;
namespace JdeScoping.ExcelIO.Formatting;
/// <summary>
/// Worksheet protection utilities.
/// </summary>
public static class WorksheetProtector
{
/// <summary>
/// Applies standard protection to a worksheet with the given password.
/// </summary>
/// <param name="worksheet">The worksheet to protect.</param>
/// <param name="password">The protection password.</param>
public static void ApplyProtection(IXLWorksheet worksheet, string password)
{
var protection = worksheet.Protect(password);
// Allow these operations
protection.AllowElement(XLSheetProtectionElements.DeleteColumns);
protection.AllowElement(XLSheetProtectionElements.DeleteRows);
protection.AllowElement(XLSheetProtectionElements.AutoFilter);
protection.AllowElement(XLSheetProtectionElements.FormatCells);
protection.AllowElement(XLSheetProtectionElements.FormatColumns);
protection.AllowElement(XLSheetProtectionElements.FormatRows);
protection.AllowElement(XLSheetProtectionElements.SelectLockedCells);
protection.AllowElement(XLSheetProtectionElements.SelectUnlockedCells);
protection.AllowElement(XLSheetProtectionElements.EditObjects);
protection.AllowElement(XLSheetProtectionElements.Sort);
}
/// <summary>
/// Applies criteria sheet protection (simpler, just password).
/// </summary>
/// <param name="worksheet">The worksheet to protect.</param>
/// <param name="password">The protection password.</param>
public static void ApplyCriteriaProtection(IXLWorksheet worksheet, string password)
{
worksheet.Protect(password);
}
/// <summary>
/// Unlocks a range for user editing beyond the data area.
/// </summary>
/// <param name="worksheet">The worksheet containing the range.</param>
/// <param name="lastDataRow">The last row of data.</param>
/// <param name="lastDataCol">The last column of data.</param>
/// <param name="extensionRows">Number of rows to unlock beyond data (default 1000).</param>
/// <param name="extensionCols">Number of columns to unlock beyond data (default 1000).</param>
public static void UnlockExtensionArea(
IXLWorksheet worksheet,
int lastDataRow,
int lastDataCol,
int extensionRows = 1000,
int extensionCols = 1000)
{
var extensionRange = worksheet.Range(
1, lastDataCol + 1,
lastDataRow + extensionRows, lastDataCol + extensionCols);
extensionRange.Style.Protection.Locked = false;
}
}
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
namespace JdeScoping.ExcelIO.Formatting;
/// <summary>
/// Worksheet protection utilities.
/// </summary>
public static class WorksheetProtector
{
/// <summary>
/// Applies standard protection to a worksheet with the given password.
/// </summary>
/// <param name="worksheet">The worksheet to protect.</param>
/// <param name="password">The protection password.</param>
public static void ApplyProtection(ISheet worksheet, string password)
{
worksheet.ProtectSheet(password);
if (worksheet is not XSSFSheet xssfSheet)
{
return;
}
// Set to false to allow these operations while sheet protection is enabled.
xssfSheet.LockDeleteColumns(false);
xssfSheet.LockDeleteRows(false);
xssfSheet.LockAutoFilter(false);
xssfSheet.LockFormatCells(false);
xssfSheet.LockFormatColumns(false);
xssfSheet.LockFormatRows(false);
xssfSheet.LockSelectLockedCells(false);
xssfSheet.LockSelectUnlockedCells(false);
xssfSheet.LockObjects(false);
xssfSheet.LockSort(false);
xssfSheet.EnableLocking();
}
/// <summary>
/// Applies criteria sheet protection (simpler, just password).
/// </summary>
/// <param name="worksheet">The worksheet to protect.</param>
/// <param name="password">The protection password.</param>
public static void ApplyCriteriaProtection(ISheet worksheet, string password)
{
worksheet.ProtectSheet(password);
}
/// <summary>
/// Unlocks a range for user editing beyond the data area.
/// </summary>
/// <param name="worksheet">The worksheet containing the range.</param>
/// <param name="lastDataRow">The last row of data.</param>
/// <param name="lastDataCol">The last column of data.</param>
/// <param name="extensionRows">Number of rows to unlock beyond data (default 1000).</param>
/// <param name="extensionCols">Number of columns to unlock beyond data (default 1000).</param>
public static void UnlockExtensionArea(
ISheet worksheet,
int lastDataRow,
int lastDataCol,
int extensionRows = 1000,
int extensionCols = 1000)
{
var workbook = worksheet.Workbook;
var unlockedStyle = workbook.CreateCellStyle();
unlockedStyle.IsLocked = false;
// Apply unlocked default style to extension columns (NPOI uses 0-based columns).
var startCol = lastDataCol;
var endCol = lastDataCol + extensionCols - 1;
for (var col = startCol; col <= endCol; col++)
{
worksheet.SetDefaultColumnStyle(col, unlockedStyle);
}
// Touch at least one extension cell to ensure style materializes in workbook XML.
var row = worksheet.GetRow(0) ?? worksheet.CreateRow(0);
var cell = row.GetCell(startCol) ?? row.CreateCell(startCol);
cell.CellStyle = unlockedStyle;
}
}
@@ -1,193 +1,134 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Options;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Generates the Search Criteria sheet for Excel export.
/// </summary>
public class CriteriaSheetGenerator
{
private readonly IOptions<ExcelExportOptions> _options;
private readonly FluentTableWriter _tableWriter;
/// <summary>
/// Initializes a new instance of the CriteriaSheetGenerator class.
/// </summary>
/// <param name="options">Excel export options.</param>
/// <param name="tableWriter">Fluent table writer.</param>
public CriteriaSheetGenerator(
IOptions<ExcelExportOptions> options,
FluentTableWriter tableWriter)
{
_options = options;
_tableWriter = tableWriter;
}
/// <summary>
/// Generates the Search Criteria sheet.
/// </summary>
/// <param name="workbook">The workbook to add the sheet to.</param>
/// <param name="search">The search model with criteria.</param>
public void Generate(XLWorkbook workbook, SearchModel search)
{
var worksheet = workbook.Worksheets.Add("Search Criteria");
var row = 1;
// Write name and user
HeaderFormatter.ApplyHeaderFormat(worksheet.Cell(row, 1), "Search Name");
worksheet.Cell(row, 2).Value = search.Name;
HeaderFormatter.ApplyHeaderFormat(worksheet.Cell(++row, 1), "User Name");
worksheet.Cell(row, 2).Value = search.UserName;
// Skip row
row++;
// Write timestamps
HeaderFormatter.ApplyHeaderFormat(worksheet.Cell(++row, 1), "Submit timestamp");
worksheet.Cell(row, 2).Value = FormatTimestamp(search.SubmitDt);
HeaderFormatter.ApplyHeaderFormat(worksheet.Cell(++row, 1), "Start timestamp");
worksheet.Cell(row, 2).Value = FormatTimestamp(search.StartDt);
HeaderFormatter.ApplyHeaderFormat(worksheet.Cell(++row, 1), "Completed timestamp");
worksheet.Cell(row, 2).Value = FormatTimestamp(search.EndDt);
// Skip row
row++;
// Write timespan filter table
var timespanData = new List<TimespanFilter>
{
new() { MinimumDt = search.MinimumDt, MaximumDt = search.MaximumDt }
};
var timespanTable = _tableWriter.WriteTable(worksheet, ++row, 1, timespanData);
if (timespanTable != null)
{
row = timespanTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write work order filter table
var workOrderTable = _tableWriter.WriteTable(worksheet, row, 1, search.WorkOrderFilter);
if (workOrderTable != null)
{
row = workOrderTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write item number filter table
var itemNumberTable = _tableWriter.WriteTable(worksheet, row, 1, search.ItemNumberFilter);
if (itemNumberTable != null)
{
row = itemNumberTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write profit center filter table
var profitCenterTable = _tableWriter.WriteTable(worksheet, row, 1, search.ProfitCenterFilter);
if (profitCenterTable != null)
{
row = profitCenterTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write work center filter table
var workCenterTable = _tableWriter.WriteTable(worksheet, row, 1, search.WorkCenterFilter);
if (workCenterTable != null)
{
row = workCenterTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write component lot filter table
var componentLotTable = _tableWriter.WriteTable(worksheet, row, 1, search.ComponentLotFilter);
if (componentLotTable != null)
{
row = componentLotTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write operator filter table
var operatorTable = _tableWriter.WriteTable(worksheet, row, 1, search.OperatorFilter);
if (operatorTable != null)
{
row = operatorTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write item/operation/MIS filter table
var itemOpMisTable = _tableWriter.WriteTable(worksheet, row, 1, search.ItemOperationMisFilter);
if (itemOpMisTable != null)
{
row = itemOpMisTable.RangeAddress.LastAddress.RowNumber + 3;
}
else
{
row += 4;
}
// Write extract MIS data option
var headerRange = worksheet.Range(row, 1, row, 2);
HeaderFormatter.ApplyHeaderFormat(headerRange, "Extract MIS data?", merge: true);
worksheet.Cell(++row, 1).Value = search.ExtractMisData ? "YES" : "NO";
// Auto-fit columns with 15% padding
for (var column = 1; column <= 4; column++)
{
worksheet.Column(column).AdjustToContents();
worksheet.Column(column).Width *= ExcelFormats.CriteriaPaddingFactor;
}
// Apply protection
WorksheetProtector.ApplyCriteriaProtection(worksheet, _options.Value.CriteriaSheetPassword);
}
private string FormatTimestamp(DateTime? dateTime)
{
if (!dateTime.HasValue)
{
return string.Empty;
}
var options = _options.Value;
var targetTimezone = TimeZoneInfo.FindSystemTimeZoneById(options.TimezoneId);
var dt = dateTime.Value;
// Convert to target timezone based on the source DateTime's Kind
var localTime = dt.Kind switch
{
DateTimeKind.Utc => TimeZoneInfo.ConvertTimeFromUtc(dt, targetTimezone),
DateTimeKind.Local => TimeZoneInfo.ConvertTime(dt, TimeZoneInfo.Local, targetTimezone),
// Unspecified - database values are stored in target timezone, no conversion needed
_ => dt
};
return $"{localTime:MMM dd, yyyy hh:mm:ss tt} {options.TimezoneAbbreviation}";
}
}
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Options;
using NPOI.SS.UserModel;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Generates the Search Criteria sheet for Excel export.
/// </summary>
public class CriteriaSheetGenerator
{
private const int ExcelMaxColumnWidth = 255 * 256;
private readonly IOptions<ExcelExportOptions> _options;
private readonly FluentTableWriter _tableWriter;
/// <summary>
/// Initializes a new instance of the CriteriaSheetGenerator class.
/// </summary>
/// <param name="options">Excel export options.</param>
/// <param name="tableWriter">Fluent table writer.</param>
public CriteriaSheetGenerator(
IOptions<ExcelExportOptions> options,
FluentTableWriter tableWriter)
{
_options = options;
_tableWriter = tableWriter;
}
/// <summary>
/// Generates the Search Criteria sheet.
/// </summary>
/// <param name="workbook">The workbook to add the sheet to.</param>
/// <param name="search">The search model with criteria.</param>
public void Generate(IWorkbook workbook, SearchModel search)
{
var worksheet = workbook.CreateSheet("Search Criteria");
var row = 1;
HeaderFormatter.ApplyHeaderFormat(GetOrCreateCell(worksheet, row, 1), "Search Name");
GetOrCreateCell(worksheet, row, 2).SetCellValue(search.Name);
HeaderFormatter.ApplyHeaderFormat(GetOrCreateCell(worksheet, ++row, 1), "User Name");
GetOrCreateCell(worksheet, row, 2).SetCellValue(search.UserName);
row++;
HeaderFormatter.ApplyHeaderFormat(GetOrCreateCell(worksheet, ++row, 1), "Submit timestamp");
GetOrCreateCell(worksheet, row, 2).SetCellValue(FormatTimestamp(search.SubmitDt));
HeaderFormatter.ApplyHeaderFormat(GetOrCreateCell(worksheet, ++row, 1), "Start timestamp");
GetOrCreateCell(worksheet, row, 2).SetCellValue(FormatTimestamp(search.StartDt));
HeaderFormatter.ApplyHeaderFormat(GetOrCreateCell(worksheet, ++row, 1), "Completed timestamp");
GetOrCreateCell(worksheet, row, 2).SetCellValue(FormatTimestamp(search.EndDt));
row++;
var timespanData = new List<TimespanFilter>
{
new() { MinimumDt = search.MinimumDt, MaximumDt = search.MaximumDt }
};
var timespanTable = _tableWriter.WriteTable(worksheet, ++row, 1, timespanData);
row = GetNextTableStartRow(timespanTable, row);
var workOrderTable = _tableWriter.WriteTable(worksheet, row, 1, search.WorkOrderFilter);
row = GetNextTableStartRow(workOrderTable, row);
var itemNumberTable = _tableWriter.WriteTable(worksheet, row, 1, search.ItemNumberFilter);
row = GetNextTableStartRow(itemNumberTable, row);
var profitCenterTable = _tableWriter.WriteTable(worksheet, row, 1, search.ProfitCenterFilter);
row = GetNextTableStartRow(profitCenterTable, row);
var workCenterTable = _tableWriter.WriteTable(worksheet, row, 1, search.WorkCenterFilter);
row = GetNextTableStartRow(workCenterTable, row);
var componentLotTable = _tableWriter.WriteTable(worksheet, row, 1, search.ComponentLotFilter);
row = GetNextTableStartRow(componentLotTable, row);
var operatorTable = _tableWriter.WriteTable(worksheet, row, 1, search.OperatorFilter);
row = GetNextTableStartRow(operatorTable, row);
var itemOpMisTable = _tableWriter.WriteTable(worksheet, row, 1, search.ItemOperationMisFilter);
row = GetNextTableStartRow(itemOpMisTable, row);
HeaderFormatter.ApplyHeaderFormat(worksheet, row, 1, row, 2, "Extract MIS data?", merge: true);
GetOrCreateCell(worksheet, ++row, 1).SetCellValue(search.ExtractMisData ? "YES" : "NO");
for (var column = 1; column <= 4; column++)
{
worksheet.AutoSizeColumn(column - 1);
var padded = (int)(worksheet.GetColumnWidth(column - 1) * ExcelFormats.CriteriaPaddingFactor);
worksheet.SetColumnWidth(column - 1, padded > ExcelMaxColumnWidth ? ExcelMaxColumnWidth : padded);
}
WorksheetProtector.ApplyCriteriaProtection(worksheet, _options.Value.CriteriaSheetPassword);
}
private static int GetNextTableStartRow(TableWriteResult? result, int fallbackRow)
{
return result != null ? result.LastRow + 3 : fallbackRow + 4;
}
private static ICell GetOrCreateCell(ISheet sheet, int rowNumber1Based, int colNumber1Based)
{
var row = sheet.GetRow(rowNumber1Based - 1) ?? sheet.CreateRow(rowNumber1Based - 1);
return row.GetCell(colNumber1Based - 1) ?? row.CreateCell(colNumber1Based - 1);
}
private string FormatTimestamp(DateTime? dateTime)
{
if (!dateTime.HasValue)
{
return string.Empty;
}
var options = _options.Value;
var targetTimezone = TimeZoneInfo.FindSystemTimeZoneById(options.TimezoneId);
var dt = dateTime.Value;
var localTime = dt.Kind switch
{
DateTimeKind.Utc => TimeZoneInfo.ConvertTimeFromUtc(dt, targetTimezone),
DateTimeKind.Local => TimeZoneInfo.ConvertTime(dt, TimeZoneInfo.Local, targetTimezone),
_ => dt
};
return $"{localTime:MMM dd, yyyy hh:mm:ss tt} {options.TimezoneAbbreviation}";
}
}
@@ -1,82 +1,92 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Utilities;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Generates data entry templates for bulk upload.
/// </summary>
public class DataEntryTemplateGenerator
{
/// <summary>
/// Generates a single-column data entry template.
/// </summary>
/// <typeparam name="T">The type of data items.</typeparam>
/// <param name="sourceData">Optional source data to pre-populate.</param>
/// <param name="headerText">Header text for the column.</param>
/// <returns>The Excel file as a byte array.</returns>
public byte[] Generate<T>(IEnumerable<T>? sourceData, string headerText)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Data Entry Template");
// Header
var headerCell = worksheet.Cell(1, 1);
HeaderFormatter.ApplyHeaderFormat(headerCell, headerText);
worksheet.Column(1).Width = 45;
// Data (if provided)
if (sourceData != null)
{
var row = 2;
foreach (var item in sourceData)
{
worksheet.Cell(row++, 1).Value = CellValueConverter.ConvertToXlValue(item);
}
}
// All cells as text
worksheet.Column(1).Style.NumberFormat.Format = ExcelFormats.StdFormat;
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
/// <summary>
/// Generates a multi-column data entry template.
/// </summary>
/// <param name="sourceData">Optional source data to pre-populate (array of rows, each row is array of values).</param>
/// <param name="headers">Header texts for each column.</param>
/// <returns>The Excel file as a byte array.</returns>
public byte[] Generate(object[][]? sourceData, string[] headers)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Data Entry Template");
// Headers
for (var col = 0; col < headers.Length; col++)
{
HeaderFormatter.ApplyHeaderFormat(worksheet.Cell(1, col + 1), headers[col]);
worksheet.Column(col + 1).Width = 65;
worksheet.Column(col + 1).Style.NumberFormat.Format = ExcelFormats.StdFormat;
}
// Data
if (sourceData != null)
{
for (var row = 0; row < sourceData.Length; row++)
{
for (var col = 0; col < sourceData[row].Length; col++)
{
worksheet.Cell(row + 2, col + 1).Value = CellValueConverter.ConvertToXlValue(sourceData[row][col]);
}
}
}
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
}
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Utilities;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Generates data entry templates for bulk upload.
/// </summary>
public class DataEntryTemplateGenerator
{
/// <summary>
/// Generates a single-column data entry template.
/// </summary>
/// <typeparam name="T">The type of data items.</typeparam>
/// <param name="sourceData">Optional source data to pre-populate.</param>
/// <param name="headerText">Header text for the column.</param>
/// <returns>The Excel file as a byte array.</returns>
public byte[] Generate<T>(IEnumerable<T>? sourceData, string headerText)
{
using var workbook = new XSSFWorkbook();
var worksheet = workbook.CreateSheet("Data Entry Template");
var style = workbook.CreateCellStyle();
style.DataFormat = workbook.CreateDataFormat().GetFormat(ExcelFormats.StdFormat);
worksheet.SetDefaultColumnStyle(0, style);
var headerCell = GetOrCreateCell(worksheet, 1, 1);
HeaderFormatter.ApplyHeaderFormat(headerCell, headerText);
worksheet.SetColumnWidth(0, 45 * 256);
if (sourceData != null)
{
var row = 2;
foreach (var item in sourceData)
{
var cell = GetOrCreateCell(worksheet, row++, 1);
CellValueConverter.SetCellValue(cell, item);
}
}
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
return stream.ToArray();
}
/// <summary>
/// Generates a multi-column data entry template.
/// </summary>
/// <param name="sourceData">Optional source data to pre-populate (array of rows, each row is array of values).</param>
/// <param name="headers">Header texts for each column.</param>
/// <returns>The Excel file as a byte array.</returns>
public byte[] Generate(object[][]? sourceData, string[] headers)
{
using var workbook = new XSSFWorkbook();
var worksheet = workbook.CreateSheet("Data Entry Template");
var dataFormat = workbook.CreateDataFormat();
for (var col = 0; col < headers.Length; col++)
{
var style = workbook.CreateCellStyle();
style.DataFormat = dataFormat.GetFormat(ExcelFormats.StdFormat);
worksheet.SetDefaultColumnStyle(col, style);
HeaderFormatter.ApplyHeaderFormat(GetOrCreateCell(worksheet, 1, col + 1), headers[col]);
worksheet.SetColumnWidth(col, 65 * 256);
}
if (sourceData != null)
{
for (var row = 0; row < sourceData.Length; row++)
{
for (var col = 0; col < sourceData[row].Length; col++)
{
var cell = GetOrCreateCell(worksheet, row + 2, col + 1);
CellValueConverter.SetCellValue(cell, sourceData[row][col]);
}
}
}
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
return stream.ToArray();
}
private static ICell GetOrCreateCell(ISheet sheet, int rowNumber1Based, int colNumber1Based)
{
var row = sheet.GetRow(rowNumber1Based - 1) ?? sheet.CreateRow(rowNumber1Based - 1);
return row.GetCell(colNumber1Based - 1) ?? row.CreateCell(colNumber1Based - 1);
}
}
@@ -1,142 +1,248 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Utilities;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Writes Excel tables using fluent mapping configuration.
/// </summary>
public sealed class FluentTableWriter
{
private readonly ExcelMapRegistry _registry;
/// <summary>
/// Initializes a new instance of the <see cref="FluentTableWriter"/> class.
/// </summary>
/// <param name="registry">The registry containing Excel column maps for types.</param>
public FluentTableWriter(ExcelMapRegistry registry)
{
_registry = registry;
}
/// <summary>
/// Writes a table to the worksheet using the registered map for type T.
/// </summary>
/// <typeparam name="T">The type of objects to write to the table.</typeparam>
/// <param name="worksheet">The Excel worksheet to write to.</param>
/// <param name="startRow">The starting row number (1-based).</param>
/// <param name="startCol">The starting column number (1-based).</param>
/// <param name="data">The data to write to the table.</param>
/// <param name="tableNameOverride">Optional override for the table name; uses map name if null.</param>
/// <param name="showHeader">Whether to show a merged header above the table.</param>
/// <param name="headerText">Optional text to display in the merged header.</param>
/// <returns>The created Excel table, or null if no columns are configured.</returns>
public IXLTable? WriteTable<T>(
IXLWorksheet worksheet,
int startRow,
int startCol,
IEnumerable<T> data,
string? tableNameOverride = null,
bool showHeader = false,
string? headerText = null)
{
var map = _registry.GetMap<T>();
var columns = map.Columns;
var tableName = tableNameOverride ?? map.TableName ?? typeof(T).Name;
var header = headerText ?? map.TabName ?? string.Empty;
if (columns.Count == 0)
return null;
var dataList = data.ToList();
var baseRow = startRow;
// Write merged header if requested
if (showHeader && !string.IsNullOrEmpty(header))
{
var mergedHeaderRange = worksheet.Range(baseRow, startCol, baseRow, startCol + columns.Count - 1);
HeaderFormatter.ApplyHeaderFormat(mergedHeaderRange, header, merge: true);
baseRow++;
}
// Write column headers
var col = startCol;
foreach (var column in columns)
{
var cell = worksheet.Cell(baseRow, col);
HeaderFormatter.ApplyHeaderFormat(cell, column.HeaderText);
// Pre-set column formatting
worksheet.Column(col).Style.Alignment.WrapText = column.WrapText;
if (!column.AutoWidth)
{
worksheet.Column(col).Width = column.Width;
}
col++;
}
// Write data rows
var row = baseRow + 1;
foreach (var item in dataList)
{
col = startCol;
foreach (var column in columns)
{
var value = column.ValueGetter(item!);
worksheet.Cell(row, col).Value = CellValueConverter.ConvertToXlValue(value);
col++;
}
row++;
}
// Handle empty data case
if (dataList.Count == 0)
{
row = baseRow + 1;
}
// Create table range
var dataRange = worksheet.Range(
baseRow, startCol,
baseRow + dataList.Count, startCol + columns.Count - 1);
// Create table
var table = dataRange.CreateTable(tableName);
table.Theme = XLTableTheme.TableStyleLight18;
table.ShowTotalsRow = false;
// Apply column formatting
col = startCol;
var tableStartRow = table.RangeAddress.FirstAddress.RowNumber;
var tableEndRow = table.RangeAddress.LastAddress.RowNumber;
foreach (var column in columns)
{
// Apply number format
worksheet.Range(tableStartRow, col, tableEndRow, col)
.Style.NumberFormat.Format = column.Format;
// Apply column width
if (column.WrapText && !column.AutoWidth)
{
worksheet.Column(col).Width = column.Width;
}
else if (column.AutoWidth)
{
worksheet.Column(col).AdjustToContents();
worksheet.Column(col).Width *= ExcelFormats.DataPaddingFactor;
}
else
{
worksheet.Column(col).Width = column.Width;
}
col++;
}
return table;
}
}
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Utilities;
using NPOI.SS;
using NPOI.SS.UserModel;
using NPOI.SS.Util;
using NPOI.XSSF.UserModel;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Result metadata for a written table region.
/// </summary>
public sealed class TableWriteResult
{
public int FirstRow { get; init; }
public int LastRow { get; init; }
public int FirstCol { get; init; }
public int LastCol { get; init; }
public XSSFTable? Table { get; init; }
}
/// <summary>
/// Writes Excel tables using fluent mapping configuration.
/// </summary>
public sealed class FluentTableWriter
{
private const int ExcelMaxColumnWidth = 255 * 256;
private readonly ExcelMapRegistry _registry;
/// <summary>
/// Initializes a new instance of the <see cref="FluentTableWriter"/> class.
/// </summary>
/// <param name="registry">The registry containing Excel column maps for types.</param>
public FluentTableWriter(ExcelMapRegistry registry)
{
_registry = registry;
}
/// <summary>
/// Writes a table to the worksheet using the registered map for type T.
/// </summary>
/// <typeparam name="T">The type of objects to write to the table.</typeparam>
/// <param name="worksheet">The Excel worksheet to write to.</param>
/// <param name="startRow">The starting row number (1-based).</param>
/// <param name="startCol">The starting column number (1-based).</param>
/// <param name="data">The data to write to the table.</param>
/// <param name="tableNameOverride">Optional override for the table name; uses map name if null.</param>
/// <param name="showHeader">Whether to show a merged header above the table.</param>
/// <param name="headerText">Optional text to display in the merged header.</param>
/// <returns>The created table metadata, or null if no columns are configured.</returns>
public TableWriteResult? WriteTable<T>(
ISheet worksheet,
int startRow,
int startCol,
IEnumerable<T> data,
string? tableNameOverride = null,
bool showHeader = false,
string? headerText = null)
{
var map = _registry.GetMap<T>();
var columns = map.Columns;
var tableName = tableNameOverride ?? map.TableName ?? typeof(T).Name;
var header = headerText ?? map.TabName ?? string.Empty;
if (columns.Count == 0)
{
return null;
}
var dataList = data.ToList();
var baseRow = startRow;
if (showHeader && !string.IsNullOrEmpty(header))
{
HeaderFormatter.ApplyHeaderFormat(
worksheet,
baseRow,
startCol,
baseRow,
startCol + columns.Count - 1,
header,
merge: true);
baseRow++;
}
var dataFormat = worksheet.Workbook.CreateDataFormat();
var columnStyles = new ICellStyle[columns.Count];
// Write column headers and set column defaults.
for (var i = 0; i < columns.Count; i++)
{
var col = startCol + i;
var columnStyle = worksheet.Workbook.CreateCellStyle();
if (!string.IsNullOrEmpty(columns[i].Format))
{
columnStyle.DataFormat = dataFormat.GetFormat(columns[i].Format);
}
columnStyle.WrapText = columns[i].WrapText;
columnStyles[i] = columnStyle;
var headerRow = GetOrCreateRow(worksheet, baseRow);
var headerCell = GetOrCreateCell(headerRow, col);
HeaderFormatter.ApplyHeaderFormat(headerCell, columns[i].HeaderText);
}
// Write data rows.
var row = baseRow + 1;
foreach (var item in dataList)
{
var npoiRow = GetOrCreateRow(worksheet, row);
for (var i = 0; i < columns.Count; i++)
{
var col = startCol + i;
var column = columns[i];
var value = column.ValueGetter(item!);
var cell = GetOrCreateCell(npoiRow, col);
CellValueConverter.SetCellValue(cell, value);
cell.CellStyle = columnStyles[i];
}
row++;
}
if (dataList.Count == 0)
{
row = baseRow + 1;
}
var firstRow = baseRow;
var lastRow = baseRow + dataList.Count;
var firstCol = startCol;
var lastCol = startCol + columns.Count - 1;
var table = CreateTableIfSupported(worksheet, tableName, firstRow, lastRow, firstCol, lastCol);
// Preserve filter behavior.
worksheet.SetAutoFilter(new CellRangeAddress(firstRow - 1, lastRow - 1, firstCol - 1, lastCol - 1));
// Apply column widths.
for (var i = 0; i < columns.Count; i++)
{
var column = columns[i];
var colIndex = startCol + i - 1;
if (column.WrapText && !column.AutoWidth)
{
worksheet.SetColumnWidth(colIndex, ToNpoiWidth(column.Width));
}
else if (column.AutoWidth)
{
worksheet.AutoSizeColumn(colIndex);
var width = worksheet.GetColumnWidth(colIndex);
var padded = (int)(width * ExcelFormats.DataPaddingFactor);
worksheet.SetColumnWidth(colIndex, ClampWidth(padded));
}
else
{
worksheet.SetColumnWidth(colIndex, ToNpoiWidth(column.Width));
}
}
return new TableWriteResult
{
FirstRow = firstRow,
LastRow = lastRow,
FirstCol = firstCol,
LastCol = lastCol,
Table = table
};
}
private static IRow GetOrCreateRow(ISheet sheet, int rowNumber1Based)
{
return sheet.GetRow(rowNumber1Based - 1) ?? sheet.CreateRow(rowNumber1Based - 1);
}
private static ICell GetOrCreateCell(IRow row, int colNumber1Based)
{
return row.GetCell(colNumber1Based - 1) ?? row.CreateCell(colNumber1Based - 1);
}
private static XSSFTable? CreateTableIfSupported(
ISheet worksheet,
string tableName,
int firstRow,
int lastRow,
int firstCol,
int lastCol)
{
if (worksheet is not XSSFSheet xssfSheet)
{
return null;
}
var safeName = ToSafeTableName(tableName);
var area = new AreaReference(
new CellReference(firstRow - 1, firstCol - 1),
new CellReference(lastRow - 1, lastCol - 1),
SpreadsheetVersion.EXCEL2007);
var table = xssfSheet.CreateTable();
table.Name = safeName;
table.DisplayName = safeName;
table.StyleName = "TableStyleLight18";
table.CellReferences = area;
table.UpdateReferences();
table.UpdateHeaders();
return table;
}
private static string ToSafeTableName(string tableName)
{
var chars = tableName.Where(ch => char.IsLetterOrDigit(ch) || ch == '_').ToArray();
var value = new string(chars);
if (string.IsNullOrWhiteSpace(value))
{
return "Table1";
}
if (!char.IsLetter(value[0]) && value[0] != '_')
{
value = $"_{value}";
}
return value;
}
private static int ToNpoiWidth(double widthInChars)
{
return ClampWidth((int)(widthInChars * 256));
}
private static int ClampWidth(int width)
{
if (width < 0)
{
return 0;
}
return width > ExcelMaxColumnWidth ? ExcelMaxColumnWidth : width;
}
}
@@ -6,10 +6,10 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<ItemGroup>
<PackageReference Include="NPOI" Version="2.7.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
@@ -1,136 +1,166 @@
using ClosedXML.Excel;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.ViewModels;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ExcelIO.Parsing;
/// <summary>
/// Service for parsing Excel files uploaded by users.
/// </summary>
public class ExcelParserService : IExcelParserService
{
private readonly ILogger<ExcelParserService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ExcelParserService"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public ExcelParserService(ILogger<ExcelParserService> logger)
{
_logger = logger;
}
/// <inheritdoc />
public List<long> ParseWorkOrders(Stream fileStream)
{
using var workbook = new XLWorkbook(fileStream);
var worksheet = workbook.Worksheet(1);
var workOrderNumbers = new List<long>();
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
for (var row = 2; row <= lastRow; row++)
{
var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
if (long.TryParse(cellValue, out var woNumber))
{
workOrderNumbers.Add(woNumber);
}
}
return workOrderNumbers;
}
/// <inheritdoc />
public List<string> ParseItems(Stream fileStream)
{
using var workbook = new XLWorkbook(fileStream);
var worksheet = workbook.Worksheet(1);
var itemNumbers = new List<string>();
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
for (var row = 2; row <= lastRow; row++)
{
var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
if (!string.IsNullOrEmpty(cellValue))
{
itemNumbers.Add(cellValue);
}
}
return itemNumbers;
}
/// <inheritdoc />
public List<LotViewModel> ParseComponentLots(Stream fileStream)
{
using var workbook = new XLWorkbook(fileStream);
var worksheet = workbook.Worksheet(1);
var lotViewModels = new List<LotViewModel>();
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
for (var row = 2; row <= lastRow; row++)
{
var lotNumber = worksheet.Cell(row, 1).GetString()?.Trim() ?? string.Empty;
var itemNumber = worksheet.Cell(row, 2).GetString()?.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(lotNumber))
{
lotViewModels.Add(new LotViewModel
{
LotNumber = lotNumber,
ItemNumber = itemNumber
});
}
}
return lotViewModels;
}
/// <inheritdoc />
public List<PartOperationViewModel> ParsePartOperations(Stream fileStream)
{
using var workbook = new XLWorkbook(fileStream);
var worksheet = workbook.Worksheet(1);
var partOperations = new List<PartOperationViewModel>();
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
for (var row = 2; row <= lastRow; row++)
{
try
{
var itemNumber = worksheet.Cell(row, 1).GetString()?.Trim() ?? string.Empty;
var operationNumber = worksheet.Cell(row, 2).GetString()?.Trim() ?? string.Empty;
var misNumber = worksheet.Cell(row, 3).GetString()?.Trim() ?? string.Empty;
var misRevision = worksheet.Cell(row, 4).GetString()?.Trim() ?? string.Empty;
// Remove decimal places from operation number
if (!string.IsNullOrEmpty(operationNumber) && operationNumber.Contains('.'))
{
operationNumber = operationNumber[..operationNumber.IndexOf('.')];
}
if (!string.IsNullOrEmpty(itemNumber) && !string.IsNullOrEmpty(operationNumber))
{
partOperations.Add(new PartOperationViewModel
{
ItemNumber = itemNumber,
OperationNumber = operationNumber,
MisNumber = misNumber,
MisRevision = misRevision
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse row {RowNumber} in part operations sheet", row);
}
}
return partOperations;
}
}
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.ViewModels;
using Microsoft.Extensions.Logging;
using NPOI.SS.UserModel;
namespace JdeScoping.ExcelIO.Parsing;
/// <summary>
/// Service for parsing Excel files uploaded by users.
/// </summary>
public class ExcelParserService : IExcelParserService
{
private readonly ILogger<ExcelParserService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ExcelParserService"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public ExcelParserService(ILogger<ExcelParserService> logger)
{
_logger = logger;
}
/// <inheritdoc />
public List<long> ParseWorkOrders(Stream fileStream)
{
using var workbook = WorkbookFactory.Create(ResetStream(fileStream));
var worksheet = workbook.GetSheetAt(0);
var formatter = new DataFormatter();
var workOrderNumbers = new List<long>();
var lastRow = worksheet.LastRowNum + 1;
for (var row = 2; row <= lastRow; row++)
{
var cellValue = GetCellText(worksheet, row, 1, formatter);
if (long.TryParse(cellValue, out var woNumber))
{
workOrderNumbers.Add(woNumber);
}
}
return workOrderNumbers;
}
/// <inheritdoc />
public List<string> ParseItems(Stream fileStream)
{
using var workbook = WorkbookFactory.Create(ResetStream(fileStream));
var worksheet = workbook.GetSheetAt(0);
var formatter = new DataFormatter();
var itemNumbers = new List<string>();
var lastRow = worksheet.LastRowNum + 1;
for (var row = 2; row <= lastRow; row++)
{
var cellValue = GetCellText(worksheet, row, 1, formatter);
if (!string.IsNullOrEmpty(cellValue))
{
itemNumbers.Add(cellValue);
}
}
return itemNumbers;
}
/// <inheritdoc />
public List<LotViewModel> ParseComponentLots(Stream fileStream)
{
using var workbook = WorkbookFactory.Create(ResetStream(fileStream));
var worksheet = workbook.GetSheetAt(0);
var formatter = new DataFormatter();
var lotViewModels = new List<LotViewModel>();
var lastRow = worksheet.LastRowNum + 1;
for (var row = 2; row <= lastRow; row++)
{
var lotNumber = GetCellText(worksheet, row, 1, formatter);
var itemNumber = GetCellText(worksheet, row, 2, formatter);
if (!string.IsNullOrEmpty(lotNumber))
{
lotViewModels.Add(new LotViewModel
{
LotNumber = lotNumber,
ItemNumber = itemNumber
});
}
}
return lotViewModels;
}
/// <inheritdoc />
public List<PartOperationViewModel> ParsePartOperations(Stream fileStream)
{
using var workbook = WorkbookFactory.Create(ResetStream(fileStream));
var worksheet = workbook.GetSheetAt(0);
var formatter = new DataFormatter();
var partOperations = new List<PartOperationViewModel>();
var lastRow = worksheet.LastRowNum + 1;
for (var row = 2; row <= lastRow; row++)
{
try
{
var itemNumber = GetCellText(worksheet, row, 1, formatter);
var operationNumber = GetCellText(worksheet, row, 2, formatter);
var misNumber = GetCellText(worksheet, row, 3, formatter);
var misRevision = GetCellText(worksheet, row, 4, formatter);
if (!string.IsNullOrEmpty(operationNumber) && operationNumber.Contains('.'))
{
operationNumber = operationNumber[..operationNumber.IndexOf('.')];
}
if (!string.IsNullOrEmpty(itemNumber) && !string.IsNullOrEmpty(operationNumber))
{
partOperations.Add(new PartOperationViewModel
{
ItemNumber = itemNumber,
OperationNumber = operationNumber,
MisNumber = misNumber,
MisRevision = misRevision
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse row {RowNumber} in part operations sheet", row);
}
}
return partOperations;
}
private static string GetCellText(ISheet sheet, int rowNumber1Based, int colNumber1Based, DataFormatter formatter)
{
var row = sheet.GetRow(rowNumber1Based - 1);
if (row == null)
{
return string.Empty;
}
var cell = row.GetCell(colNumber1Based - 1);
if (cell == null)
{
return string.Empty;
}
return formatter.FormatCellValue(cell).Trim();
}
private static Stream ResetStream(Stream stream)
{
if (stream.CanSeek)
{
stream.Position = 0;
}
return stream;
}
}
@@ -1,70 +1,85 @@
using ClosedXML.Excel;
using JdeScoping.Core.Interfaces;
namespace JdeScoping.ExcelIO.Templates;
/// <summary>
/// Service for generating Excel template files for data entry.
/// </summary>
public class ExcelTemplateService : IExcelTemplateService
{
/// <inheritdoc />
public byte[] GenerateSingleColumn<T>(IEnumerable<T> data, string headerText)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Template");
// Write header
worksheet.Cell(1, 1).Value = headerText;
worksheet.Cell(1, 1).Style.Font.Bold = true;
// Write data
var row = 2;
foreach (var item in data)
{
worksheet.Cell(row, 1).Value = item?.ToString() ?? string.Empty;
row++;
}
// Auto-fit column width
worksheet.Column(1).AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
/// <inheritdoc />
public byte[] GenerateMultiColumn(object?[][] data, string[] headers)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Template");
// Write headers
for (var col = 0; col < headers.Length; col++)
{
worksheet.Cell(1, col + 1).Value = headers[col];
worksheet.Cell(1, col + 1).Style.Font.Bold = true;
}
// Write data
for (var row = 0; row < data.Length; row++)
{
for (var col = 0; col < data[row].Length; col++)
{
var value = data[row][col];
if (value != null)
{
worksheet.Cell(row + 2, col + 1).Value = value.ToString();
}
}
}
// Auto-fit column widths
worksheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
}
using JdeScoping.Core.Interfaces;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
namespace JdeScoping.ExcelIO.Templates;
/// <summary>
/// Service for generating Excel template files for data entry.
/// </summary>
public class ExcelTemplateService : IExcelTemplateService
{
/// <inheritdoc />
public byte[] GenerateSingleColumn<T>(IEnumerable<T> data, string headerText)
{
using var workbook = new XSSFWorkbook();
var worksheet = workbook.CreateSheet("Template");
var headerCell = GetOrCreateCell(worksheet, 1, 1);
headerCell.SetCellValue(headerText);
var boldStyle = workbook.CreateCellStyle();
var boldFont = workbook.CreateFont();
boldFont.IsBold = true;
boldStyle.SetFont(boldFont);
headerCell.CellStyle = boldStyle;
var row = 2;
foreach (var item in data)
{
GetOrCreateCell(worksheet, row++, 1).SetCellValue(item?.ToString() ?? string.Empty);
}
worksheet.AutoSizeColumn(0);
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
return stream.ToArray();
}
/// <inheritdoc />
public byte[] GenerateMultiColumn(object?[][] data, string[] headers)
{
using var workbook = new XSSFWorkbook();
var worksheet = workbook.CreateSheet("Template");
var boldStyle = workbook.CreateCellStyle();
var boldFont = workbook.CreateFont();
boldFont.IsBold = true;
boldStyle.SetFont(boldFont);
for (var col = 0; col < headers.Length; col++)
{
var headerCell = GetOrCreateCell(worksheet, 1, col + 1);
headerCell.SetCellValue(headers[col]);
headerCell.CellStyle = boldStyle;
}
for (var row = 0; row < data.Length; row++)
{
for (var col = 0; col < data[row].Length; col++)
{
var value = data[row][col];
if (value != null)
{
GetOrCreateCell(worksheet, row + 2, col + 1).SetCellValue(value.ToString());
}
}
}
for (var col = 0; col < headers.Length; col++)
{
worksheet.AutoSizeColumn(col);
}
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
return stream.ToArray();
}
private static ICell GetOrCreateCell(ISheet sheet, int rowNumber1Based, int colNumber1Based)
{
var row = sheet.GetRow(rowNumber1Based - 1) ?? sheet.CreateRow(rowNumber1Based - 1);
return row.GetCell(colNumber1Based - 1) ?? row.CreateCell(colNumber1Based - 1);
}
}
@@ -1,31 +1,51 @@
using ClosedXML.Excel;
namespace JdeScoping.ExcelIO.Utilities;
/// <summary>
/// Utility class for converting .NET objects to ClosedXML cell values.
/// </summary>
public static class CellValueConverter
{
/// <summary>
/// Converts a .NET object to an XLCellValue for use in ClosedXML worksheets.
/// </summary>
/// <param name="value">The value to convert.</param>
/// <returns>An XLCellValue suitable for setting as a cell value.</returns>
public static XLCellValue ConvertToXlValue(object? value)
{
return value switch
{
null => Blank.Value,
string s => s,
int i => i,
long l => l,
decimal d => d,
double dbl => dbl,
float f => f,
DateTime dt => dt,
bool b => b,
_ => value.ToString() ?? string.Empty
};
}
}
using NPOI.SS.UserModel;
namespace JdeScoping.ExcelIO.Utilities;
/// <summary>
/// Utility class for converting .NET objects to NPOI cell values.
/// </summary>
public static class CellValueConverter
{
/// <summary>
/// Sets an NPOI cell value from a .NET object.
/// </summary>
/// <param name="cell">The target cell.</param>
/// <param name="value">The value to write.</param>
public static void SetCellValue(ICell cell, object? value)
{
switch (value)
{
case null:
cell.SetCellType(CellType.Blank);
break;
case string s:
cell.SetCellValue(s);
break;
case int i:
cell.SetCellValue(i);
break;
case long l:
cell.SetCellValue(l);
break;
case decimal d:
cell.SetCellValue((double)d);
break;
case double dbl:
cell.SetCellValue(dbl);
break;
case float f:
cell.SetCellValue(f);
break;
case DateTime dt:
cell.SetCellValue(dt);
break;
case bool b:
cell.SetCellValue(b);
break;
default:
cell.SetCellValue(value.ToString() ?? string.Empty);
break;
}
}
}