Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
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)