Files
jdescopingtool/PLANS/2026-01-01-excelio-consolidation-implementation.md
T
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

48 KiB

ExcelIO Consolidation Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Rename ExcelExport to ExcelIO and consolidate all Excel file I/O operations into a single project.

Architecture: Move interfaces to Core, create separate services for parsing/templates/export, update FileIOController to use services via DI, move tests to ExcelIO.Tests.

Tech Stack: .NET 10, ClosedXML, xUnit, NSubstitute, Shouldly


Phase 1: Rename Projects

Task 1.1: Rename ExcelExport source project

Step 1: Rename the project directory

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && mv src/JdeScoping.ExcelExport src/JdeScoping.ExcelIO

Step 2: Rename the .csproj file

Run:

mv src/JdeScoping.ExcelIO/JdeScoping.ExcelExport.csproj src/JdeScoping.ExcelIO/JdeScoping.ExcelIO.csproj

Step 3: Update namespace in all source files

Run:

find src/JdeScoping.ExcelIO -name "*.cs" -type f ! -path "*/obj/*" ! -path "*/bin/*" -exec sed -i '' 's/JdeScoping\.ExcelExport/JdeScoping.ExcelIO/g' {} +

Task 1.2: Rename ExcelExport test project

Step 1: Rename the test project directory

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && mv tests/JdeScoping.ExcelExport.Tests tests/JdeScoping.ExcelIO.Tests

Step 2: Rename the .csproj file

Run:

mv tests/JdeScoping.ExcelIO.Tests/JdeScoping.ExcelExport.Tests.csproj tests/JdeScoping.ExcelIO.Tests/JdeScoping.ExcelIO.Tests.csproj

Step 3: Update project reference in test .csproj

Modify tests/JdeScoping.ExcelIO.Tests/JdeScoping.ExcelIO.Tests.csproj: Change:

<ProjectReference Include="..\..\src\JdeScoping.ExcelExport\JdeScoping.ExcelExport.csproj" />

To:

<ProjectReference Include="..\..\src\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />

Step 4: Update namespace in all test files

Run:

find tests/JdeScoping.ExcelIO.Tests -name "*.cs" -type f ! -path "*/obj/*" ! -path "*/bin/*" -exec sed -i '' 's/JdeScoping\.ExcelExport/JdeScoping.ExcelIO/g' {} +

Task 1.3: Update solution file

Files:

  • Modify: JdeScoping.slnx

Step 1: Update solution file paths

In JdeScoping.slnx, change:

<Project Path="src/JdeScoping.ExcelExport/JdeScoping.ExcelExport.csproj" />

To:

<Project Path="src/JdeScoping.ExcelIO/JdeScoping.ExcelIO.csproj" />

And change:

<Project Path="tests/JdeScoping.ExcelExport.Tests/JdeScoping.ExcelExport.Tests.csproj" />

To:

<Project Path="tests/JdeScoping.ExcelIO.Tests/JdeScoping.ExcelIO.Tests.csproj" />

Task 1.4: Update project references

Step 1: Update Host project reference

In src/JdeScoping.Host/JdeScoping.Host.csproj, change:

<ProjectReference Include="..\JdeScoping.ExcelExport\JdeScoping.ExcelExport.csproj" />

To:

<ProjectReference Include="..\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />

Step 2: Update Host Program.cs

In src/JdeScoping.Host/Program.cs, change:

using JdeScoping.ExcelExport;

To:

using JdeScoping.ExcelIO;

And change:

.AddExcelExport(builder.Configuration)

To:

.AddExcelIO(builder.Configuration)

Step 3: Update ServiceCollectionExtensions method name

In src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs, change:

