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

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.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

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