# 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: ```bash cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && mv src/JdeScoping.ExcelExport src/JdeScoping.ExcelIO ``` **Step 2: Rename the .csproj file** Run: ```bash mv src/JdeScoping.ExcelIO/JdeScoping.ExcelExport.csproj src/JdeScoping.ExcelIO/JdeScoping.ExcelIO.csproj ``` **Step 3: Update namespace in all source files** Run: ```bash 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: ```bash cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && mv tests/JdeScoping.ExcelExport.Tests tests/JdeScoping.ExcelIO.Tests ``` **Step 2: Rename the .csproj file** Run: ```bash 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: ```xml ``` To: ```xml ``` **Step 4: Update namespace in all test files** Run: ```bash 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: ```xml ``` To: ```xml ``` And change: ```xml ``` To: ```xml ``` --- ### Task 1.4: Update project references **Step 1: Update Host project reference** In `src/JdeScoping.Host/JdeScoping.Host.csproj`, change: ```xml ``` To: ```xml ``` **Step 2: Update Host Program.cs** In `src/JdeScoping.Host/Program.cs`, change: ```csharp using JdeScoping.ExcelExport; ``` To: ```csharp using JdeScoping.ExcelIO; ``` And change: ```csharp .AddExcelExport(builder.Configuration) ``` To: ```csharp .AddExcelIO(builder.Configuration) ``` **Step 3: Update ServiceCollectionExtensions method name** In `src/JdeScoping.ExcelIO/ServiceCollectionExtensions.cs`, change: ```csharp public static IServiceCollection AddExcelExport( ``` To: ```csharp public static IServiceCollection AddExcelIO( ``` --- ### Task 1.5: Clean and verify build **Step 1: Clean old build artifacts** Run: ```bash 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: ```bash cd /Users/dohertj2/Desktop/JdeScopingTool/NEW && dotnet restore && dotnet build ``` Expected: Build succeeded with 0 errors. **Step 3: Run ExcelIO tests** Run: ```bash 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`: ```csharp namespace JdeScoping.Core.Interfaces; /// /// Service for generating Excel export files from search data. /// public interface IExcelExportService { /// /// Generates an Excel file from the provided search model. /// /// Search model with criteria and results. /// Cancellation token. /// Excel file as byte array. Task GenerateAsync(object search, CancellationToken cancellationToken = default); } ``` **Step 2: Delete old interface** Run: ```bash 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: ```csharp using JdeScoping.Core.Interfaces; ``` Remove: ```csharp 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`: ```csharp namespace JdeScoping.Core.Interfaces; /// /// Service for generating Excel template files for data entry. /// public interface IExcelTemplateService { /// /// Generates an Excel file with a single column of data. /// /// Type of data items. /// Data items to write. /// Header text for the column. /// Excel file as byte array. byte[] GenerateSingleColumn(IEnumerable data, string headerText); /// /// Generates an Excel file with multiple columns of data. /// /// 2D array of data (rows x columns). /// Header text for each column. /// Excel file as byte array. 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`: ```csharp using JdeScoping.Core.ViewModels; namespace JdeScoping.Core.Interfaces; /// /// Service for parsing Excel files uploaded by users. /// public interface IExcelParserService { /// /// Parses work order numbers from an Excel file. /// /// Excel file stream. /// List of work order numbers. List ParseWorkOrders(Stream fileStream); /// /// Parses item numbers from an Excel file. /// /// Excel file stream. /// List of item numbers. List ParseItems(Stream fileStream); /// /// Parses component lot/item pairs from an Excel file. /// /// Excel file stream. /// List of lot view models with LotNumber and ItemNumber. List ParseComponentLots(Stream fileStream); /// /// Parses part operations from an Excel file. /// /// Excel file stream. /// List of part operation view models. List 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: ```bash mkdir -p /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.ExcelIO/Templates ``` **Step 2: Create ExcelTemplateService** Create `src/JdeScoping.ExcelIO/Templates/ExcelTemplateService.cs`: ```csharp using ClosedXML.Excel; using JdeScoping.Core.Interfaces; namespace JdeScoping.ExcelIO.Templates; /// /// Service for generating Excel template files for data entry. /// public class ExcelTemplateService : IExcelTemplateService { /// public byte[] GenerateSingleColumn(IEnumerable 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(); } /// 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: ```csharp using JdeScoping.ExcelIO.Templates; ``` Add the registration after the existing services: ```csharp // Register template service (singleton - stateless) services.AddSingleton(); ``` --- ## Phase 4: Create ExcelParserService ### Task 4.1: Create ExcelParserService **Files:** - Create: `src/JdeScoping.ExcelIO/Parsing/ExcelParserService.cs` **Step 1: Create Parsing directory** Run: ```bash mkdir -p /Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.ExcelIO/Parsing ``` **Step 2: Create ExcelParserService** Create `src/JdeScoping.ExcelIO/Parsing/ExcelParserService.cs`: ```csharp using ClosedXML.Excel; using JdeScoping.Core.Interfaces; using JdeScoping.Core.ViewModels; namespace JdeScoping.ExcelIO.Parsing; /// /// Service for parsing Excel files uploaded by users. /// public class ExcelParserService : IExcelParserService { /// public List ParseWorkOrders(Stream fileStream) { using var workbook = new XLWorkbook(fileStream); var worksheet = workbook.Worksheet(1); var workOrderNumbers = new List(); 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; } /// public List ParseItems(Stream fileStream) { using var workbook = new XLWorkbook(fileStream); var worksheet = workbook.Worksheet(1); var itemNumbers = new List(); 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; } /// public List ParseComponentLots(Stream fileStream) { using var workbook = new XLWorkbook(fileStream); var worksheet = workbook.Worksheet(1); var lotViewModels = new List(); 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; } /// public List ParsePartOperations(Stream fileStream) { using var workbook = new XLWorkbook(fileStream); var worksheet = workbook.Worksheet(1); var partOperations = new List(); 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: ```csharp using JdeScoping.ExcelIO.Parsing; ``` Add the registration: ```csharp // Register parser service (singleton - stateless) services.AddSingleton(); ``` --- ## 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: ```csharp using JdeScoping.Core.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JdeScoping.Api.Controllers; /// /// Handles file upload/download operations for Excel templates. /// [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 _logger; public FileIOController( ILotFinderRepository repository, IExcelParserService parserService, IExcelTemplateService templateService, ILogger 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** ```csharp using JdeScoping.Api.Models; using JdeScoping.Core.ViewModels; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JdeScoping.Api.Controllers; /// /// Work order file operations. /// public partial class FileIOController { /// /// Uploads an Excel file containing work order numbers and returns the matched work orders /// [HttpPost("workorders/upload")] [ProducesResponseType(typeof(FileUploadResult), StatusCodes.Status200OK)] public async Task>> UploadWorkOrders( IFormFile? file, CancellationToken ct) { if (file is null) { return Ok(new FileUploadResult { 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 { WasSuccessful = true, Data = viewModels }); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse uploaded work order file"); return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "Failed to parse uploaded file" }); } } /// /// Downloads an Excel template with current work order data /// [HttpPost("workorders/download")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] public IActionResult DownloadWorkOrders([FromBody] List? 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** ```csharp using JdeScoping.Api.Models; using JdeScoping.Core.ViewModels; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JdeScoping.Api.Controllers; /// /// Item file operations. /// public partial class FileIOController { /// /// Uploads an Excel file containing item numbers and returns the matched items /// [HttpPost("items/upload")] [ProducesResponseType(typeof(FileUploadResult), StatusCodes.Status200OK)] public async Task>> UploadItems( IFormFile? file, CancellationToken ct) { if (file is null) { return Ok(new FileUploadResult { 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 { WasSuccessful = true, Data = viewModels }); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse uploaded items file"); return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "Failed to parse uploaded file" }); } } /// /// Downloads an Excel template with current item data /// [HttpPost("items/download")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] public IActionResult DownloadItems([FromBody] List? 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** ```csharp using JdeScoping.Api.Models; using JdeScoping.Core.ViewModels; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JdeScoping.Api.Controllers; /// /// Component lot file operations. /// public partial class FileIOController { /// /// Uploads an Excel file containing component lot/item pairs and returns the matched lots /// [HttpPost("componentlots/upload")] [ProducesResponseType(typeof(FileUploadResult), StatusCodes.Status200OK)] public async Task>> UploadComponentLots( IFormFile? file, CancellationToken ct) { if (file is null) { return Ok(new FileUploadResult { 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 { WasSuccessful = true, Data = viewModels }); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse uploaded component lots file"); return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "Failed to parse uploaded file" }); } } /// /// Downloads an Excel template with current component lot data /// [HttpPost("componentlots/download")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] public IActionResult DownloadComponentLots([FromBody] List? 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** ```csharp using JdeScoping.Api.Models; using JdeScoping.Core.ViewModels; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JdeScoping.Api.Controllers; /// /// Part operations file operations. /// public partial class FileIOController { /// /// Uploads an Excel file containing part operations and returns the parsed data /// [HttpPost("partoperations/upload")] [ProducesResponseType(typeof(FileUploadResult), StatusCodes.Status200OK)] public ActionResult> UploadPartOperations(IFormFile? file) { if (file is null) { return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "No file uploaded" }); } try { using var stream = file.OpenReadStream(); var partOperations = _parserService.ParsePartOperations(stream); return Ok(new FileUploadResult { WasSuccessful = true, Data = partOperations.ToArray() }); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse uploaded part operations file"); return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "Failed to parse uploaded file" }); } } /// /// Downloads an Excel template with current part operation data /// [HttpPost("partoperations/download")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] public IActionResult DownloadPartOperations([FromBody] List? 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: ```bash 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: ```xml ``` Add project reference (if not already present): ```xml ``` --- ## 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** ```csharp 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 _logger; private readonly FileIOController _controller; public FileControllerTests() { _repository = Substitute.For(); _parserService = Substitute.For(); _templateService = Substitute.For(); _logger = Substitute.For>(); _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 { 12345, 67890 }; _parserService.ParseWorkOrders(Arg.Any()).Returns(parsedNumbers); var workOrders = new List { new() { WorkOrderNumber = 12345, ItemNumber = "ITEM-001" }, new() { WorkOrderNumber = 67890, ItemNumber = "ITEM-002" } }; _repository.LookupWorkordersAsync(parsedNumbers, Arg.Any()) .Returns(workOrders); // Act var result = await _controller.UploadWorkOrders(formFile, CancellationToken.None); // Assert result.Result.ShouldBeOfType(); var okResult = (OkObjectResult)result.Result!; var uploadResult = okResult.Value.ShouldBeOfType>(); uploadResult.WasSuccessful.ShouldBeTrue(); uploadResult.Data.ShouldNotBeNull(); uploadResult.Data.Length.ShouldBe(2); } [Fact] public void DownloadWorkOrders_CallsTemplateService() { // Arrange var workOrders = new List { 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(); 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(); var okResult = (OkObjectResult)result.Result!; var uploadResult = okResult.Value.ShouldBeOfType>(); 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 { new() { ItemNumber = "ITEM-001", OperationNumber = "100", MisNumber = "MIS001", MisRevision = "A" } }; _parserService.ParsePartOperations(Arg.Any()).Returns(parsedOps); // Act var result = _controller.UploadPartOperations(formFile); // Assert result.Result.ShouldBeOfType(); var okResult = (OkObjectResult)result.Result!; var uploadResult = okResult.Value.ShouldBeOfType>(); 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(); 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: ```bash 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`: ```csharp 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: ```bash 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`: ```csharp 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(), "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: ```csharp using JdeScoping.ExcelIO.Interfaces; ``` To: ```csharp using JdeScoping.Core.Interfaces; ``` --- ### Task 7.2: Delete empty Interfaces folder **Step 1: Remove empty folder** Run: ```bash 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** ```csharp 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; /// /// Extension methods for registering Excel I/O services. /// public static class ServiceCollectionExtensions { /// /// Adds Excel I/O services to the service collection. /// /// The service collection. /// The configuration. /// The service collection for chaining. public static IServiceCollection AddExcelIO( this IServiceCollection services, IConfiguration configuration) { // Bind options services.Configure( configuration.GetSection(ExcelExportOptions.SectionName)); // Register export service (scoped - per request) services.AddScoped(); // Register template service (singleton - stateless) services.AddSingleton(); // Register parser service (singleton - stateless) services.AddSingleton(); // Register generators (scoped - they use options) services.AddScoped(); // Register helpers (singleton - stateless) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; } } ``` --- ## Phase 8: Verification ### Task 8.1: Build the solution **Step 1: Clean and build** Run: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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)