public static IServiceCollection AddExcelExport(

To:

public static IServiceCollection AddExcelIO(

Task 1.5: Clean and verify build

Step 1: Clean old build artifacts

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && rm -rf src/JdeScoping.ExcelIO/obj src/JdeScoping.ExcelIO/bin tests/JdeScoping.ExcelIO.Tests/obj tests/JdeScoping.ExcelIO.Tests/bin

Step 2: Restore and build

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && dotnet restore && dotnet build

Expected: Build succeeded with 0 errors.

Step 3: Run ExcelIO tests

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && dotnet test tests/JdeScoping.ExcelIO.Tests --verbosity quiet

Expected: All tests pass.


Phase 2: Add Interfaces to Core

Task 2.1: Move IExcelExportService to Core

Files:

  • Create: src/JdeScoping.Core/Interfaces/IExcelExportService.cs
  • Delete: src/JdeScoping.ExcelIO/Interfaces/IExcelExportService.cs

Step 1: Create interface in Core

Create src/JdeScoping.Core/Interfaces/IExcelExportService.cs:

namespace JdeScoping.Core.Interfaces;

/// <summary>
/// Service for generating Excel export files from search data.
/// </summary>
public interface IExcelExportService
{
    /// <summary>
    /// Generates an Excel file from the provided search model.
    /// </summary>
    /// <param name="search">Search model with criteria and results.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Excel file as byte array.</returns>
    Task<byte[]> GenerateAsync(object search, CancellationToken cancellationToken = default);
}

Step 2: Delete old interface

Run:

rm src/JdeScoping.ExcelIO/Interfaces/IExcelExportService.cs

Step 3: Update ExcelExportService to use Core interface

In src/JdeScoping.ExcelIO/Export/ExcelExportService.cs (after reorganization), update:

using JdeScoping.Core.Interfaces;

Remove:

using JdeScoping.ExcelIO.Interfaces;

Task 2.2: Create IExcelTemplateService interface

Files:

  • Create: src/JdeScoping.Core/Interfaces/IExcelTemplateService.cs

Step 1: Create interface

Create src/JdeScoping.Core/Interfaces/IExcelTemplateService.cs:

namespace JdeScoping.Core.Interfaces;

/// <summary>
/// Service for generating Excel template files for data entry.
/// </summary>
public interface IExcelTemplateService
{
    /// <summary>
    /// Generates an Excel file with a single column of data.
    /// </summary>
    /// <typeparam name="T">Type of data items.</typeparam>
    /// <param name="data">Data items to write.</param>
    /// <param name="headerText">Header text for the column.</param>
    /// <returns>Excel file as byte array.</returns>
    byte[] GenerateSingleColumn<T>(IEnumerable<T> data, string headerText);

    /// <summary>
    /// Generates an Excel file with multiple columns of data.
    /// </summary>
    /// <param name="data">2D array of data (rows x columns).</param>
    /// <param name="headers">Header text for each column.</param>
    /// <returns>Excel file as byte array.</returns>
    byte[] GenerateMultiColumn(object?[][] data, string[] headers);
}

Task 2.3: Create IExcelParserService interface

Files:

  • Create: src/JdeScoping.Core/Interfaces/IExcelParserService.cs

Step 1: Create interface

Create src/JdeScoping.Core/Interfaces/IExcelParserService.cs:

using JdeScoping.Core.ViewModels;

namespace JdeScoping.Core.Interfaces;

/// <summary>
/// Service for parsing Excel files uploaded by users.
/// </summary>
public interface IExcelParserService
{
    /// <summary>
    /// Parses work order numbers from an Excel file.
    /// </summary>
    /// <param name="fileStream">Excel file stream.</param>
    /// <returns>List of work order numbers.</returns>
    List<long> ParseWorkOrders(Stream fileStream);

    /// <summary>
    /// Parses item numbers from an Excel file.
    /// </summary>
    /// <param name="fileStream">Excel file stream.</param>
    /// <returns>List of item numbers.</returns>
    List<string> ParseItems(Stream fileStream);

    /// <summary>
    /// Parses component lot/item pairs from an Excel file.
    /// </summary>
    /// <param name="fileStream">Excel file stream.</param>
    /// <returns>List of lot view models with LotNumber and ItemNumber.</returns>
    List<LotViewModel> ParseComponentLots(Stream fileStream);

    /// <summary>
    /// Parses part operations from an Excel file.
    /// </summary>
    /// <param name="fileStream">Excel file stream.</param>
    /// <returns>List of part operation view models.</returns>
    List<PartOperationViewModel> ParsePartOperations(Stream fileStream);
}

Phase 3: Create ExcelTemplateService

Task 3.1: Create ExcelTemplateService

Files:

  • Create: src/JdeScoping.ExcelIO/Templates/ExcelTemplateService.cs

Step 1: Create Templates directory

Run:

mkdir -p /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.ExcelIO/Templates

Step 2: Create ExcelTemplateService

Create src/JdeScoping.ExcelIO/Templates/ExcelTemplateService.cs:

using ClosedXML.Excel;
using JdeScoping.Core.Interfaces;

namespace JdeScoping.ExcelIO.Templates;

/// <summary>
/// Service for generating Excel template files for data entry.
/// </summary>
public class ExcelTemplateService : IExcelTemplateService
{
    /// <inheritdoc />
    public byte[] GenerateSingleColumn<T>(IEnumerable<T> data, string headerText)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Template");

        // Write header
        worksheet.Cell(1, 1).Value = headerText;
        worksheet.Cell(1, 1).Style.Font.Bold = true;

        // Write data
        var row = 2;
        foreach (var item in data)
        {
            worksheet.Cell(row, 1).Value = item?.ToString() ?? string.Empty;
            row++;
        }

        // Auto-fit column width
        worksheet.Column(1).AdjustToContents();

        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }

    /// <inheritdoc />
    public byte[] GenerateMultiColumn(object?[][] data, string[] headers)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Template");

        // Write headers
        for (var col = 0; col < headers.Length; col++)
        {
            worksheet.Cell(1, col + 1).Value = headers[col];
            worksheet.Cell(1, col + 1).Style.Font.Bold = true;
        }

        // Write data
        for (var row = 0; row < data.Length; row++)
        {
            for (var col = 0; col < data[row].Length; col++)
            {
                var value = data[row][col];
                if (value != null)
                {
                    worksheet.Cell(row + 2, col + 1).Value = value.ToString();
                }
            }
        }

        // Auto-fit column widths
        worksheet.Columns().AdjustToContents();

        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }
}

Task 3.2: Register ExcelTemplateService

Files:

  • Modify: src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs

Step 1: Add registration

In src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs, add the using statement:

using JdeScoping.ExcelIO.Templates;

Add the registration after the existing services:

