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

1615 lines
48 KiB
Markdown

# 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
<ProjectReference Include="..\..\src\JdeScoping.ExcelExport\JdeScoping.ExcelExport.csproj" />
```
To:
```xml
<ProjectReference Include="..\..\src\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />
```
**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
<Project Path="src/JdeScoping.ExcelExport/JdeScoping.ExcelExport.csproj" />
```
To:
```xml
<Project Path="src/JdeScoping.ExcelIO/JdeScoping.ExcelIO.csproj" />
```
And change:
```xml
<Project Path="tests/JdeScoping.ExcelExport.Tests/JdeScoping.ExcelExport.Tests.csproj" />
```
To:
```xml
<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:
```xml
<ProjectReference Include="..\JdeScoping.ExcelExport\JdeScoping.ExcelExport.csproj" />
```
To:
```xml
<ProjectReference Include="..\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />
```
**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;
/// <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:
```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;
/// <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`:
```csharp
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:
```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;
/// <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:
```csharp
using JdeScoping.ExcelIO.Templates;
```
Add the registration after the existing services:
```csharp
// 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:
```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;
/// <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:
```csharp
using JdeScoping.ExcelIO.Parsing;
```
Add the registration:
```csharp
// 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:
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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:
```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
<PackageReference Include="ClosedXML" Version="0.105.0" />
```
Add project reference (if not already present):
```xml
<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**
```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<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:
```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<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:
```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;
/// <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:
```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)