Files
jdescopingtool/PLANS/2026-01-06-fluent-excel-mapping-implementation.md
T
Joseph Doherty 1618b6664d refactor: remove unused CMS/JDE repositories and data sources
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.
2026-01-07 05:04:49 -05:00

1433 lines
41 KiB
Markdown

# 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**
```bash
mkdir -p src/JdeScoping.ExcelIO/Mapping
```
**Step 2: Write ColumnDefinition class**
```csharp
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**
```bash
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**
```csharp
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**
```bash
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**
```csharp
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**
```bash
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**
```csharp
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**
```bash
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**
```csharp
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**
```bash
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**
```csharp
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**
```bash
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**
```bash
mkdir -p src/JdeScoping.Core/Models/SearchResults
```
**Step 2: Write SearchResult.cs (pure POCO, no attributes)**
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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**
```bash
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**
```bash
mkdir -p src/JdeScoping.ExcelIO/Mapping/Maps
```
**Step 2: Write SearchResultMap.cs**
```csharp
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**
```csharp
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**
```csharp
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**
```bash
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**
```csharp
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`:
```csharp
/// <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**
```bash
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:
```csharp
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
```
Add to `AddExcelIO` method:
```csharp
// 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**
```bash
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:
```csharp
using JdeScoping.ExcelIO.Models.Reporting;
```
With:
```csharp
using JdeScoping.Core.Models.SearchResults;
```
**Step 2: Replace AttributeTableWriter with FluentTableWriter**
Change constructor and field:
```csharp
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**
```csharp
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**
```bash
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:
```csharp
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.Results;
```
With:
```csharp
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**
```bash
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:
```csharp
using JdeScoping.ExcelIO.Models.Reporting;
```
With:
```csharp
using JdeScoping.Core.Models.SearchResults;
```
**Step 2: Verify build**
Run: `dotnet build src/JdeScoping.ExcelIO`
Expected: Success
**Step 3: Commit**
```bash
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:
```bash
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**
```bash
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**
```bash
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:
```bash
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**
```bash
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**
```bash
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:
```bash
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**
```bash
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:
```csharp
services.AddSingleton<OutputColumnCache>();
services.AddSingleton<AttributeTableWriter>();
```
**Step 4: Verify build**
Run: `dotnet build`
Expected: Success
**Step 5: Commit**
```bash
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:
```csharp
using JdeScoping.ExcelIO.Models.Reporting;
```
To:
```csharp
using JdeScoping.Core.Models.SearchResults;
```
**Step 3: Remove or update tests for deleted infrastructure**
- Delete `AttributeTableWriterTests.cs` if it exists
- Delete `OutputColumnCacheTests.cs` if 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**
```bash
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:
```csharp
using JdeScoping.DataAccess.Models.Results;
```
To:
```csharp
using JdeScoping.Core.Models.SearchResults;
```
**Step 2: Run all tests**
Run: `dotnet test`
Expected: All tests pass
**Step 3: Commit**
```bash
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:
```bash
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**
```bash
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<T>, ColumnBuilder<T,P> |
| OutputColumnCache (reflection) | ExcelMapRegistry (explicit registration) |
| AttributeTableWriter | FluentTableWriter |
**Files Created:** 12
**Files Deleted:** ~15
**Net reduction:** ~3 files, significantly cleaner architecture