// Register template service (singleton - stateless)
services.AddSingleton<IExcelTemplateService, ExcelTemplateService>();

Phase 4: Create ExcelParserService

Task 4.1: Create ExcelParserService

Files:

  • Create: src/JdeScoping.ExcelIO/Parsing/ExcelParserService.cs

Step 1: Create Parsing directory

Run:

mkdir -p /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.ExcelIO/Parsing

Step 2: Create ExcelParserService

Create src/JdeScoping.ExcelIO/Parsing/ExcelParserService.cs:

using ClosedXML.Excel;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.ViewModels;

namespace JdeScoping.ExcelIO.Parsing;

/// <summary>
/// Service for parsing Excel files uploaded by users.
/// </summary>
public class ExcelParserService : IExcelParserService
{
    /// <inheritdoc />
    public List<long> ParseWorkOrders(Stream fileStream)
    {
        using var workbook = new XLWorkbook(fileStream);
        var worksheet = workbook.Worksheet(1);

        var workOrderNumbers = new List<long>();
        var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;

        for (var row = 2; row <= lastRow; row++)
        {
            var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
            if (long.TryParse(cellValue, out var woNumber))
            {
                workOrderNumbers.Add(woNumber);
            }
        }

        return workOrderNumbers;
    }

    /// <inheritdoc />
    public List<string> ParseItems(Stream fileStream)
    {
        using var workbook = new XLWorkbook(fileStream);
        var worksheet = workbook.Worksheet(1);

        var itemNumbers = new List<string>();
        var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;

        for (var row = 2; row <= lastRow; row++)
        {
            var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
            if (!string.IsNullOrEmpty(cellValue))
            {
                itemNumbers.Add(cellValue);
            }
        }

        return itemNumbers;
    }

    /// <inheritdoc />
    public List<LotViewModel> ParseComponentLots(Stream fileStream)
    {
        using var workbook = new XLWorkbook(fileStream);
        var worksheet = workbook.Worksheet(1);

        var lotViewModels = new List<LotViewModel>();
        var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;

        for (var row = 2; row <= lastRow; row++)
        {
            var lotNumber = worksheet.Cell(row, 1).GetString()?.Trim() ?? string.Empty;
            var itemNumber = worksheet.Cell(row, 2).GetString()?.Trim() ?? string.Empty;

            if (!string.IsNullOrEmpty(lotNumber))
            {
                lotViewModels.Add(new LotViewModel
                {
                    LotNumber = lotNumber,
                    ItemNumber = itemNumber
                });
            }
        }

        return lotViewModels;
    }

    /// <inheritdoc />
    public List<PartOperationViewModel> ParsePartOperations(Stream fileStream)
    {
        using var workbook = new XLWorkbook(fileStream);
        var worksheet = workbook.Worksheet(1);

        var partOperations = new List<PartOperationViewModel>();
        var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;

        for (var row = 2; row <= lastRow; row++)
        {
            try
            {
                var itemNumber = worksheet.Cell(row, 1).GetString()?.Trim() ?? string.Empty;
                var operationNumber = worksheet.Cell(row, 2).GetString()?.Trim() ?? string.Empty;
                var misNumber = worksheet.Cell(row, 3).GetString()?.Trim() ?? string.Empty;
                var misRevision = worksheet.Cell(row, 4).GetString()?.Trim() ?? string.Empty;

                // Remove decimal places from operation number
                if (!string.IsNullOrEmpty(operationNumber) && operationNumber.Contains('.'))
                {
                    operationNumber = operationNumber[..operationNumber.IndexOf('.')];
                }

                if (!string.IsNullOrEmpty(itemNumber) && !string.IsNullOrEmpty(operationNumber))
                {
                    partOperations.Add(new PartOperationViewModel
                    {
                        ItemNumber = itemNumber,
                        OperationNumber = operationNumber,
                        MisNumber = misNumber,
                        MisRevision = misRevision
                    });
                }
            }
            catch
            {
                // Skip invalid rows
            }
        }

        return partOperations;
    }
}

Task 4.2: Register ExcelParserService

Files:

  • Modify: src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs

Step 1: Add registration

In src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs, add the using statement:

using JdeScoping.ExcelIO.Parsing;

Add the registration:

// Register parser service (singleton - stateless)
services.AddSingleton<IExcelParserService, ExcelParserService>();

Phase 5: Update FileIOController

Task 5.1: Update FileController.cs (main partial class)

Files:

  • Modify: src/JdeScoping.Api/Controllers/FileController.cs

Step 1: Add service dependencies

Replace entire file with:

using JdeScoping.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace JdeScoping.Api.Controllers;

/// <summary>
/// Handles file upload/download operations for Excel templates.
/// </summary>
[Authorize]
[ApiController]
[Route("api/fileio")]
public partial class FileIOController : ApiControllerBase
{
    private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

    private readonly ILotFinderRepository _repository;
    private readonly IExcelParserService _parserService;
    private readonly IExcelTemplateService _templateService;
    private readonly ILogger<FileIOController> _logger;

    public FileIOController(
        ILotFinderRepository repository,
        IExcelParserService parserService,
        IExcelTemplateService templateService,
        ILogger<FileIOController> logger)
    {
        _repository = repository;
        _parserService = parserService;
        _templateService = templateService;
        _logger = logger;
    }
}

