Remove legacy JDE and CMS direct-access code that is no longer used: - Delete ICmsDataSource, IJdeDataSource interfaces - Delete ISearchProcessor, IUpdateProcessor interfaces - Delete IJdeRepository and ICmsRepository (all partials) - Delete JdeRepository and CmsRepository implementations - Delete JdeQueries and CmsQueries - Delete JdeFileDataSource, JdeOracleDataSource - Delete CmsFileDataSource, CmsOracleDataSource - Remove unused methods from LotFinderRepository interfaces - Delete associated unit tests (CmsRepositoryTests, JdeRepositoryTests) All data sync now uses ETL pipelines via DataSync project.
41 KiB
Fluent Excel Mapping Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace attribute-based Excel column configuration with fluent mapping classes, moving result models to Core as pure POCOs.
Architecture: External metadata configuration pattern (like Entity Framework Fluent API). Core models remain pure domain objects without presentation concerns. ExcelIO owns all Excel-specific configuration via fluent map classes.
Tech Stack: C# records, expression trees for property access, ClosedXML for Excel generation.
Phase 1: Create Mapping Infrastructure in ExcelIO
Task 1: Create ColumnDefinition class
Files:
- Create:
src/JdeScoping.ExcelIO/Mapping/ColumnDefinition.cs
Step 1: Create the Mapping directory
mkdir -p src/JdeScoping.ExcelIO/Mapping
Step 2: Write ColumnDefinition class
namespace JdeScoping.ExcelIO.Mapping;
/// <summary>
/// Defines how a property maps to an Excel column.
/// </summary>
public sealed class ColumnDefinition
{
/// <summary>Property name for debugging/error messages.</summary>
public required string PropertyName { get; init; }
/// <summary>Compiled delegate to get property value from object.</summary>
public required Func<object, object?> ValueGetter { get; init; }
/// <summary>Property type for formatting decisions.</summary>
public required Type PropertyType { get; init; }
/// <summary>Column display order (lower = leftmost).</summary>
public int Order { get; set; }
/// <summary>Column header text.</summary>
public string HeaderText { get; set; } = string.Empty;
/// <summary>Excel number format string.</summary>
public string Format { get; set; } = "@";
/// <summary>Auto-size column width.</summary>
public bool AutoWidth { get; set; } = true;
/// <summary>Manual column width (when AutoWidth = false).</summary>
public double Width { get; set; }
/// <summary>Enable text wrapping.</summary>
public bool WrapText { get; set; }
}
Step 3: Verify file compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 4: Commit
git add src/JdeScoping.ExcelIO/Mapping/ColumnDefinition.cs
git commit -m "feat(ExcelIO): add ColumnDefinition for fluent mapping"
Task 2: Create ColumnBuilder fluent API
Files:
- Create:
src/JdeScoping.ExcelIO/Mapping/ColumnBuilder.cs
Step 1: Write ColumnBuilder class
namespace JdeScoping.ExcelIO.Mapping;
/// <summary>
/// Fluent builder for configuring Excel column properties.
/// </summary>
/// <typeparam name="T">The model type being mapped.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
public sealed class ColumnBuilder<T, TProperty>
{
private readonly ColumnDefinition _definition;
internal ColumnBuilder(ColumnDefinition definition)
{
_definition = definition;
}
/// <summary>Sets the column display order.</summary>
public ColumnBuilder<T, TProperty> Order(int order)
{
_definition.Order = order;
return this;
}
/// <summary>Sets the column header text.</summary>
public ColumnBuilder<T, TProperty> Header(string text)
{
_definition.HeaderText = text;
return this;
}
/// <summary>Sets the Excel number format.</summary>
public ColumnBuilder<T, TProperty> Format(string format)
{
_definition.Format = format;
return this;
}
/// <summary>Sets a fixed column width (disables auto-width).</summary>
public ColumnBuilder<T, TProperty> Width(double width)
{
_definition.AutoWidth = false;
_definition.Width = width;
return this;
}
/// <summary>Enables text wrapping for the column.</summary>
public ColumnBuilder<T, TProperty> WrapText(bool wrap = true)
{
_definition.WrapText = wrap;
return this;
}
}
Step 2: Verify file compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 3: Commit
git add src/JdeScoping.ExcelIO/Mapping/ColumnBuilder.cs
git commit -m "feat(ExcelIO): add ColumnBuilder fluent API"
Task 3: Create ExcelClassMap base class
Files:
- Create:
src/JdeScoping.ExcelIO/Mapping/ExcelClassMap.cs
Step 1: Write ExcelClassMap and IExcelClassMap
using System.Linq.Expressions;
namespace JdeScoping.ExcelIO.Mapping;
/// <summary>
/// Interface for Excel class maps (non-generic access).
/// </summary>
public interface IExcelClassMap
{
/// <summary>The type this map applies to.</summary>
Type MappedType { get; }
/// <summary>Excel table name (for named ranges).</summary>
string? TableName { get; }
/// <summary>Worksheet tab name.</summary>
string? TabName { get; }
/// <summary>Ordered column definitions.</summary>
IReadOnlyList<ColumnDefinition> Columns { get; }
}
/// <summary>
/// Base class for defining Excel column mappings via fluent API.
/// </summary>
/// <typeparam name="T">The model type to map.</typeparam>
public abstract class ExcelClassMap<T> : IExcelClassMap
{
private readonly List<ColumnDefinition> _columns = [];
/// <inheritdoc />
public Type MappedType => typeof(T);
/// <inheritdoc />
public string? TableName { get; private set; }
/// <inheritdoc />
public string? TabName { get; private set; }
/// <inheritdoc />
public IReadOnlyList<ColumnDefinition> Columns =>
_columns.OrderBy(c => c.Order).ThenBy(c => c.PropertyName).ToList();
/// <summary>
/// Configures the table and tab names for this model.
/// </summary>
protected void Table(string tableName, string tabName)
{
TableName = tableName;
TabName = tabName;
}
/// <summary>
/// Maps a property to an Excel column.
/// </summary>
protected ColumnBuilder<T, TProperty> Map<TProperty>(Expression<Func<T, TProperty>> property)
{
var memberExpr = property.Body as MemberExpression
?? throw new ArgumentException("Expression must be a property access", nameof(property));
var propertyName = memberExpr.Member.Name;
var compiled = property.Compile();
var definition = new ColumnDefinition
{
PropertyName = propertyName,
PropertyType = typeof(TProperty),
ValueGetter = obj => compiled((T)obj),
HeaderText = propertyName // Default to property name
};
_columns.Add(definition);
return new ColumnBuilder<T, TProperty>(definition);
}
}
Step 2: Verify file compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 3: Commit
git add src/JdeScoping.ExcelIO/Mapping/ExcelClassMap.cs
git commit -m "feat(ExcelIO): add ExcelClassMap base class with fluent Map method"
Task 4: Create ExcelMapRegistry
Files:
- Create:
src/JdeScoping.ExcelIO/Mapping/ExcelMapRegistry.cs
Step 1: Write ExcelMapRegistry class
namespace JdeScoping.ExcelIO.Mapping;
/// <summary>
/// Registry for Excel class maps, used for DI and lookup.
/// </summary>
public sealed class ExcelMapRegistry
{
private readonly Dictionary<Type, IExcelClassMap> _maps = new();
/// <summary>
/// Registers a map for a type.
/// </summary>
public void Register<T>(ExcelClassMap<T> map)
{
_maps[typeof(T)] = map;
}
/// <summary>
/// Gets the map for a type.
/// </summary>
public IExcelClassMap GetMap<T>() => GetMap(typeof(T));
/// <summary>
/// Gets the map for a type.
/// </summary>
public IExcelClassMap GetMap(Type type)
{
if (_maps.TryGetValue(type, out var map))
return map;
throw new InvalidOperationException(
$"No Excel map registered for type {type.Name}. " +
$"Register a map using ExcelMapRegistry.Register().");
}
/// <summary>
/// Checks if a map exists for a type.
/// </summary>
public bool HasMap<T>() => HasMap(typeof(T));
/// <summary>
/// Checks if a map exists for a type.
/// </summary>
public bool HasMap(Type type) => _maps.ContainsKey(type);
}
Step 2: Verify file compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 3: Commit
git add src/JdeScoping.ExcelIO/Mapping/ExcelMapRegistry.cs
git commit -m "feat(ExcelIO): add ExcelMapRegistry for DI integration"
Task 5: Create ExcelFormats constants
Files:
- Create:
src/JdeScoping.ExcelIO/Mapping/ExcelFormats.cs
Step 1: Write ExcelFormats class
namespace JdeScoping.ExcelIO.Mapping;
/// <summary>
/// Standard Excel format strings for column configuration.
/// </summary>
public static class ExcelFormats
{
/// <summary>Text format (default).</summary>
public const string Text = "@";
/// <summary>Date format: MM/dd/yyyy</summary>
public const string Date = "[$-409]MM/dd/yyyy;@";
/// <summary>Timestamp format: m/d/yy h:mm AM/PM</summary>
public const string Timestamp = "[$-409]m/d/yy h:mm AM/PM;@";
/// <summary>Default width for wrapped text columns.</summary>
public const double WrappedColumnWidth = 65;
}
Step 2: Verify file compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 3: Commit
git add src/JdeScoping.ExcelIO/Mapping/ExcelFormats.cs
git commit -m "feat(ExcelIO): add ExcelFormats constants"
Task 6: Write unit tests for mapping infrastructure
Files:
- Create:
tests/JdeScoping.ExcelIO.Tests/Mapping/ExcelClassMapTests.cs
Step 1: Write test class
using JdeScoping.ExcelIO.Mapping;
using Shouldly;
using Xunit;
namespace JdeScoping.ExcelIO.Tests.Mapping;
public class ExcelClassMapTests
{
// Test model
private sealed class TestModel
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
}
// Test map
private sealed class TestModelMap : ExcelClassMap<TestModel>
{
public TestModelMap()
{
Table("Test_Table", "Test Tab");
Map(x => x.Id).Order(10).Header("ID Number");
Map(x => x.Name).Order(20).Header("Full Name");
Map(x => x.CreatedAt).Order(30).Header("Created").Format(ExcelFormats.Timestamp);
}
}
[Fact]
public void MappedType_ReturnsCorrectType()
{
var map = new TestModelMap();
map.MappedType.ShouldBe(typeof(TestModel));
}
[Fact]
public void Table_SetsTableAndTabName()
{
var map = new TestModelMap();
map.TableName.ShouldBe("Test_Table");
map.TabName.ShouldBe("Test Tab");
}
[Fact]
public void Columns_ReturnsOrderedColumns()
{
var map = new TestModelMap();
var columns = map.Columns;
columns.Count.ShouldBe(3);
columns[0].PropertyName.ShouldBe("Id");
columns[1].PropertyName.ShouldBe("Name");
columns[2].PropertyName.ShouldBe("CreatedAt");
}
[Fact]
public void Map_SetsHeaderText()
{
var map = new TestModelMap();
var columns = map.Columns;
columns[0].HeaderText.ShouldBe("ID Number");
columns[1].HeaderText.ShouldBe("Full Name");
columns[2].HeaderText.ShouldBe("Created");
}
[Fact]
public void Map_SetsFormat()
{
var map = new TestModelMap();
var columns = map.Columns;
columns[2].Format.ShouldBe(ExcelFormats.Timestamp);
}
[Fact]
public void ValueGetter_ExtractsPropertyValue()
{
var map = new TestModelMap();
var columns = map.Columns;
var testObj = new TestModel { Id = 42, Name = "Test", CreatedAt = new DateTime(2024, 1, 15) };
columns[0].ValueGetter(testObj).ShouldBe(42);
columns[1].ValueGetter(testObj).ShouldBe("Test");
columns[2].ValueGetter(testObj).ShouldBe(new DateTime(2024, 1, 15));
}
}
public class ExcelMapRegistryTests
{
private sealed class DummyModel { public int Id { get; init; } }
private sealed class DummyMap : ExcelClassMap<DummyModel>
{
public DummyMap() { Map(x => x.Id).Order(1).Header("ID"); }
}
[Fact]
public void Register_AndGetMap_ReturnsMap()
{
var registry = new ExcelMapRegistry();
var map = new DummyMap();
registry.Register(map);
var retrieved = registry.GetMap<DummyModel>();
retrieved.ShouldBe(map);
}
[Fact]
public void GetMap_UnregisteredType_ThrowsInvalidOperationException()
{
var registry = new ExcelMapRegistry();
Should.Throw<InvalidOperationException>(() => registry.GetMap<DummyModel>());
}
[Fact]
public void HasMap_RegisteredType_ReturnsTrue()
{
var registry = new ExcelMapRegistry();
registry.Register(new DummyMap());
registry.HasMap<DummyModel>().ShouldBeTrue();
}
[Fact]
public void HasMap_UnregisteredType_ReturnsFalse()
{
var registry = new ExcelMapRegistry();
registry.HasMap<DummyModel>().ShouldBeFalse();
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.ExcelIO.Tests --filter "FullyQualifiedName~ExcelClassMapTests|FullyQualifiedName~ExcelMapRegistryTests"
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.ExcelIO.Tests/Mapping/ExcelClassMapTests.cs
git commit -m "test(ExcelIO): add unit tests for fluent mapping infrastructure"
Phase 2: Move Models to Core
Task 7: Create SearchResults models in Core
Files:
- Create:
src/JdeScoping.Core/Models/SearchResults/SearchResult.cs - Create:
src/JdeScoping.Core/Models/SearchResults/MisSearchResult.cs - Create:
src/JdeScoping.Core/Models/SearchResults/MisNonMatchSearchResult.cs - Create:
src/JdeScoping.Core/Models/SearchResults/SearchModel.cs
Step 1: Create directory
mkdir -p src/JdeScoping.Core/Models/SearchResults
Step 2: Write SearchResult.cs (pure POCO, no attributes)
namespace JdeScoping.Core.Models.SearchResults;
/// <summary>
/// JDE search result - work order with current status.
/// </summary>
public sealed class SearchResult
{
public long WorkOrderNumber { get; init; }
public string WorkOrderBranchCode { get; init; } = string.Empty;
public string LotNumber { get; init; } = string.Empty;
public string ItemNumber { get; init; } = string.Empty;
public string PlanningFamily { get; init; } = string.Empty;
public string StockingType { get; init; } = string.Empty;
public decimal OrderQuantity { get; init; }
public decimal HeldQuantity { get; init; }
public decimal ScrappedQuantity { get; init; }
public decimal ShippedQuantity { get; init; }
public string StepBranchCode { get; init; } = string.Empty;
public decimal StepNumber { get; init; }
public string StepDescription { get; init; } = string.Empty;
public string FunctionOperationDescription { get; init; } = string.Empty;
public DateTime StepUpdateDt { get; init; }
public string StatusCode { get; init; } = string.Empty;
public string StatusDescription { get; init; } = string.Empty;
public DateTime? StatusUpdateDt { get; init; }
// Inclusion flags
public bool ManuallySpecified { get; init; }
public bool SplitOrder { get; init; }
public bool Cardex { get; init; }
public bool PartsList { get; init; }
public bool Flagged { get; init; }
/// <summary>
/// Computed reason why this work order was included in results.
/// </summary>
public string InclusionReason => (ManuallySpecified, Flagged, Cardex, PartsList, SplitOrder) switch
{
(true, _, _, _, _) => "ManuallySpecified",
(_, true, _, _, _) => "Flagged",
(_, _, true, true, _) => "ComponentUsage (CARDEX + Parts List)",
(_, _, true, false, _) => "ComponentUsage (CARDEX)",
(_, _, false, true, _) => "ComponentUsage (Parts List)",
(_, _, _, _, true) => "Split order",
_ => "UNKNOWN"
};
}
Step 3: Write MisSearchResult.cs
namespace JdeScoping.Core.Models.SearchResults;
/// <summary>
/// MIS (Manufacturing Instruction Sheet) data result.
/// </summary>
public sealed class MisSearchResult
{
public string ItemNumber { get; init; } = string.Empty;
public string ItemDescription { get; init; } = string.Empty;
public string SequenceNumber { get; init; } = string.Empty;
public string MisNumber { get; init; } = string.Empty;
public string RevId { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime? ReleaseDate { get; init; }
public string BranchCode { get; init; } = string.Empty;
public decimal JobStepSequenceNumber { get; init; }
public decimal? MatchedSequenceNumber { get; init; }
public bool RoutingMatch { get; init; }
public bool MasterMatch { get; init; }
public string FunctionOperationDescription { get; init; } = string.Empty;
public string CharNumber { get; init; } = string.Empty;
public string TestDescription { get; init; } = string.Empty;
public string SamplingType { get; init; } = string.Empty;
public string SamplingValue { get; init; } = string.Empty;
public string ToolsGauges { get; init; } = string.Empty;
public string WorkInstructions { get; init; } = string.Empty;
}
Step 4: Write MisNonMatchSearchResult.cs
namespace JdeScoping.Core.Models.SearchResults;
/// <summary>
/// MIS non-match investigation result.
/// </summary>
public sealed class MisNonMatchSearchResult
{
public string WorkCenterCode { get; init; } = string.Empty;
public long WorkOrderNumber { get; init; }
public DateTime WorkOrderStartDate { get; init; }
public decimal JobStepNumber { get; init; }
public string JobStepDescription { get; init; } = string.Empty;
public DateTime? JobStepEndDate { get; init; }
public string FunctionCode { get; init; } = string.Empty;
public bool WasJobStepAdded { get; init; }
public decimal? MatchedJobStepNumber { get; init; }
public string ItemNumber { get; init; } = string.Empty;
public string ItemDescription { get; init; } = string.Empty;
public string RoutingType { get; init; } = string.Empty;
}
Step 5: Write SearchModel.cs
namespace JdeScoping.Core.Models.SearchResults;
/// <summary>
/// Aggregates search metadata and results for export.
/// </summary>
public class SearchModel
{
public int Id { get; set; }
public string UserName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public DateTime? SubmitDt { get; set; }
public DateTime? StartDt { get; set; }
public DateTime? EndDt { get; set; }
public bool ExtractMisData { get; set; }
public List<SearchResult> Results { get; set; } = [];
public List<MisSearchResult> MisResults { get; set; } = [];
public List<MisNonMatchSearchResult> MisNonMatchResults { get; set; } = [];
}
Step 6: Verify Core compiles
Run: dotnet build src/JdeScoping.Core
Expected: Success
Step 7: Commit
git add src/JdeScoping.Core/Models/SearchResults/
git commit -m "feat(Core): add SearchResults models as pure POCOs"
Task 8: Create fluent maps in ExcelIO
Files:
- Create:
src/JdeScoping.ExcelIO/Mapping/Maps/SearchResultMap.cs - Create:
src/JdeScoping.ExcelIO/Mapping/Maps/MisSearchResultMap.cs - Create:
src/JdeScoping.ExcelIO/Mapping/Maps/MisNonMatchSearchResultMap.cs
Step 1: Create Maps directory
mkdir -p src/JdeScoping.ExcelIO/Mapping/Maps
Step 2: Write SearchResultMap.cs
using JdeScoping.Core.Models.SearchResults;
namespace JdeScoping.ExcelIO.Mapping.Maps;
/// <summary>
/// Excel column mapping for SearchResult.
/// </summary>
public sealed class SearchResultMap : ExcelClassMap<SearchResult>
{
public SearchResultMap()
{
Table("Search_Results", "Search Results");
Map(x => x.WorkOrderNumber).Order(10).Header("Work Order Number");
Map(x => x.WorkOrderBranchCode).Order(20).Header("Work Order Branch Code");
Map(x => x.LotNumber).Order(30).Header("Lot Number");
Map(x => x.ItemNumber).Order(40).Header("Item Number");
Map(x => x.PlanningFamily).Order(50).Header("Planning Family");
Map(x => x.StockingType).Order(55).Header("Stocking Type");
Map(x => x.OrderQuantity).Order(60).Header("Order Quantity");
Map(x => x.HeldQuantity).Order(70).Header("Held Quantity");
Map(x => x.ScrappedQuantity).Order(80).Header("Scrapped Quantity");
Map(x => x.ShippedQuantity).Order(90).Header("Shipped Quantity");
Map(x => x.StepBranchCode).Order(100).Header("Operation Step Branch Code");
Map(x => x.StepNumber).Order(110).Header("Operation Step");
Map(x => x.StepDescription).Order(120).Header("Operation Step Description");
Map(x => x.FunctionOperationDescription).Order(130).Header("Function Operation Description");
Map(x => x.StepUpdateDt).Order(140).Header("Operation Step Update Timestamp").Format(ExcelFormats.Timestamp);
Map(x => x.StatusCode).Order(150).Header("Status Code");
Map(x => x.StatusDescription).Order(160).Header("Status Description");
Map(x => x.StatusUpdateDt).Order(170).Header("Status Update Timestamp").Format(ExcelFormats.Date);
Map(x => x.InclusionReason).Order(180).Header("Inclusion Reason");
}
}
Step 3: Write MisSearchResultMap.cs
using JdeScoping.Core.Models.SearchResults;
namespace JdeScoping.ExcelIO.Mapping.Maps;
/// <summary>
/// Excel column mapping for MisSearchResult.
/// </summary>
public sealed class MisSearchResultMap : ExcelClassMap<MisSearchResult>
{
public MisSearchResultMap()
{
Table("MIS_Info", "MIS Info");
Map(x => x.ItemNumber).Order(10).Header("Item Number");
Map(x => x.SequenceNumber).Order(20).Header("MIS Job Step Sequence Number");
Map(x => x.MisNumber).Order(30).Header("MIS Number");
Map(x => x.RevId).Order(40).Header("MIS Revision");
Map(x => x.ItemDescription).Order(50).Header("Item Description");
Map(x => x.Status).Order(60).Header("MIS Release Status");
Map(x => x.ReleaseDate).Order(70).Header("MIS Release Date").Format(ExcelFormats.Timestamp);
Map(x => x.BranchCode).Order(80).Header("Branch Code");
Map(x => x.JobStepSequenceNumber).Order(90).Header("Job Step Sequence Number");
Map(x => x.MatchedSequenceNumber).Order(100).Header("Matched Sequence Number");
Map(x => x.RoutingMatch).Order(110).Header("Matched to F3112Z1?");
Map(x => x.MasterMatch).Order(120).Header("Matched to F3003?");
Map(x => x.FunctionOperationDescription).Order(130).Header("Function Operation Description");
Map(x => x.CharNumber).Order(140).Header("Char Number");
Map(x => x.TestDescription).Order(150).Header("Test Description").Width(ExcelFormats.WrappedColumnWidth).WrapText();
Map(x => x.SamplingType).Order(160).Header("Sampling Type");
Map(x => x.SamplingValue).Order(170).Header("Sampling Value");
Map(x => x.ToolsGauges).Order(180).Header("Tools & Gauges").Width(ExcelFormats.WrappedColumnWidth).WrapText();
Map(x => x.WorkInstructions).Order(190).Header("Work Instructions").Width(ExcelFormats.WrappedColumnWidth).WrapText();
}
}
Step 4: Write MisNonMatchSearchResultMap.cs
using JdeScoping.Core.Models.SearchResults;
namespace JdeScoping.ExcelIO.Mapping.Maps;
/// <summary>
/// Excel column mapping for MisNonMatchSearchResult.
/// </summary>
public sealed class MisNonMatchSearchResultMap : ExcelClassMap<MisNonMatchSearchResult>
{
public MisNonMatchSearchResultMap()
{
Table("Investigation", "Investigation");
Map(x => x.WorkCenterCode).Order(10).Header("Work Center Code");
Map(x => x.WorkOrderNumber).Order(20).Header("Work Order Number");
Map(x => x.WorkOrderStartDate).Order(30).Header("Work Order Start Date").Format(ExcelFormats.Date);
Map(x => x.JobStepNumber).Order(40).Header("Job Step Number");
Map(x => x.JobStepDescription).Order(50).Header("Function Operation Description");
Map(x => x.JobStepEndDate).Order(60).Header("Job Step End Date").Format(ExcelFormats.Date);
Map(x => x.FunctionCode).Order(70).Header("Function Code");
Map(x => x.WasJobStepAdded).Order(75).Header("Was Job Step Added?");
Map(x => x.MatchedJobStepNumber).Order(76).Header("Matched Job Step Number");
Map(x => x.ItemNumber).Order(80).Header("Item Number");
Map(x => x.ItemDescription).Order(90).Header("Item Description");
Map(x => x.RoutingType).Order(100).Header("Routing Type");
}
}
Step 5: Verify ExcelIO compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 6: Commit
git add src/JdeScoping.ExcelIO/Mapping/Maps/
git commit -m "feat(ExcelIO): add fluent maps for SearchResult models"
Phase 3: Create FluentTableWriter
Task 9: Create FluentTableWriter
Files:
- Create:
src/JdeScoping.ExcelIO/Generators/FluentTableWriter.cs
Step 1: Write FluentTableWriter class
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Mapping;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Writes Excel tables using fluent mapping configuration.
/// </summary>
public sealed class FluentTableWriter
{
private readonly ExcelMapRegistry _registry;
public FluentTableWriter(ExcelMapRegistry registry)
{
_registry = registry;
}
/// <summary>
/// Writes a table to the worksheet using the registered map for type T.
/// </summary>
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 = 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;
}
private 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
};
}
}
Step 2: Add DataPaddingFactor to ExcelFormats if missing
Check if ExcelFormats.DataPaddingFactor exists in Formatting/ExcelFormats.cs. If not, add it to Mapping/ExcelFormats.cs:
/// <summary>Multiplier for auto-width columns.</summary>
public const double DataPaddingFactor = 1.1;
Step 3: Verify ExcelIO compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 4: Commit
git add src/JdeScoping.ExcelIO/Generators/FluentTableWriter.cs
git add src/JdeScoping.ExcelIO/Mapping/ExcelFormats.cs
git commit -m "feat(ExcelIO): add FluentTableWriter using map registry"
Task 10: Register maps in DependencyInjection
Files:
- Modify:
src/JdeScoping.ExcelIO/DependencyInjection.cs
Step 1: Update DependencyInjection.cs
Add imports and registry registration:
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
Add to AddExcelIO method:
// Register Excel map registry with all maps
services.AddSingleton(sp =>
{
var registry = new ExcelMapRegistry();
registry.Register(new SearchResultMap());
registry.Register(new MisSearchResultMap());
registry.Register(new MisNonMatchSearchResultMap());
return registry;
});
// Register fluent table writer
services.AddSingleton<FluentTableWriter>();
Step 2: Verify ExcelIO compiles
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 3: Commit
git add src/JdeScoping.ExcelIO/DependencyInjection.cs
git commit -m "feat(ExcelIO): register ExcelMapRegistry and FluentTableWriter in DI"
Phase 4: Migrate ExcelExportService and DataAccess
Task 11: Update ExcelExportService to use Core models and FluentTableWriter
Files:
- Modify:
src/JdeScoping.ExcelIO/ExcelExportService.cs
Step 1: Update imports
Replace:
using JdeScoping.ExcelIO.Models.Reporting;
With:
using JdeScoping.Core.Models.SearchResults;
Step 2: Replace AttributeTableWriter with FluentTableWriter
Change constructor and field:
private readonly FluentTableWriter _tableWriter;
public ExcelExportService(
ILogger<ExcelExportService> logger,
IOptions<ExcelExportOptions> options,
CriteriaSheetGenerator criteriaGenerator,
FluentTableWriter tableWriter)
Step 3: Update GenerateResultsSheet to use map for tab name
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);
// ... rest unchanged
}
Inject ExcelMapRegistry _registry in constructor and update all sheet generation methods similarly.
Step 4: Verify build
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 5: Commit
git add src/JdeScoping.ExcelIO/ExcelExportService.cs
git commit -m "refactor(ExcelIO): migrate ExcelExportService to Core models and FluentTableWriter"
Task 12: Update DataAccess to use Core models
Files:
- Modify:
src/JdeScoping.DataAccess/Services/SearchProcessor.cs
Step 1: Update imports
Replace:
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.Results;
With:
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.DataAccess.Models; // Keep for SearchQueryResult
Step 2: Verify build
Run: dotnet build src/JdeScoping.DataAccess
Expected: Success
Step 3: Commit
git add src/JdeScoping.DataAccess/Services/SearchProcessor.cs
git commit -m "refactor(DataAccess): use Core.Models.SearchResults"
Task 13: Update CriteriaSheetGenerator to use Core models
Files:
- Modify:
src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs
Step 1: Update imports
Replace:
using JdeScoping.ExcelIO.Models.Reporting;
With:
using JdeScoping.Core.Models.SearchResults;
Step 2: Verify build
Run: dotnet build src/JdeScoping.ExcelIO
Expected: Success
Step 3: Commit
git add src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs
git commit -m "refactor(ExcelIO): update CriteriaSheetGenerator to use Core models"
Phase 5: Delete Duplicate Code
Task 14: Delete DataAccess Models and Attributes
Files:
- Delete:
src/JdeScoping.DataAccess/Models/Results/(entire directory) - Delete:
src/JdeScoping.DataAccess/Models/SearchModel.cs - Delete:
src/JdeScoping.DataAccess/Attributes/(entire directory)
Step 1: Verify no remaining usages
Run:
grep -r "JdeScoping.DataAccess.Models.Results" src/
grep -r "JdeScoping.DataAccess.Attributes" src/
Expected: No matches (or only in files being deleted)
Step 2: Delete files
rm -rf src/JdeScoping.DataAccess/Models/Results/
rm src/JdeScoping.DataAccess/Models/SearchModel.cs
rm -rf src/JdeScoping.DataAccess/Attributes/
Step 3: Verify build
Run: dotnet build
Expected: Success
Step 4: Commit
git add -A
git commit -m "refactor(DataAccess): remove duplicate Models and Attributes (now in Core/ExcelIO)"
Task 15: Delete ExcelIO duplicate Models/Reporting
Files:
- Delete:
src/JdeScoping.ExcelIO/Models/Reporting/SearchResult.cs - Delete:
src/JdeScoping.ExcelIO/Models/Reporting/MisSearchResult.cs - Delete:
src/JdeScoping.ExcelIO/Models/Reporting/MisNonMatchSearchResult.cs - Delete:
src/JdeScoping.ExcelIO/Models/Reporting/SearchModel.cs
Note: Keep filter entry models (*FilterEntry.cs, TimespanFilter.cs) as they're specific to ExcelIO criteria sheet.
Step 1: Verify no remaining usages of old models
Run:
grep -r "JdeScoping.ExcelIO.Models.Reporting.SearchResult" src/
grep -r "JdeScoping.ExcelIO.Models.Reporting.MisSearchResult" src/
grep -r "JdeScoping.ExcelIO.Models.Reporting.SearchModel" src/
Expected: No matches
Step 2: Delete files
rm src/JdeScoping.ExcelIO/Models/Reporting/SearchResult.cs
rm src/JdeScoping.ExcelIO/Models/Reporting/MisSearchResult.cs
rm src/JdeScoping.ExcelIO/Models/Reporting/MisNonMatchSearchResult.cs
rm src/JdeScoping.ExcelIO/Models/Reporting/SearchModel.cs
Step 3: Verify build
Run: dotnet build
Expected: Success
Step 4: Commit
git add -A
git commit -m "refactor(ExcelIO): remove duplicate result models (now in Core)"
Task 16: Delete old attribute-based infrastructure from ExcelIO
Files:
- Delete:
src/JdeScoping.ExcelIO/Attributes/OutputColumnAttribute.cs - Delete:
src/JdeScoping.ExcelIO/Attributes/OutputTableAttribute.cs - Delete:
src/JdeScoping.ExcelIO/Helpers/OutputColumnCache.cs - Delete:
src/JdeScoping.ExcelIO/Models/OutputColumn.cs - Delete:
src/JdeScoping.ExcelIO/Generators/AttributeTableWriter.cs
Step 1: Verify no remaining usages
Run:
grep -r "OutputColumnAttribute" src/
grep -r "OutputTableAttribute" src/
grep -r "OutputColumnCache" src/
grep -r "AttributeTableWriter" src/
Expected: No matches in source files (tests may still reference)
Step 2: Delete files
rm src/JdeScoping.ExcelIO/Attributes/OutputColumnAttribute.cs
rm src/JdeScoping.ExcelIO/Attributes/OutputTableAttribute.cs
rm src/JdeScoping.ExcelIO/Helpers/OutputColumnCache.cs
rm src/JdeScoping.ExcelIO/Models/OutputColumn.cs
rm src/JdeScoping.ExcelIO/Generators/AttributeTableWriter.cs
rmdir src/JdeScoping.ExcelIO/Attributes 2>/dev/null || true
rmdir src/JdeScoping.ExcelIO/Helpers 2>/dev/null || true
Step 3: Update DependencyInjection.cs to remove old registrations
Remove these lines:
services.AddSingleton<OutputColumnCache>();
services.AddSingleton<AttributeTableWriter>();
Step 4: Verify build
Run: dotnet build
Expected: Success
Step 5: Commit
git add -A
git commit -m "refactor(ExcelIO): remove attribute-based table writer infrastructure"
Phase 6: Update Tests
Task 17: Update ExcelIO tests
Files:
- Modify/Delete tests that reference old attribute-based infrastructure
- Update tests to use Core models
Step 1: Run existing tests to identify failures
Run: dotnet test tests/JdeScoping.ExcelIO.Tests
Expected: Some failures from removed types
Step 2: Update test files to use Core models
Update imports in test files from:
using JdeScoping.ExcelIO.Models.Reporting;
To:
using JdeScoping.Core.Models.SearchResults;
Step 3: Remove or update tests for deleted infrastructure
- Delete
AttributeTableWriterTests.csif it exists - Delete
OutputColumnCacheTests.csif it exists - Update any integration tests to use new FluentTableWriter
Step 4: Run all tests
Run: dotnet test
Expected: All tests pass
Step 5: Commit
git add -A
git commit -m "test: update ExcelIO tests for fluent mapping"
Task 18: Update DataAccess tests
Files:
- Update tests that reference old DataAccess models
Step 1: Update imports in test files
Update from:
using JdeScoping.DataAccess.Models.Results;
To:
using JdeScoping.Core.Models.SearchResults;
Step 2: Run all tests
Run: dotnet test
Expected: All tests pass
Step 3: Commit
git add -A
git commit -m "test: update DataAccess tests for Core models"
Phase 7: Final Verification
Task 19: Full build and test
Step 1: Clean build
Run: dotnet clean && dotnet build
Expected: Success with 0 errors, 0 warnings
Step 2: Run all tests
Run: dotnet test
Expected: All tests pass
Step 3: Verify no duplicate types
Run:
grep -r "class SearchResult" src/ --include="*.cs" | grep -v "ExcelClassMap"
grep -r "class MisSearchResult" src/ --include="*.cs" | grep -v "ExcelClassMap"
Expected: Only one definition of each in Core
Step 4: Final commit
git add -A
git commit -m "refactor: complete fluent Excel mapping migration"
Summary
| Before | After |
|---|---|
| Models duplicated in DataAccess + ExcelIO | Single source of truth in Core |
| Attributes on models (presentation in domain) | Fluent maps in ExcelIO (separation of concerns) |
| OutputColumnAttribute, OutputTableAttribute | ExcelClassMap, ColumnBuilder<T,P> |
| OutputColumnCache (reflection) | ExcelMapRegistry (explicit registration) |
| AttributeTableWriter | FluentTableWriter |
Files Created: 12 Files Deleted: ~15 Net reduction: ~3 files, significantly cleaner architecture