Task 5.2: Update FileIOController.WorkOrders.cs

Files:

  • Modify: src/JdeScoping.Api/Controllers/FileIOController.WorkOrders.cs

Step 1: Replace entire file

using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace JdeScoping.Api.Controllers;

/// <summary>
/// Work order file operations.
/// </summary>
public partial class FileIOController
{
    /// <summary>
    /// Uploads an Excel file containing work order numbers and returns the matched work orders
    /// </summary>
    [HttpPost("workorders/upload")]
    [ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status200OK)]
    public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
        IFormFile? file,
        CancellationToken ct)
    {
        if (file is null)
        {
            return Ok(new FileUploadResult<WorkOrderViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "No file uploaded"
            });
        }

        try
        {
            using var stream = file.OpenReadStream();
            var workOrderNumbers = _parserService.ParseWorkOrders(stream);

            var workOrders = await _repository.LookupWorkordersAsync(workOrderNumbers, ct);
            var viewModels = workOrders
                .Select(wo => wo.ToViewModel())
                .DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
                .OrderBy(wo => wo.WorkOrderNumber)
                .ToArray();

            return Ok(new FileUploadResult<WorkOrderViewModel>
            {
                WasSuccessful = true,
                Data = viewModels
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to parse uploaded work order file");
            return Ok(new FileUploadResult<WorkOrderViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "Failed to parse uploaded file"
            });
        }
    }

    /// <summary>
    /// Downloads an Excel template with current work order data
    /// </summary>
    [HttpPost("workorders/download")]
    [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
    public IActionResult DownloadWorkOrders([FromBody] List<long>? workOrders)
    {
        var data = _templateService.GenerateSingleColumn(workOrders ?? [], "Work Order Number");
        return File(data, ContentType, "work_order_template.xlsx");
    }
}

Task 5.3: Update FileIOController.Items.cs

Files:

  • Modify: src/JdeScoping.Api/Controllers/FileIOController.Items.cs

Step 1: Replace entire file

using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace JdeScoping.Api.Controllers;

/// <summary>
/// Item file operations.
/// </summary>
public partial class FileIOController
{
    /// <summary>
    /// Uploads an Excel file containing item numbers and returns the matched items
    /// </summary>
    [HttpPost("items/upload")]
    [ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status200OK)]
    public async Task<ActionResult<FileUploadResult<ItemViewModel>>> UploadItems(
        IFormFile? file,
        CancellationToken ct)
    {
        if (file is null)
        {
            return Ok(new FileUploadResult<ItemViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "No file uploaded"
            });
        }

        try
        {
            using var stream = file.OpenReadStream();
            var itemNumbers = _parserService.ParseItems(stream);

            var items = await _repository.LookupItemsAsync(itemNumbers, ct);
            var viewModels = items
                .Select(i => i.ToViewModel())
                .DistinctBy(i => new { i.ItemNumber, i.Description })
                .ToArray();

            return Ok(new FileUploadResult<ItemViewModel>
            {
                WasSuccessful = true,
                Data = viewModels
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to parse uploaded items file");
            return Ok(new FileUploadResult<ItemViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "Failed to parse uploaded file"
            });
        }
    }

    /// <summary>
    /// Downloads an Excel template with current item data
    /// </summary>
    [HttpPost("items/download")]
    [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
    public IActionResult DownloadItems([FromBody] List<ItemViewModel>? items)
    {
        var sourceData = (items ?? [])
            .Select(i => new object?[] { i.ItemNumber })
            .ToArray();
        var headers = new[] { "Item Number" };
        var data = _templateService.GenerateMultiColumn(sourceData, headers);
        return File(data, ContentType, "item_number_template.xlsx");
    }
}

Task 5.4: Update FileIOController.ComponentLots.cs

Files:

  • Modify: src/JdeScoping.Api/Controllers/FileIOController.ComponentLots.cs

Step 1: Replace entire file

using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace JdeScoping.Api.Controllers;

/// <summary>
/// Component lot file operations.
/// </summary>
public partial class FileIOController
{
    /// <summary>
    /// Uploads an Excel file containing component lot/item pairs and returns the matched lots
    /// </summary>
    [HttpPost("componentlots/upload")]
    [ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status200OK)]
    public async Task<ActionResult<FileUploadResult<LotViewModel>>> UploadComponentLots(
        IFormFile? file,
        CancellationToken ct)
    {
        if (file is null)
        {
            return Ok(new FileUploadResult<LotViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "No file uploaded"
            });
        }

        try
        {
            using var stream = file.OpenReadStream();
            var lotViewModels = _parserService.ParseComponentLots(stream);

            var lots = await _repository.LookupLotsAsync(lotViewModels, ct);
            var viewModels = lots
                .Select(l => l.ToViewModel())
                .DistinctBy(l => new { l.LotNumber, l.ItemNumber })
                .OrderBy(l => l.LotNumber)
                .ToArray();

            return Ok(new FileUploadResult<LotViewModel>
            {
                WasSuccessful = true,
                Data = viewModels
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to parse uploaded component lots file");
            return Ok(new FileUploadResult<LotViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "Failed to parse uploaded file"
            });
        }
    }

    /// <summary>
    /// Downloads an Excel template with current component lot data
    /// </summary>
    [HttpPost("componentlots/download")]
    [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
    public IActionResult DownloadComponentLots([FromBody] List<LotViewModel>? lotNumbers)
    {
        var sourceData = (lotNumbers ?? [])
            .Select(l => new object?[] { l.LotNumber, l.ItemNumber })
            .ToArray();
        var headers = new[] { "Component Lot Number", "Component Item Number" };
        var data = _templateService.GenerateMultiColumn(sourceData, headers);
        return File(data, ContentType, "component_lot_template.xlsx");
    }
}

Task 5.5: Update FileIOController.PartOperations.cs

Files:

  • Modify: src/JdeScoping.Api/Controllers/FileIOController.PartOperations.cs

Step 1: Replace entire file

using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace JdeScoping.Api.Controllers;

/// <summary>
/// Part operations file operations.
/// </summary>
public partial class FileIOController
{
    /// <summary>
    /// Uploads an Excel file containing part operations and returns the parsed data
    /// </summary>
    [HttpPost("partoperations/upload")]
    [ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status200OK)]
    public ActionResult<FileUploadResult<PartOperationViewModel>> UploadPartOperations(IFormFile? file)
    {
        if (file is null)
        {
            return Ok(new FileUploadResult<PartOperationViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "No file uploaded"
            });
        }

        try
        {
            using var stream = file.OpenReadStream();
            var partOperations = _parserService.ParsePartOperations(stream);

            return Ok(new FileUploadResult<PartOperationViewModel>
            {
                WasSuccessful = true,
                Data = partOperations.ToArray()
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to parse uploaded part operations file");
            return Ok(new FileUploadResult<PartOperationViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "Failed to parse uploaded file"
            });
        }
    }

    /// <summary>
    /// Downloads an Excel template with current part operation data
    /// </summary>
    [HttpPost("partoperations/download")]
    [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
    public IActionResult DownloadPartOperations([FromBody] List<PartOperationViewModel>? partOperations)
    {
        var sourceData = (partOperations ?? [])
            .Select(po => new object?[] { po.ItemNumber, po.OperationNumber, po.MisNumber, po.MisRevision })
            .ToArray();
        var headers = new[] { "Item Number", "Operation Number", "MIS Number", "MIS Revision" };
        var data = _templateService.GenerateMultiColumn(sourceData, headers);
        return File(data, ContentType, "item_operations_mis_template.xlsx");
    }
}

Task 5.6: Delete old helper and update Api project

Files:

  • Delete: src/JdeScoping.Api/Helpers/ExcelTemplateGenerator.cs
  • Modify: src/JdeScoping.Api/JdeScoping.Api.csproj

Step 1: Delete old helper

Run:

rm /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.Api/Helpers/ExcelTemplateGenerator.cs

Step 2: Update Api project to remove ClosedXML and add ExcelIO reference

In src/JdeScoping.Api/JdeScoping.Api.csproj:

Remove this line:

<PackageReference Include="ClosedXML" Version="0.105.0" />

Add project reference (if not already present):

<ProjectReference Include="..\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />

Phase 6: Update Tests

Task 6.1: Update FileControllerTests to use mocked services

Files:

  • Modify: tests/JdeScoping.Api.Tests/Controllers/FileControllerTests.cs

Step 1: Replace entire file

using JdeScoping.Api.Controllers;
using JdeScoping.Api.Models;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;

namespace JdeScoping.Api.Tests.Controllers;

public class FileControllerTests
{
    private readonly ILotFinderRepository _repository;
    private readonly IExcelParserService _parserService;
    private readonly IExcelTemplateService _templateService;
    private readonly ILogger<FileIOController> _logger;
    private readonly FileIOController _controller;

    public FileControllerTests()
    {
        _repository = Substitute.For<ILotFinderRepository>();
        _parserService = Substitute.For<IExcelParserService>();
        _templateService = Substitute.For<IExcelTemplateService>();
        _logger = Substitute.For<ILogger<FileIOController>>();
        _controller = new FileIOController(_repository, _parserService, _templateService, _logger);
    }

    [Fact]
    public async Task UploadWorkOrders_CallsParserAndRepository()
    {
        // Arrange
        var formFile = CreateFormFile(new byte[] { 1, 2, 3 }, "workorders.xlsx");
        var parsedNumbers = new List<long> { 12345, 67890 };
        _parserService.ParseWorkOrders(Arg.Any<Stream>()).Returns(parsedNumbers);

        var workOrders = new List<WorkOrder>
        {
            new() { WorkOrderNumber = 12345, ItemNumber = "ITEM-001" },
            new() { WorkOrderNumber = 67890, ItemNumber = "ITEM-002" }
        };
        _repository.LookupWorkordersAsync(parsedNumbers, Arg.Any<CancellationToken>())
            .Returns(workOrders);

        // Act
        var result = await _controller.UploadWorkOrders(formFile, CancellationToken.None);

        // Assert
        result.Result.ShouldBeOfType<OkObjectResult>();
        var okResult = (OkObjectResult)result.Result!;
        var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<WorkOrderViewModel>>();
        uploadResult.WasSuccessful.ShouldBeTrue();
        uploadResult.Data.ShouldNotBeNull();
        uploadResult.Data.Length.ShouldBe(2);
    }

    [Fact]
    public void DownloadWorkOrders_CallsTemplateService()
    {
        // Arrange
        var workOrders = new List<long> { 12345, 67890 };
        var expectedBytes = new byte[] { 1, 2, 3, 4, 5 };
        _templateService.GenerateSingleColumn(workOrders, "Work Order Number").Returns(expectedBytes);

        // Act
        var result = _controller.DownloadWorkOrders(workOrders);

        // Assert
        result.ShouldBeOfType<FileContentResult>();
        var fileResult = (FileContentResult)result;
        fileResult.ContentType.ShouldBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        fileResult.FileDownloadName.ShouldBe("work_order_template.xlsx");
        fileResult.FileContents.ShouldBe(expectedBytes);
    }

    [Fact]
    public async Task UploadWorkOrders_NoFile_ReturnsError()
    {
        // Act
        var result = await _controller.UploadWorkOrders(null, CancellationToken.None);

        // Assert
        result.Result.ShouldBeOfType<OkObjectResult>();
        var okResult = (OkObjectResult)result.Result!;
        var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<WorkOrderViewModel>>();
        uploadResult.WasSuccessful.ShouldBeFalse();
        uploadResult.ErrorMessage.ShouldBe("No file uploaded");
    }

    [Fact]
    public void UploadPartOperations_CallsParser()
    {
        // Arrange
        var formFile = CreateFormFile(new byte[] { 1, 2, 3 }, "partops.xlsx");
        var parsedOps = new List<PartOperationViewModel>
        {
            new() { ItemNumber = "ITEM-001", OperationNumber = "100", MisNumber = "MIS001", MisRevision = "A" }
        };
        _parserService.ParsePartOperations(Arg.Any<Stream>()).Returns(parsedOps);

        // Act
        var result = _controller.UploadPartOperations(formFile);

        // Assert
        result.Result.ShouldBeOfType<OkObjectResult>();
        var okResult = (OkObjectResult)result.Result!;
        var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<PartOperationViewModel>>();
        uploadResult.WasSuccessful.ShouldBeTrue();
        uploadResult.Data.ShouldNotBeNull();
        uploadResult.Data.Length.ShouldBe(1);
    }

    private static IFormFile CreateFormFile(byte[] content, string fileName)
    {
        var stream = new MemoryStream(content);
        var formFile = Substitute.For<IFormFile>();
        formFile.OpenReadStream().Returns(stream);
        formFile.FileName.Returns(fileName);
        formFile.Length.Returns(content.Length);
        return formFile;
    }
}

Task 6.2: Create ExcelParserServiceTests

Files:

  • Create: tests/JdeScoping.ExcelIO.Tests/Parsing/ExcelParserServiceTests.cs

Step 1: Create Parsing directory

Run:

mkdir -p /Users/dohertj2/Desktop/JdeScopingTool/NEW/tests/JdeScoping.ExcelIO.Tests/Parsing

Step 2: Create test file

Create tests/JdeScoping.ExcelIO.Tests/Parsing/ExcelParserServiceTests.cs:

using ClosedXML.Excel;
using JdeScoping.ExcelIO.Parsing;
using Shouldly;

namespace JdeScoping.ExcelIO.Tests.Parsing;

public class ExcelParserServiceTests
{
    private readonly ExcelParserService _service = new();

    [Fact]
    public void ParseWorkOrders_ReturnsWorkOrderNumbers()
    {
        // Arrange
        var excelData = CreateWorkOrderExcel([12345, 67890, 11111]);

        // Act
        using var stream = new MemoryStream(excelData);
        var result = _service.ParseWorkOrders(stream);

        // Assert
        result.Count.ShouldBe(3);
        result.ShouldContain(12345);
        result.ShouldContain(67890);
        result.ShouldContain(11111);
    }

    [Fact]
    public void ParseWorkOrders_SkipsInvalidNumbers()
    {
        // Arrange
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Sheet1");
        worksheet.Cell(1, 1).Value = "Work Order";
        worksheet.Cell(2, 1).Value = "12345";
        worksheet.Cell(3, 1).Value = "not-a-number";
        worksheet.Cell(4, 1).Value = "67890";

        using var ms = new MemoryStream();
        workbook.SaveAs(ms);
        ms.Position = 0;

        // Act
        var result = _service.ParseWorkOrders(ms);

        // Assert
        result.Count.ShouldBe(2);
    }

    [Fact]
    public void ParseItems_ReturnsItemNumbers()
    {
        // Arrange
        var excelData = CreateItemExcel(["ITEM-001", "ITEM-002"]);

        // Act
        using var stream = new MemoryStream(excelData);
        var result = _service.ParseItems(stream);

        // Assert
        result.Count.ShouldBe(2);
        result.ShouldContain("ITEM-001");
        result.ShouldContain("ITEM-002");
    }

    [Fact]
    public void ParseComponentLots_ReturnsLotViewModels()
    {
        // Arrange
        var excelData = CreateComponentLotExcel([("LOT001", "ITEM-001"), ("LOT002", "ITEM-002")]);

        // Act
        using var stream = new MemoryStream(excelData);
        var result = _service.ParseComponentLots(stream);

        // Assert
        result.Count.ShouldBe(2);
        result[0].LotNumber.ShouldBe("LOT001");
        result[0].ItemNumber.ShouldBe("ITEM-001");
    }

    [Fact]
    public void ParsePartOperations_ReturnsPartOperations()
    {
        // Arrange
        var excelData = CreatePartOperationExcel([("ITEM-001", "100", "MIS001", "A")]);

        // Act
        using var stream = new MemoryStream(excelData);
        var result = _service.ParsePartOperations(stream);

        // Assert
        result.Count.ShouldBe(1);
        result[0].ItemNumber.ShouldBe("ITEM-001");
        result[0].OperationNumber.ShouldBe("100");
        result[0].MisNumber.ShouldBe("MIS001");
        result[0].MisRevision.ShouldBe("A");
    }

    [Fact]
    public void ParsePartOperations_TruncatesDecimalOperationNumbers()
    {
        // Arrange
        var excelData = CreatePartOperationExcel([("ITEM-001", "100.5", "MIS001", "A")]);

        // Act
        using var stream = new MemoryStream(excelData);
        var result = _service.ParsePartOperations(stream);

        // Assert
        result[0].OperationNumber.ShouldBe("100");
    }

    private static byte[] CreateWorkOrderExcel(long[] workOrderNumbers)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Sheet1");
        worksheet.Cell(1, 1).Value = "Work Order Number";
        for (var i = 0; i < workOrderNumbers.Length; i++)
        {
            worksheet.Cell(i + 2, 1).Value = workOrderNumbers[i];
        }
        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }

    private static byte[] CreateItemExcel(string[] itemNumbers)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Sheet1");
        worksheet.Cell(1, 1).Value = "Item Number";
        for (var i = 0; i < itemNumbers.Length; i++)
        {
            worksheet.Cell(i + 2, 1).Value = itemNumbers[i];
        }
        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }

    private static byte[] CreateComponentLotExcel((string LotNumber, string ItemNumber)[] lots)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Sheet1");
        worksheet.Cell(1, 1).Value = "Lot Number";
        worksheet.Cell(1, 2).Value = "Item Number";
        for (var i = 0; i < lots.Length; i++)
        {
            worksheet.Cell(i + 2, 1).Value = lots[i].LotNumber;
            worksheet.Cell(i + 2, 2).Value = lots[i].ItemNumber;
        }
        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }

    private static byte[] CreatePartOperationExcel((string ItemNumber, string OpNumber, string MisNumber, string MisRevision)[] operations)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.Worksheets.Add("Sheet1");
        worksheet.Cell(1, 1).Value = "Item Number";
        worksheet.Cell(1, 2).Value = "Operation Number";
        worksheet.Cell(1, 3).Value = "MIS Number";
        worksheet.Cell(1, 4).Value = "MIS Revision";
        for (var i = 0; i < operations.Length; i++)
        {
            worksheet.Cell(i + 2, 1).Value = operations[i].ItemNumber;
            worksheet.Cell(i + 2, 2).Value = operations[i].OpNumber;
            worksheet.Cell(i + 2, 3).Value = operations[i].MisNumber;
            worksheet.Cell(i + 2, 4).Value = operations[i].MisRevision;
        }
        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }
}

Task 6.3: Create ExcelTemplateServiceTests

Files:

  • Create: tests/JdeScoping.ExcelIO.Tests/Templates/ExcelTemplateServiceTests.cs

Step 1: Create Templates directory

Run:

mkdir -p /Users/dohertj2/Desktop/JdeScopingTool/NEW/tests/JdeScoping.ExcelIO.Tests/Templates

Step 2: Create test file

Create tests/JdeScoping.ExcelIO.Tests/Templates/ExcelTemplateServiceTests.cs:

using ClosedXML.Excel;
using JdeScoping.ExcelIO.Templates;
using Shouldly;

namespace JdeScoping.ExcelIO.Tests.Templates;

public class ExcelTemplateServiceTests
{
    private readonly ExcelTemplateService _service = new();

    [Fact]
    public void GenerateSingleColumn_CreatesValidExcel()
    {
        // Arrange
        var data = new[] { 12345L, 67890L };

        // Act
        var result = _service.GenerateSingleColumn(data, "Work Order Number");

        // Assert
        result.ShouldNotBeNull();
        result.Length.ShouldBeGreaterThan(0);

        // Verify content
        using var stream = new MemoryStream(result);
        using var workbook = new XLWorkbook(stream);
        var worksheet = workbook.Worksheet(1);

        worksheet.Cell(1, 1).GetString().ShouldBe("Work Order Number");
        worksheet.Cell(2, 1).GetString().ShouldBe("12345");
        worksheet.Cell(3, 1).GetString().ShouldBe("67890");
    }

    [Fact]
    public void GenerateMultiColumn_CreatesValidExcel()
    {
        // Arrange
        var data = new[]
        {
            new object?[] { "ITEM-001", "Description 1" },
            new object?[] { "ITEM-002", "Description 2" }
        };
        var headers = new[] { "Item Number", "Description" };

        // Act
        var result = _service.GenerateMultiColumn(data, headers);

        // Assert
        result.ShouldNotBeNull();
        result.Length.ShouldBeGreaterThan(0);

        // Verify content
        using var stream = new MemoryStream(result);
        using var workbook = new XLWorkbook(stream);
        var worksheet = workbook.Worksheet(1);

        worksheet.Cell(1, 1).GetString().ShouldBe("Item Number");
        worksheet.Cell(1, 2).GetString().ShouldBe("Description");
        worksheet.Cell(2, 1).GetString().ShouldBe("ITEM-001");
        worksheet.Cell(2, 2).GetString().ShouldBe("Description 1");
    }

    [Fact]
    public void GenerateSingleColumn_HandlesEmptyData()
    {
        // Act
        var result = _service.GenerateSingleColumn(Array.Empty<string>(), "Header");

        // Assert
        result.ShouldNotBeNull();
        result.Length.ShouldBeGreaterThan(0);
    }

    [Fact]
    public void GenerateMultiColumn_HandlesNullValues()
    {
        // Arrange
        var data = new[]
        {
            new object?[] { "ITEM-001", null }
        };
        var headers = new[] { "Item", "Value" };

        // Act
        var result = _service.GenerateMultiColumn(data, headers);

        // Assert
        result.ShouldNotBeNull();
        using var stream = new MemoryStream(result);
        using var workbook = new XLWorkbook(stream);
        var worksheet = workbook.Worksheet(1);

        worksheet.Cell(2, 2).GetString().ShouldBe(string.Empty);
    }
}

Phase 7: Final Updates

Task 7.1: Update ExcelExportService to use Core interface

Files:

  • Modify: src/JdeScoping.ExcelIO/ExcelExportService.cs

Step 1: Update using statements

In src/JdeScoping.ExcelIO/ExcelExportService.cs, change:

using JdeScoping.ExcelIO.Interfaces;

To:

using JdeScoping.Core.Interfaces;

Task 7.2: Delete empty Interfaces folder

Step 1: Remove empty folder

Run:

rmdir /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.ExcelIO/Interfaces 2>/dev/null || rm -rf /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.ExcelIO/Interfaces

Task 7.3: Update ServiceCollectionExtensions completely

Files:

  • Modify: src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs

Step 1: Replace entire file

using JdeScoping.Core.Interfaces;
using JdeScoping.ExcelIO.Configuration;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Parsing;
using JdeScoping.ExcelIO.Templates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace JdeScoping.ExcelIO;

/// <summary>
/// Extension methods for registering Excel I/O services.
/// </summary>
public static class ServiceCollectionExtensions
{
    /// <summary>
    /// Adds Excel I/O services to the service collection.
    /// </summary>
    /// <param name="services">The service collection.</param>
    /// <param name="configuration">The configuration.</param>
    /// <returns>The service collection for chaining.</returns>
    public static IServiceCollection AddExcelIO(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Bind options
        services.Configure<ExcelExportOptions>(
            configuration.GetSection(ExcelExportOptions.SectionName));

        // Register export service (scoped - per request)
        services.AddScoped<IExcelExportService, ExcelExportService>();

        // Register template service (singleton - stateless)
        services.AddSingleton<IExcelTemplateService, ExcelTemplateService>();

        // Register parser service (singleton - stateless)
        services.AddSingleton<IExcelParserService, ExcelParserService>();

        // Register generators (scoped - they use options)
        services.AddScoped<CriteriaSheetGenerator>();

        // Register helpers (singleton - stateless)
        services.AddSingleton<OutputColumnCache>();
        services.AddSingleton<AttributeTableWriter>();
        services.AddSingleton<DataEntryTemplateGenerator>();

        return services;
    }
}

Phase 8: Verification

Task 8.1: Build the solution

Step 1: Clean and build

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && dotnet clean && dotnet build

Expected: Build succeeded with 0 errors.


Task 8.2: Run all tests

Step 1: Execute test suite

Run:

cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && dotnet test --verbosity quiet

Expected: All tests pass.


Task 8.3: Verify project structure

Step 1: List source projects

Run:

ls -1 /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/

Expected output should include JdeScoping.ExcelIO (not ExcelExport).

Step 2: Verify no ClosedXML in Api project

Run:

grep -l "ClosedXML" /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.Api/*.csproj /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.Api/**/*.cs 2>/dev/null || echo "No ClosedXML references found in Api - correct!"

Expected: "No ClosedXML references found in Api - correct!"

Step 3: Verify interfaces in Core

Run:

ls /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.Core/Interfaces/IExcel*.cs

Expected: Should list IExcelExportService.cs, IExcelTemplateService.cs, IExcelParserService.cs


Summary

Total Tasks: 22 tasks across 8 phases

  • Phase 1: Rename projects (5 tasks)
  • Phase 2: Add interfaces to Core (3 tasks)
  • Phase 3: Create ExcelTemplateService (2 tasks)
  • Phase 4: Create ExcelParserService (2 tasks)
  • Phase 5: Update FileIOController (6 tasks)
  • Phase 6: Update tests (3 tasks)
  • Phase 7: Final updates (3 tasks)
  • Phase 8: Verification (3 tasks)