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.
This commit is contained in:
@@ -0,0 +1,687 @@
|
||||
# Excel Export Design
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture and implementation approach for the Excel export subsystem, including workbook generation, sheet generators, formatting patterns, and memory management.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Search Processing │
|
||||
│ (SearchProcessor produces SearchModel with populated results) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ IExcelExportService │
|
||||
│ Task<byte[]> GenerateAsync(SearchModel, CancellationToken) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ExcelExportService │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ - ILogger<ExcelExportService> │
|
||||
│ - IOptions<ExcelExportOptions> │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Uses: │
|
||||
│ - CriteriaSheetGenerator │
|
||||
│ - AttributeTableWriter │
|
||||
│ - WorksheetProtector │
|
||||
│ - HeaderFormatter │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ClosedXML │
|
||||
│ XLWorkbook → IXLWorksheet → IXLCell/IXLRange │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
byte[] output
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
NEW/src/JdeScoping.ExcelExport/
|
||||
├── Attributes/
|
||||
│ ├── OutputColumnAttribute.cs
|
||||
│ └── OutputTableAttribute.cs
|
||||
├── Configuration/
|
||||
│ └── ExcelExportOptions.cs
|
||||
├── Generators/
|
||||
│ ├── CriteriaSheetGenerator.cs # Search Criteria tab
|
||||
│ ├── AttributeTableWriter.cs # Generic attribute-driven table writer
|
||||
│ └── DataEntryTemplateGenerator.cs # Bulk upload templates
|
||||
├── Formatting/
|
||||
│ ├── HeaderFormatter.cs # Header cell formatting
|
||||
│ ├── ColumnFormatter.cs # Column width and number format
|
||||
│ └── WorksheetProtector.cs # Password protection
|
||||
├── Helpers/
|
||||
│ └── OutputColumnCache.cs # Cached reflection for column metadata
|
||||
├── Models/
|
||||
│ └── OutputColumn.cs # Column metadata model
|
||||
├── Interfaces/
|
||||
│ └── IExcelExportService.cs
|
||||
├── ExcelExportService.cs
|
||||
├── ServiceCollectionExtensions.cs
|
||||
└── JdeScoping.ExcelExport.csproj
|
||||
```
|
||||
|
||||
## ClosedXML Workbook Generation
|
||||
|
||||
### Library Selection: ClosedXML
|
||||
|
||||
**Why ClosedXML over EPPlus:**
|
||||
|
||||
| Aspect | EPPlus v7+ | ClosedXML |
|
||||
|--------|------------|-----------|
|
||||
| License | Commercial (Polyform NC) | MIT (fully free) |
|
||||
| API Similarity | N/A (legacy v4 was LGPL) | Very similar to EPPlus v4 |
|
||||
| Maintenance | Active (commercial) | Active (community) |
|
||||
| .NET 10 Support | Yes | Yes |
|
||||
| NuGet Downloads | High | High |
|
||||
|
||||
**NuGet Package:** `ClosedXML` (version 0.104.* or later)
|
||||
|
||||
### API Migration Guide: EPPlus to ClosedXML
|
||||
|
||||
| EPPlus (Legacy) | ClosedXML (New) |
|
||||
|-----------------|-----------------|
|
||||
| `ExcelPackage` | `XLWorkbook` |
|
||||
| `ExcelWorkbook` | `XLWorkbook` (same object) |
|
||||
| `ExcelWorksheet` | `IXLWorksheet` |
|
||||
| `ExcelRange` | `IXLRange` or `IXLCell` |
|
||||
| `ExcelTable` | `IXLTable` |
|
||||
| `worksheet.Cells[row, col]` | `worksheet.Cell(row, col)` |
|
||||
| `worksheet.Tables.Add(range, name)` | `worksheet.Range(...).CreateTable(name)` or `AsTable()` |
|
||||
| `TableStyles.Light18` | `XLTableTheme.TableStyleLight18` |
|
||||
| `Color.Gainsboro` | `XLColor.Gainsboro` |
|
||||
| `range.Style.Fill.BackgroundColor.SetColor(...)` | `range.Style.Fill.BackgroundColor = XLColor.X` |
|
||||
| `worksheet.Column(col).AutoFit()` | `worksheet.Column(col).AdjustToContents()` |
|
||||
| `worksheet.Protection.SetPassword(pwd)` | `worksheet.Protect(pwd)` |
|
||||
| `worksheet.ProtectedRanges.Add(...)` | Not needed (use cell unlock instead) |
|
||||
|
||||
### Workbook Generation Flow
|
||||
|
||||
```csharp
|
||||
public async Task<byte[]> GenerateAsync(
|
||||
SearchModel search,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["SearchId"] = search.Id,
|
||||
["SearchName"] = search.Name
|
||||
});
|
||||
|
||||
_logger.LogInformation("Starting Excel export generation");
|
||||
|
||||
// ClosedXML operations are synchronous, wrap in Task.Run for non-blocking
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
|
||||
// 1. Always generate Search Criteria sheet (first tab)
|
||||
GenerateCriteriaSheet(workbook, search);
|
||||
|
||||
// 2. Always generate Search Results sheet (second tab)
|
||||
GenerateResultsSheet(workbook, search.Results);
|
||||
|
||||
// 3. Conditionally generate MIS Info sheet
|
||||
if (search.ExtractMisData && search.MisResults != null)
|
||||
{
|
||||
GenerateMisInfoSheet(workbook, search.MisResults);
|
||||
}
|
||||
|
||||
// 4. Conditionally generate Investigation sheet
|
||||
if (search.ExtractMisData && search.MisNonMatchResults != null)
|
||||
{
|
||||
GenerateInvestigationSheet(workbook, search.MisNonMatchResults);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Save to byte array
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
|
||||
}, cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
## Sheet Generators
|
||||
|
||||
### Criteria Sheet Generator
|
||||
|
||||
The Search Criteria sheet documents all search parameters and execution metadata.
|
||||
|
||||
**Structure:**
|
||||
|
||||
```
|
||||
Row 1: [Search Name] [value]
|
||||
Row 2: [User Name] [value]
|
||||
Row 3: (blank)
|
||||
Row 4: [Submit timestamp] [MMM dd, yyyy hh:mm:ss tt EST]
|
||||
Row 5: [Start timestamp] [MMM dd, yyyy hh:mm:ss tt EST]
|
||||
Row 6: [Completed timestamp] [MMM dd, yyyy hh:mm:ss tt EST]
|
||||
Row 7: (blank)
|
||||
Row 8+: [Timespan Filter Table]
|
||||
(2 blank rows)
|
||||
[Work Order Filter Table]
|
||||
(2 blank rows)
|
||||
[Item Number Filter Table]
|
||||
...
|
||||
[Extract MIS data?] [YES/NO]
|
||||
```
|
||||
|
||||
**Filter Table Order:**
|
||||
1. Timespan Filter
|
||||
2. Work Order Filter
|
||||
3. Item Number Filter
|
||||
4. Profit Center Filter
|
||||
5. Work Center Filter
|
||||
6. Component Lot Filter
|
||||
7. Operator Filter
|
||||
8. Item/Operation/MIS Filter
|
||||
|
||||
**Implementation Pattern:**
|
||||
|
||||
```csharp
|
||||
private void GenerateCriteriaSheet(XLWorkbook workbook, SearchModel search)
|
||||
{
|
||||
var worksheet = workbook.Worksheets.Add("Search Criteria");
|
||||
var row = 1;
|
||||
|
||||
// Header rows
|
||||
ApplyHeaderFormat(worksheet.Cell(row, 1), "Search Name");
|
||||
worksheet.Cell(row, 2).Value = search.Name;
|
||||
|
||||
ApplyHeaderFormat(worksheet.Cell(++row, 1), "User Name");
|
||||
worksheet.Cell(row, 2).Value = search.UserName;
|
||||
|
||||
row++; // blank
|
||||
|
||||
// Timestamps
|
||||
ApplyHeaderFormat(worksheet.Cell(++row, 1), "Submit timestamp");
|
||||
worksheet.Cell(row, 2).Value = $"{search.SubmitDT:MMM dd, yyyy hh:mm:ss tt} EST";
|
||||
|
||||
// ... more timestamps ...
|
||||
|
||||
// Filter tables
|
||||
row = WriteFilterTable(worksheet, ++row, CreateTimespanFilter(search));
|
||||
row = WriteFilterTable(worksheet, row + 3, search.WorkOrderFilter);
|
||||
row = WriteFilterTable(worksheet, row + 3, search.ItemNumberFilter);
|
||||
// ... more filter tables ...
|
||||
|
||||
// Extract MIS data indicator
|
||||
var headerRange = worksheet.Range(row, 1, row, 2);
|
||||
ApplyHeaderFormat(headerRange, "Extract MIS data?", merge: true);
|
||||
worksheet.Cell(++row, 1).Value = search.ExtractMisData ? "YES" : "NO";
|
||||
|
||||
// Auto-fit with 15% padding
|
||||
for (int col = 1; col <= 4; col++)
|
||||
{
|
||||
worksheet.Column(col).AdjustToContents();
|
||||
worksheet.Column(col).Width *= 1.15;
|
||||
}
|
||||
|
||||
// Protection
|
||||
worksheet.Protect(_options.Value.CriteriaSheetPassword);
|
||||
}
|
||||
```
|
||||
|
||||
### Attribute-Driven Table Writer
|
||||
|
||||
The `AttributeTableWriter` generates Excel tables from model collections using reflection on `OutputColumnAttribute` and `OutputTableAttribute`.
|
||||
|
||||
**Column Ordering:**
|
||||
1. Sort by `OutputColumnAttribute.Order` (ascending)
|
||||
2. Break ties alphabetically by property name
|
||||
|
||||
**Implementation Pattern:**
|
||||
|
||||
```csharp
|
||||
public class AttributeTableWriter
|
||||
{
|
||||
private readonly OutputColumnCache _cache;
|
||||
|
||||
public IXLTable WriteTable<T>(
|
||||
IXLWorksheet worksheet,
|
||||
int startRow,
|
||||
int startCol,
|
||||
IEnumerable<T> data,
|
||||
string? tableNameOverride = null)
|
||||
{
|
||||
var tableAttr = typeof(T).GetCustomAttribute<OutputTableAttribute>();
|
||||
var columns = _cache.GetColumns<T>();
|
||||
var tableName = tableNameOverride ?? tableAttr?.TableName ?? typeof(T).Name;
|
||||
|
||||
// Write header row
|
||||
var col = startCol;
|
||||
foreach (var column in columns)
|
||||
{
|
||||
var cell = worksheet.Cell(startRow, col);
|
||||
cell.Value = column.Attribute.HeaderText;
|
||||
ApplyHeaderFormat(cell);
|
||||
col++;
|
||||
}
|
||||
|
||||
// Write data rows
|
||||
var dataList = data.ToList();
|
||||
var row = startRow + 1;
|
||||
foreach (var item in dataList)
|
||||
{
|
||||
col = startCol;
|
||||
foreach (var column in columns)
|
||||
{
|
||||
var value = column.Property.GetValue(item);
|
||||
worksheet.Cell(row, col).Value = XLCellValue.FromObject(value);
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
|
||||
// Create table
|
||||
var dataRange = worksheet.Range(
|
||||
startRow, startCol,
|
||||
startRow + dataList.Count, startCol + columns.Count - 1);
|
||||
var table = dataRange.CreateTable(tableName);
|
||||
table.Theme = XLTableTheme.TableStyleLight18;
|
||||
table.ShowTotalsRow = false;
|
||||
|
||||
// Apply column formatting
|
||||
col = startCol;
|
||||
foreach (var column in columns)
|
||||
{
|
||||
ApplyColumnFormat(worksheet.Column(col), column.Attribute);
|
||||
col++;
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Sheet Generators (Results, MIS Info, Investigation)
|
||||
|
||||
These sheets use the `AttributeTableWriter` with model-specific configuration.
|
||||
|
||||
**Search Results Sheet:**
|
||||
- 19 columns per SearchResult model
|
||||
- Auto-fit with 30% padding
|
||||
- No protection (legacy behavior via attribute-driven path)
|
||||
- Light18 table style
|
||||
|
||||
**MIS Info Sheet:**
|
||||
- 19 columns per MisSearchResult model
|
||||
- Three columns with wrapped text (fixed 65-char width):
|
||||
- Test Description
|
||||
- Tools & Gauges
|
||||
- Work Instructions
|
||||
- Other columns: auto-fit with 30% padding
|
||||
|
||||
**Investigation Sheet:**
|
||||
- 12 columns per MisNonMatchSearchResult model
|
||||
- Date columns use `[$-409]MM/dd/yyyy;@` format
|
||||
- Auto-fit with 30% padding
|
||||
|
||||
## Column Formatting Patterns
|
||||
|
||||
### Format Constants
|
||||
|
||||
```csharp
|
||||
public static class ExcelFormats
|
||||
{
|
||||
public const string STD_FORMAT = "@"; // Text
|
||||
public const string DATE_FORMAT = "[$-409]MM/dd/yyyy;@";
|
||||
public const string TIMESTAMP_FORMAT = "[$-409]m/d/yy h:mm AM/PM;@";
|
||||
public const double WRAPPED_COLUMN_WIDTH = 65;
|
||||
public const double CRITERIA_PADDING_FACTOR = 1.15;
|
||||
public const double DATA_PADDING_FACTOR = 1.30;
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Fit with Padding
|
||||
|
||||
```csharp
|
||||
private void ApplyColumnFormat(IXLColumn column, OutputColumnAttribute attr)
|
||||
{
|
||||
// Set number format
|
||||
column.Style.NumberFormat.Format = attr.Format;
|
||||
|
||||
if (attr.WrapText)
|
||||
{
|
||||
column.Style.Alignment.WrapText = true;
|
||||
}
|
||||
|
||||
if (attr.AutoWidth)
|
||||
{
|
||||
column.AdjustToContents();
|
||||
column.Width *= ExcelFormats.DATA_PADDING_FACTOR;
|
||||
}
|
||||
else
|
||||
{
|
||||
column.Width = attr.Width;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Wrapped Text Columns
|
||||
|
||||
Columns marked with `WrapText = true` and `AutoWidth = false` skip auto-fit:
|
||||
|
||||
```csharp
|
||||
if (attr.WrapText && !attr.AutoWidth)
|
||||
{
|
||||
column.Width = attr.Width; // Usually 65
|
||||
column.Style.Alignment.WrapText = true;
|
||||
// Do NOT call AdjustToContents()
|
||||
}
|
||||
```
|
||||
|
||||
## Worksheet Protection
|
||||
|
||||
### Protection Configuration
|
||||
|
||||
```csharp
|
||||
public class ExcelExportOptions
|
||||
{
|
||||
public const string SectionName = "ExcelExport";
|
||||
|
||||
public string CriteriaSheetPassword { get; set; } = "JDE_SCOPING_TOOL_PASS";
|
||||
public string DataSheetPassword { get; set; } = "JDESCOPINGTOOL";
|
||||
}
|
||||
```
|
||||
|
||||
### Allowed Operations
|
||||
|
||||
Protected worksheets allow specific operations:
|
||||
|
||||
```csharp
|
||||
private void ApplyProtection(IXLWorksheet worksheet, string password)
|
||||
{
|
||||
var protection = worksheet.Protect(password);
|
||||
|
||||
// Allow these operations
|
||||
protection.AllowElement(XLSheetProtectionElements.DeleteColumns);
|
||||
protection.AllowElement(XLSheetProtectionElements.AutoFilter);
|
||||
protection.AllowElement(XLSheetProtectionElements.FormatCells);
|
||||
protection.AllowElement(XLSheetProtectionElements.FormatColumns);
|
||||
protection.AllowElement(XLSheetProtectionElements.FormatRows);
|
||||
protection.AllowElement(XLSheetProtectionElements.SelectLockedCells);
|
||||
protection.AllowElement(XLSheetProtectionElements.SelectUnlockedCells);
|
||||
protection.AllowElement(XLSheetProtectionElements.EditObjects);
|
||||
protection.AllowElement(XLSheetProtectionElements.Sort);
|
||||
|
||||
// DeleteRows is NOT allowed (not in AllowElement call)
|
||||
}
|
||||
```
|
||||
|
||||
### Editable Extension Area
|
||||
|
||||
Legacy code unlocked an area beyond the data for user additions. In ClosedXML:
|
||||
|
||||
```csharp
|
||||
// Unlock cells beyond data range for user editing
|
||||
var lastDataRow = table.RangeAddress.LastAddress.RowNumber;
|
||||
var lastDataCol = table.RangeAddress.LastAddress.ColumnNumber;
|
||||
|
||||
// Unlock 1000 rows and columns beyond data
|
||||
var extensionRange = worksheet.Range(
|
||||
1, lastDataCol + 1,
|
||||
lastDataRow + 1000, lastDataCol + 1000);
|
||||
extensionRange.Style.Protection.Locked = false;
|
||||
```
|
||||
|
||||
**Note:** The legacy code applies protection to data sheets (Search Results, MIS Info, Investigation) via the `ApplySecurity` method. The criteria sheet uses a different password.
|
||||
|
||||
## Memory Management for Large Exports
|
||||
|
||||
### Current Approach: In-Memory
|
||||
|
||||
The current implementation loads all data into memory and generates the workbook synchronously:
|
||||
|
||||
```csharp
|
||||
// All results loaded
|
||||
var results = search.Results; // List<SearchResult>
|
||||
|
||||
// Workbook built in memory
|
||||
using var workbook = new XLWorkbook();
|
||||
// ... add sheets ...
|
||||
|
||||
// Serialize to byte[]
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
```
|
||||
|
||||
### Memory Considerations
|
||||
|
||||
| Export Size | Rows | Estimated Memory | Approach |
|
||||
|-------------|------|------------------|----------|
|
||||
| Small | <10K | <50 MB | In-memory (current) |
|
||||
| Medium | 10K-100K | 50-500 MB | In-memory (current) |
|
||||
| Large | >100K | >500 MB | Consider streaming (future) |
|
||||
|
||||
### Future Optimization: Streaming
|
||||
|
||||
For very large exports, ClosedXML supports SAX-based streaming via `XLWorkbook.SaveAsAsync()`. This would require:
|
||||
|
||||
1. Writing sheets incrementally
|
||||
2. Using `IAsyncEnumerable<T>` for data sources
|
||||
3. Streaming directly to response or file
|
||||
|
||||
This is deferred to a future phase if memory pressure becomes an issue.
|
||||
|
||||
## Temp File Handling
|
||||
|
||||
### No Temp Files Required
|
||||
|
||||
The current implementation writes directly to `MemoryStream` and returns `byte[]`. The result is stored in the `Search.Results` column (VARBINARY) in the database.
|
||||
|
||||
If debugging is enabled (legacy behavior), a copy may be written to disk:
|
||||
|
||||
```csharp
|
||||
if (_options.Value.DebugWriteToFile)
|
||||
{
|
||||
var debugPath = Path.Combine(
|
||||
_options.Value.DebugOutputDirectory,
|
||||
$"Search_{search.Id}_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx");
|
||||
await File.WriteAllBytesAsync(debugPath, result, cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
This is optional and controlled by configuration.
|
||||
|
||||
## Header Formatting
|
||||
|
||||
### Standard Header Style
|
||||
|
||||
```csharp
|
||||
private void ApplyHeaderFormat(IXLCell cell, string? text = null)
|
||||
{
|
||||
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
cell.Style.Font.Bold = true;
|
||||
cell.Style.Fill.BackgroundColor = XLColor.Gainsboro;
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
cell.Value = text;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyHeaderFormat(IXLRange range, string? text = null, bool merge = false)
|
||||
{
|
||||
range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
range.Style.Font.Bold = true;
|
||||
range.Style.Fill.BackgroundColor = XLColor.Gainsboro;
|
||||
|
||||
if (merge)
|
||||
{
|
||||
range.Merge();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
range.FirstCell().Value = text;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Entry Template Generator
|
||||
|
||||
For bulk data entry via the UI:
|
||||
|
||||
```csharp
|
||||
public class DataEntryTemplateGenerator
|
||||
{
|
||||
public byte[] Generate<T>(IEnumerable<T>? sourceData, string headerText)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.Worksheets.Add("Data Entry Template");
|
||||
|
||||
// Header
|
||||
var headerCell = worksheet.Cell(1, 1);
|
||||
ApplyHeaderFormat(headerCell, headerText);
|
||||
worksheet.Column(1).Width = 45;
|
||||
|
||||
// Data (if provided)
|
||||
if (sourceData != null)
|
||||
{
|
||||
var row = 2;
|
||||
foreach (var item in sourceData)
|
||||
{
|
||||
worksheet.Cell(row++, 1).Value = XLCellValue.FromObject(item);
|
||||
}
|
||||
}
|
||||
|
||||
// All cells as text
|
||||
worksheet.Column(1).Style.NumberFormat.Format = "@";
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public byte[] Generate(object[][]? sourceData, string[] headers)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.Worksheets.Add("Data Entry Template");
|
||||
|
||||
// Headers
|
||||
for (int col = 0; col < headers.Length; col++)
|
||||
{
|
||||
ApplyHeaderFormat(worksheet.Cell(1, col + 1), headers[col]);
|
||||
worksheet.Column(col + 1).Width = 65;
|
||||
worksheet.Column(col + 1).Style.NumberFormat.Format = "@";
|
||||
}
|
||||
|
||||
// Data
|
||||
if (sourceData != null)
|
||||
{
|
||||
for (int row = 0; row < sourceData.Length; row++)
|
||||
{
|
||||
for (int col = 0; col < sourceData[row].Length; col++)
|
||||
{
|
||||
worksheet.Cell(row + 2, col + 1).Value =
|
||||
XLCellValue.FromObject(sourceData[row][col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service Registration
|
||||
|
||||
```csharp
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddExcelExport(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind options
|
||||
services.Configure<ExcelExportOptions>(
|
||||
configuration.GetSection(ExcelExportOptions.SectionName));
|
||||
|
||||
// Register main service (scoped - per request)
|
||||
services.AddScoped<IExcelExportService, ExcelExportService>();
|
||||
|
||||
// Register helpers (singleton - stateless)
|
||||
services.AddSingleton<OutputColumnCache>();
|
||||
services.AddSingleton<AttributeTableWriter>();
|
||||
services.AddSingleton<DataEntryTemplateGenerator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## NuGet Dependencies
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.104.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.*" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Mock search models with various filter combinations
|
||||
- Verify sheet generation logic without file I/O
|
||||
- Test column ordering and formatting
|
||||
- Test inclusion reason calculation
|
||||
- Test null handling for optional sheets
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Generate actual .xlsx files
|
||||
- Open with ClosedXML to verify structure
|
||||
- Compare against legacy output samples
|
||||
- Test protection passwords
|
||||
- Verify table styles applied correctly
|
||||
|
||||
### Test Data
|
||||
|
||||
```csharp
|
||||
public static class ExcelExportTestData
|
||||
{
|
||||
public static SearchModel CreateMinimalSearch() => new()
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test Search",
|
||||
UserName = "testuser",
|
||||
SubmitDT = DateTime.Now.AddHours(-1),
|
||||
StartDT = DateTime.Now.AddMinutes(-30),
|
||||
EndDT = DateTime.Now,
|
||||
ExtractMisData = false,
|
||||
Results = new List<SearchResult>
|
||||
{
|
||||
CreateSearchResult(12345, "ITEM-001")
|
||||
}
|
||||
};
|
||||
|
||||
public static SearchModel CreateFullSearch() => new()
|
||||
{
|
||||
// ... includes MisResults and MisNonMatchResults
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# Implement Excel Export
|
||||
|
||||
## Summary
|
||||
|
||||
Implement the Excel export subsystem that generates multi-sheet Excel workbooks (.xlsx) containing search results and criteria documentation using the ClosedXML library on .NET 10. This phase provides an injectable `IExcelExportService` that transforms search data into formatted, protected spreadsheets with conditional sheet generation based on search options (MIS data extraction).
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- `IExcelExportService` interface and `ExcelExportService` implementation
|
||||
- `ExcelExportOptions` configuration class with protection passwords
|
||||
- Search Criteria sheet generator with filter tables
|
||||
- Search Results sheet generator with attribute-driven columns
|
||||
- MIS Info sheet generator (conditional on ExtractMisData)
|
||||
- Investigation sheet generator (conditional on ExtractMisData)
|
||||
- Worksheet protection with configurable passwords
|
||||
- Attribute-driven column configuration (`OutputColumnAttribute`, `OutputTableAttribute`)
|
||||
- Data entry template generator for bulk upload
|
||||
- Header cell formatting utilities
|
||||
- Service registration extension method (`AddExcelExport`)
|
||||
- Unit tests with xUnit, Shouldly, and NSubstitute
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- API endpoints for Excel download (Phase 8: web-api-auth)
|
||||
- Search result storage/retrieval (Phase 6: search-processing)
|
||||
- SignalR progress updates (Phase 8: web-api-auth)
|
||||
- File system storage of exports (results stored as byte[] in database)
|
||||
- Streaming exports for very large files (deferred to future optimization)
|
||||
|
||||
## Motivation
|
||||
|
||||
The Excel export subsystem is a core deliverable of the JDE Scoping Tool. Users depend on well-formatted Excel reports containing:
|
||||
|
||||
- Complete search criteria documentation for audit trails
|
||||
- Work order results with status, quantities, and inclusion reasons
|
||||
- MIS (Manufacturing Information System) data for quality analysis
|
||||
- Investigation data for router mismatch analysis
|
||||
|
||||
Migrating from EPPlus (which requires commercial license in v7+) to ClosedXML (MIT license) eliminates licensing concerns while maintaining comparable functionality.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `IExcelExportService.GenerateAsync()` produces valid .xlsx files
|
||||
2. Workbooks contain correct sheets based on `ExtractMisData` flag:
|
||||
- Standard export: 2 sheets (Search Criteria, Search Results)
|
||||
- Full export: 4 sheets (+ MIS Info, Investigation)
|
||||
3. All column definitions match legacy output exactly:
|
||||
- Search Results: 19 columns with correct headers and formats
|
||||
- MIS Info: 19 columns with wrapped text columns
|
||||
- Investigation: 12 columns with date formatting
|
||||
4. Worksheet protection applied with correct passwords from configuration
|
||||
5. Filter tables use Light18 table style with Gainsboro headers
|
||||
6. Auto-fit columns with correct padding (15% for criteria, 30% for data)
|
||||
7. Wrapped text columns (Test Description, Tools & Gauges, Work Instructions) use fixed 65-character width
|
||||
8. Inclusion reason computed correctly from boolean flags
|
||||
9. Service registered correctly via `AddExcelExport()` extension method
|
||||
10. Unit tests achieve >90% code coverage
|
||||
11. `openspec validate implement-excel-export --strict` passes
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Phase | Dependency | Type |
|
||||
|-------|------------|------|
|
||||
| Phase 3: implement-domain-models | `SearchModel`, `SearchResult`, `MisSearchResult`, `MisNonMatchSearchResult` | Required |
|
||||
| Phase 6: implement-search-processing | Populated search results for testing | Soft dependency (can mock) |
|
||||
|
||||
**Note:** This phase can proceed in parallel with Phase 6 by mocking search results in tests. The `SearchModel` and result types must be defined first.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| ClosedXML API differences from EPPlus | Medium | Medium | Cross-reference ClosedXML documentation; create API mapping guide in design.md |
|
||||
| Memory pressure with large exports | Low | High | Monitor memory usage; document streaming approach for future optimization |
|
||||
| Protection password exposure | Low | Medium | Store passwords in configuration, document in security notes |
|
||||
| Column order/format mismatch | Medium | High | Generate comparison spreadsheets; verify against legacy output |
|
||||
|
||||
## Related Specs
|
||||
|
||||
- `excel-export/spec.md` - Base specification for Excel export subsystem
|
||||
- `domain-models/spec.md` - Domain models including SearchModel and result types
|
||||
- `search-processing/spec.md` - Search processing that produces results for export
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
# Excel Export Specification Delta
|
||||
|
||||
This document describes ADDED and MODIFIED requirements for the Excel export subsystem migration from .NET Framework 4.8 to .NET 10.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ClosedXML Library Usage
|
||||
|
||||
The system SHALL use ClosedXML library for all Excel generation operations.
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- The implementation MUST use `XLWorkbook` class from ClosedXML (not EPPlus)
|
||||
- Colors MUST use `XLColor` type (e.g., `XLColor.Gainsboro`)
|
||||
- Table styles MUST use `XLTableTheme` enumeration
|
||||
- Worksheet protection MUST use `IXLWorksheet.Protect()` method
|
||||
- Cell access MUST use `worksheet.Cell(row, col)` syntax
|
||||
- Table creation MUST use `range.CreateTable()` or `range.AsTable()` methods
|
||||
|
||||
#### Rationale
|
||||
|
||||
EPPlus v7+ requires a commercial license (Polyform Noncommercial). ClosedXML is MIT-licensed and provides comparable functionality for .NET 10.
|
||||
|
||||
#### Scenario: Create workbook with ClosedXML
|
||||
|
||||
- **WHEN** generating an Excel export
|
||||
- **THEN** the system uses `new XLWorkbook()` for workbook creation
|
||||
- **AND** uses `worksheet.Cell(row, col)` for cell access
|
||||
- **AND** uses `XLColor.Gainsboro` for header backgrounds
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Async Generation Pattern
|
||||
|
||||
The system SHALL provide async-first API for Excel generation.
|
||||
|
||||
#### Interface Definition
|
||||
|
||||
```csharp
|
||||
public interface IExcelExportService
|
||||
{
|
||||
Task<byte[]> GenerateAsync(
|
||||
SearchModel search,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- The `GenerateAsync` method MUST return `Task<byte[]>`
|
||||
- The method MUST accept `CancellationToken` for cancellation support
|
||||
- Since ClosedXML's `SaveAs` to `MemoryStream` is synchronous, the implementation MUST wrap CPU-bound work in `Task.Run()`
|
||||
- The method MUST check cancellation token before generating each sheet
|
||||
- The method MUST throw `OperationCanceledException` when cancelled
|
||||
|
||||
#### Scenario: Support cancellation during export
|
||||
|
||||
- **WHEN** `GenerateAsync` is called with a `CancellationToken` that is cancelled
|
||||
- **THEN** the operation throws `OperationCanceledException`
|
||||
- **AND** partial workbook resources are disposed
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scoped Structured Logging
|
||||
|
||||
The system SHALL use scoped structured logging for export operations.
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- The service MUST use `ILogger<ExcelExportService>` for structured logging
|
||||
- Log entries MUST include search context via `ILogger.BeginScope()`
|
||||
- Scope MUST include `SearchId` and `SearchName` keys
|
||||
- Export start, sheet generation, and completion MUST be logged at Information level
|
||||
- Errors MUST be logged at Error level with exception details
|
||||
|
||||
#### Implementation Pattern
|
||||
|
||||
```csharp
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["SearchId"] = search.Id,
|
||||
["SearchName"] = search.Name
|
||||
});
|
||||
|
||||
_logger.LogInformation("Starting Excel export generation");
|
||||
```
|
||||
|
||||
#### Scenario: Log export operations with context
|
||||
|
||||
- **WHEN** generating an export for a search
|
||||
- **THEN** log entries include SearchId and SearchName via scope
|
||||
- **AND** structured logging captures operation progress
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configuration via IOptions Pattern
|
||||
|
||||
The system SHALL use IOptions<T> pattern for configuration.
|
||||
|
||||
#### Configuration Class
|
||||
|
||||
```csharp
|
||||
public class ExcelExportOptions
|
||||
{
|
||||
public const string SectionName = "ExcelExport";
|
||||
|
||||
public string CriteriaSheetPassword { get; set; } = "JDE_SCOPING_TOOL_PASS";
|
||||
public string DataSheetPassword { get; set; } = "JDESCOPINGTOOL";
|
||||
public bool DebugWriteToFile { get; set; } = false;
|
||||
public string DebugOutputDirectory { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Protection passwords MUST be loaded from `IOptions<ExcelExportOptions>`
|
||||
- Default password values MUST match legacy values for backward compatibility
|
||||
- Configuration section MUST be named "ExcelExport" in appsettings.json
|
||||
- Debug file writing MUST be optional and disabled by default
|
||||
|
||||
#### Scenario: Configure via appsettings.json
|
||||
|
||||
- **WHEN** appsettings.json contains ExcelExport section
|
||||
- **THEN** ExcelExportOptions binds to configured values
|
||||
- **AND** passwords from configuration are used for worksheet protection
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Service Registration Extension Method
|
||||
|
||||
The system SHALL provide a DI extension method for service registration.
|
||||
|
||||
#### Implementation
|
||||
|
||||
```csharp
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddExcelExport(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<ExcelExportOptions>(
|
||||
configuration.GetSection(ExcelExportOptions.SectionName));
|
||||
|
||||
services.AddScoped<IExcelExportService, ExcelExportService>();
|
||||
services.AddSingleton<OutputColumnCache>();
|
||||
services.AddSingleton<AttributeTableWriter>();
|
||||
services.AddSingleton<DataEntryTemplateGenerator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- `IExcelExportService` MUST be registered as scoped (per-request lifetime)
|
||||
- Helper classes (caches, writers) MUST be registered as singleton (stateless)
|
||||
- Options MUST be bound from IConfiguration
|
||||
- Extension method MUST return IServiceCollection for chaining
|
||||
|
||||
#### Scenario: Register service in DI container
|
||||
|
||||
- **WHEN** the application starts and calls `AddExcelExport()`
|
||||
- **THEN** `IExcelExportService` is registered with `ExcelExportService` implementation
|
||||
- **AND** helper services are registered as singletons
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Native Reflection for Property Access
|
||||
|
||||
The system SHALL use native .NET reflection for attribute-driven column configuration.
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Property access MUST use native `PropertyInfo.GetValue()` (not Fasterflect)
|
||||
- Column metadata MUST be cached using `ConcurrentDictionary` for performance
|
||||
- Cache key MUST be the model Type
|
||||
- Cached data MUST include PropertyInfo, OutputColumnAttribute, and computed values
|
||||
|
||||
#### Implementation Pattern
|
||||
|
||||
```csharp
|
||||
public class OutputColumnCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<Type, IReadOnlyList<OutputColumn>> _cache = new();
|
||||
|
||||
public IReadOnlyList<OutputColumn> GetColumns<T>() =>
|
||||
_cache.GetOrAdd(typeof(T), BuildColumns);
|
||||
|
||||
private IReadOnlyList<OutputColumn> BuildColumns(Type type)
|
||||
{
|
||||
return type.GetProperties()
|
||||
.Where(p => p.GetCustomAttribute<OutputColumnAttribute>() != null)
|
||||
.Select(p => new OutputColumn(
|
||||
p.Name,
|
||||
p,
|
||||
p.GetCustomAttribute<OutputColumnAttribute>()!))
|
||||
.OrderBy(c => c.Attribute.Order)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: Cache column metadata on first access
|
||||
|
||||
- **WHEN** `GetColumns<SearchResult>()` is called multiple times
|
||||
- **THEN** reflection is performed only once
|
||||
- **AND** subsequent calls return cached metadata
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Temp File Cleanup
|
||||
|
||||
The system SHALL clean up any debug temp files based on configuration.
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Debug file writing MUST only occur when `DebugWriteToFile` is true
|
||||
- Debug files MUST be written to `DebugOutputDirectory` path
|
||||
- File naming MUST follow pattern: `Search_{SearchId}_{timestamp}.xlsx`
|
||||
- Cleanup of old debug files is NOT automatic (manual cleanup responsibility)
|
||||
|
||||
#### Scenario: Write debug file when enabled
|
||||
|
||||
- **WHEN** `DebugWriteToFile` is true in configuration
|
||||
- **THEN** a copy of the export is written to the debug directory
|
||||
- **AND** the byte[] result is still returned normally
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Table Style Application
|
||||
|
||||
The system SHALL use ClosedXML table theme enumeration.
|
||||
|
||||
#### Migration
|
||||
|
||||
| Legacy (EPPlus) | New (ClosedXML) |
|
||||
|-----------------|-----------------|
|
||||
| `TableStyles.Light18` | `XLTableTheme.TableStyleLight18` |
|
||||
| `TableStyles.Medium1` | `XLTableTheme.TableStyleMedium1` |
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Filter tables in criteria sheet MUST use `XLTableTheme.TableStyleLight18`
|
||||
- Data tables (Results, MIS Info, Investigation) MUST use `XLTableTheme.TableStyleLight18`
|
||||
- Table totals row MUST be disabled (`table.ShowTotalsRow = false`)
|
||||
|
||||
#### Scenario: Apply table theme via ClosedXML
|
||||
|
||||
- **WHEN** creating a data table
|
||||
- **THEN** the system uses `XLTableTheme.TableStyleLight18`
|
||||
- **AND** disables the totals row via `table.ShowTotalsRow = false`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Color Type Usage
|
||||
|
||||
The system SHALL use ClosedXML color types.
|
||||
|
||||
#### Migration
|
||||
|
||||
| Legacy (EPPlus/System.Drawing) | New (ClosedXML) |
|
||||
|--------------------------------|-----------------|
|
||||
| `Color.Gainsboro` | `XLColor.Gainsboro` |
|
||||
| `Color.FromArgb(...)` | `XLColor.FromArgb(...)` |
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Header background color MUST use `XLColor.Gainsboro`
|
||||
- All color assignments MUST use `XLColor` type
|
||||
|
||||
#### Scenario: Apply Gainsboro background via ClosedXML
|
||||
|
||||
- **WHEN** formatting a header cell
|
||||
- **THEN** the system uses `cell.Style.Fill.BackgroundColor = XLColor.Gainsboro`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Column Auto-Fit Method
|
||||
|
||||
The system SHALL use ClosedXML auto-fit methods.
|
||||
|
||||
#### Migration
|
||||
|
||||
| Legacy (EPPlus) | New (ClosedXML) |
|
||||
|-----------------|-----------------|
|
||||
| `column.AutoFit()` | `column.AdjustToContents()` |
|
||||
| `column.Width = x` | `column.Width = x` (same) |
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Auto-fit MUST use `AdjustToContents()` method
|
||||
- Padding factor MUST be applied after auto-fit: `column.Width *= paddingFactor`
|
||||
- Criteria sheet padding: 1.15 (15%)
|
||||
- Data sheet padding: 1.30 (30%)
|
||||
- Wrapped columns MUST NOT call `AdjustToContents()` (use fixed width)
|
||||
|
||||
#### Scenario: Auto-fit column with padding
|
||||
|
||||
- **WHEN** auto-fitting a data column
|
||||
- **THEN** the system calls `column.AdjustToContents()`
|
||||
- **AND** applies 30% padding via `column.Width *= 1.30`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Worksheet Protection API
|
||||
|
||||
The system SHALL use ClosedXML protection API.
|
||||
|
||||
#### Migration
|
||||
|
||||
| Legacy (EPPlus) | New (ClosedXML) |
|
||||
|-----------------|-----------------|
|
||||
| `worksheet.Protection.SetPassword(pwd)` | `worksheet.Protect(pwd)` |
|
||||
| `worksheet.Protection.AllowAutoFilter = true` | `protection.AllowElement(XLSheetProtectionElements.AutoFilter)` |
|
||||
| `worksheet.ProtectedRanges.Add(...)` | `range.Style.Protection.Locked = false` |
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Protection MUST return `IXLSheetProtection` object
|
||||
- Allowed operations MUST use `AllowElement()` method
|
||||
- Cells beyond data range MUST have `Locked = false` for user editing
|
||||
- Extension area: 1000 rows and columns beyond data
|
||||
|
||||
#### Scenario: Apply worksheet protection via ClosedXML
|
||||
|
||||
- **WHEN** protecting a data worksheet
|
||||
- **THEN** the system calls `worksheet.Protect(password)`
|
||||
- **AND** enables filtering via `protection.AllowElement(XLSheetProtectionElements.AutoFilter)`
|
||||
|
||||
---
|
||||
|
||||
## NuGet Package Changes
|
||||
|
||||
### REMOVED Dependencies
|
||||
|
||||
| Package | Reason |
|
||||
|---------|--------|
|
||||
| EPPlus (4.x LGPL) | Replaced by ClosedXML |
|
||||
| Fasterflect | Replaced by native reflection |
|
||||
|
||||
### ADDED Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| ClosedXML | 0.104.* | Excel generation (MIT license) |
|
||||
| Microsoft.Extensions.Options | 9.0.* | IOptions<T> pattern |
|
||||
| Microsoft.Extensions.Logging.Abstractions | 9.0.* | ILogger<T> interface |
|
||||
| Microsoft.Extensions.Configuration.Abstractions | 9.0.* | IConfiguration interface |
|
||||
@@ -0,0 +1,247 @@
|
||||
# Tasks: Implement Excel Export
|
||||
|
||||
## Phase 1: Project Setup
|
||||
|
||||
- [x] Create JdeScoping.ExcelExport project
|
||||
- Location: `NEW/src/JdeScoping.ExcelExport/JdeScoping.ExcelExport.csproj`
|
||||
- Target: net10.0
|
||||
- Validation: Project builds successfully
|
||||
- Dependencies: JdeScoping.Core (for base models)
|
||||
|
||||
- [x] Add NuGet package references
|
||||
- Packages: ClosedXML, Microsoft.Extensions.Options, Microsoft.Extensions.Logging.Abstractions, Microsoft.Extensions.Configuration.Abstractions
|
||||
- Validation: `dotnet restore` succeeds
|
||||
|
||||
- [x] Create folder structure
|
||||
- Folders: Attributes/, Configuration/, Generators/, Formatting/, Helpers/, Models/, Interfaces/
|
||||
- Validation: Directories exist
|
||||
|
||||
## Phase 2: Attributes and Configuration
|
||||
|
||||
- [x] Create OutputColumnAttribute class
|
||||
- Location: `Attributes/OutputColumnAttribute.cs`
|
||||
- Properties: Order, HeaderText, Format, AutoWidth, Width, WrapText
|
||||
- Constants: STD_FORMAT, DATE_FORMAT, TIMESTAMP_FORMAT, WRAPPED_COLUMN_WIDTH
|
||||
- Validation: Class compiles, matches legacy signature
|
||||
|
||||
- [x] Create OutputTableAttribute class
|
||||
- Location: `Attributes/OutputTableAttribute.cs`
|
||||
- Properties: TabName, TableName, ShowHeader
|
||||
- Validation: Class compiles, matches legacy signature
|
||||
|
||||
- [x] Create ExcelExportOptions class
|
||||
- Location: `Configuration/ExcelExportOptions.cs`
|
||||
- Properties: CriteriaSheetPassword, DataSheetPassword
|
||||
- Const: SectionName = "ExcelExport"
|
||||
- Validation: Class compiles with default values
|
||||
|
||||
## Phase 3: Helper Classes
|
||||
|
||||
- [x] Create OutputColumn model
|
||||
- Location: `Models/OutputColumn.cs`
|
||||
- Properties: Name, Property (PropertyInfo), Attribute (OutputColumnAttribute)
|
||||
- Validation: Class compiles
|
||||
|
||||
- [x] Create OutputColumnCache class
|
||||
- Location: `Helpers/OutputColumnCache.cs`
|
||||
- Pattern: ConcurrentDictionary for type-to-columns mapping
|
||||
- Method: GetColumns<T>() returns IReadOnlyList<OutputColumn>
|
||||
- Validation: Cache correctly caches and retrieves column metadata
|
||||
|
||||
## Phase 4: Formatting Utilities
|
||||
|
||||
- [x] Create HeaderFormatter static class
|
||||
- Location: `Formatting/HeaderFormatter.cs`
|
||||
- Methods: ApplyHeaderFormat(IXLCell, string?), ApplyHeaderFormat(IXLRange, string?, bool merge)
|
||||
- Style: Bold, centered, Gainsboro background
|
||||
- Validation: Unit tests verify cell styling
|
||||
|
||||
- [x] Create ColumnFormatter static class
|
||||
- Location: `Formatting/ColumnFormatter.cs`
|
||||
- Methods: ApplyColumnFormat(IXLColumn, OutputColumnAttribute)
|
||||
- Handles: Auto-fit with padding, wrapped text, number formats
|
||||
- Constants: ExcelFormats class with format strings
|
||||
- Validation: Unit tests verify column formatting
|
||||
|
||||
- [x] Create WorksheetProtector class
|
||||
- Location: `Formatting/WorksheetProtector.cs`
|
||||
- Method: ApplyProtection(IXLWorksheet, string password)
|
||||
- Configures: AllowElement for filter, sort, format operations
|
||||
- Validation: Protected sheet allows specified operations
|
||||
|
||||
## Phase 5: Sheet Generators
|
||||
|
||||
- [x] Create AttributeTableWriter class
|
||||
- Location: `Generators/AttributeTableWriter.cs`
|
||||
- Dependencies: OutputColumnCache
|
||||
- Method: WriteTable<T>(worksheet, startRow, startCol, data, tableNameOverride?)
|
||||
- Features: Header row, data rows, Light18 table style, column formatting
|
||||
- Validation: Generated table matches expected structure
|
||||
|
||||
- [x] Create CriteriaSheetGenerator class
|
||||
- Location: `Generators/CriteriaSheetGenerator.cs`
|
||||
- Dependencies: IOptions<ExcelExportOptions>
|
||||
- Method: Generate(XLWorkbook, SearchModel)
|
||||
- Features: Search info, timestamps, filter tables, MIS indicator, protection
|
||||
- Validation: Sheet matches legacy criteria sheet structure
|
||||
|
||||
- [x] Create DataEntryTemplateGenerator class
|
||||
- Location: `Generators/DataEntryTemplateGenerator.cs`
|
||||
- Methods: Generate<T>(data, headerText), Generate(data[][], headers[])
|
||||
- Features: Single/multi-column templates, text format, header styling
|
||||
- Validation: Generated templates match legacy output
|
||||
|
||||
## Phase 6: Service Interface and Implementation
|
||||
|
||||
- [x] Create IExcelExportService interface
|
||||
- Location: `Interfaces/IExcelExportService.cs`
|
||||
- Method: Task<byte[]> GenerateAsync(SearchModel, CancellationToken)
|
||||
- Validation: Interface compiles
|
||||
|
||||
- [x] Create ExcelExportService class
|
||||
- Location: `ExcelExportService.cs`
|
||||
- Dependencies: ILogger<ExcelExportService>, IOptions<ExcelExportOptions>, OutputColumnCache, AttributeTableWriter
|
||||
- Validation: Class compiles
|
||||
|
||||
- [x] Implement GenerateAsync method
|
||||
- Creates XLWorkbook
|
||||
- Generates criteria sheet (always)
|
||||
- Generates results sheet (always)
|
||||
- Generates MIS Info sheet (conditional)
|
||||
- Generates Investigation sheet (conditional)
|
||||
- Returns byte[] via MemoryStream
|
||||
- Validation: All sheet types generated correctly
|
||||
|
||||
- [x] Implement Search Criteria sheet generation
|
||||
- Uses CriteriaSheetGenerator
|
||||
- Filter tables with 2 blank row spacing
|
||||
- Auto-fit columns with 15% padding
|
||||
- Criteria sheet password protection
|
||||
- Validation: Matches legacy criteria sheet
|
||||
|
||||
- [x] Implement Search Results sheet generation
|
||||
- Uses AttributeTableWriter with SearchResult model
|
||||
- 19 columns per spec
|
||||
- Auto-fit with 30% padding
|
||||
- Light18 table style
|
||||
- Data sheet password protection
|
||||
- Validation: Matches legacy results sheet
|
||||
|
||||
- [x] Implement MIS Info sheet generation
|
||||
- Uses AttributeTableWriter with MisSearchResult model
|
||||
- 19 columns per spec
|
||||
- Wrapped columns (Test Description, Tools & Gauges, Work Instructions) with fixed 65-char width
|
||||
- Other columns: auto-fit with 30% padding
|
||||
- Null check: skip if MisResults is null
|
||||
- Validation: Matches legacy MIS Info sheet
|
||||
|
||||
- [x] Implement Investigation sheet generation
|
||||
- Uses AttributeTableWriter with MisNonMatchSearchResult model
|
||||
- 12 columns per spec
|
||||
- Date columns with DATE_FORMAT
|
||||
- Auto-fit with 30% padding
|
||||
- Null check: skip if MisNonMatchResults is null
|
||||
- Validation: Matches legacy Investigation sheet
|
||||
|
||||
## Phase 7: Logging and Error Handling
|
||||
|
||||
- [x] Implement structured logging
|
||||
- Use BeginScope with SearchId, SearchName
|
||||
- Log export start, sheet generation, completion
|
||||
- Log warnings for empty result sets
|
||||
- Validation: Log messages include search context
|
||||
|
||||
- [x] Implement cancellation support
|
||||
- Check CancellationToken before each sheet
|
||||
- Wrap workbook generation in Task.Run
|
||||
- Throw OperationCanceledException on cancellation
|
||||
- Validation: Long exports can be cancelled
|
||||
|
||||
## Phase 8: Service Registration
|
||||
|
||||
- [x] Create ServiceCollectionExtensions class
|
||||
- Location: `ServiceCollectionExtensions.cs`
|
||||
- Method: AddExcelExport(this IServiceCollection, IConfiguration)
|
||||
- Registers: ExcelExportOptions, IExcelExportService (scoped), helpers (singleton)
|
||||
- Validation: Services resolved correctly from DI
|
||||
|
||||
## Phase 9: Unit Tests
|
||||
|
||||
- [x] Create test project
|
||||
- Location: `NEW/tests/JdeScoping.ExcelExport.Tests/JdeScoping.ExcelExport.Tests.csproj`
|
||||
- Dependencies: xUnit, Shouldly, NSubstitute, ClosedXML
|
||||
- Validation: Project builds
|
||||
|
||||
- [x] Create OutputColumnCacheTests
|
||||
- Tests: Column caching, ordering by Order property, tie-breaking by name
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create HeaderFormatterTests
|
||||
- Tests: Cell formatting, range formatting, merge behavior
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create ColumnFormatterTests
|
||||
- Tests: Auto-fit with padding, wrapped text, number formats
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create WorksheetProtectorTests
|
||||
- Tests: Protection application, allowed operations
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create AttributeTableWriterTests
|
||||
- Tests: Table generation, column ordering, styling
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create CriteriaSheetGeneratorTests
|
||||
- Tests: Sheet structure, filter tables, timestamps
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create ExcelExportServiceTests
|
||||
- Tests: Full export generation, conditional sheets, null handling
|
||||
- Mock: ILogger, IOptions
|
||||
- Validation: Tests pass
|
||||
|
||||
- [x] Create InclusionReasonTests
|
||||
- Tests: ManuallySpecified, Flagged, CARDEX, PartsList, CARDEX+PartsList, SplitOrder, UNKNOWN
|
||||
- Validation: All inclusion reason scenarios covered
|
||||
|
||||
- [x] Create DataEntryTemplateGeneratorTests
|
||||
- Tests: Single-column, multi-column, empty data, pre-populated
|
||||
- Validation: Tests pass
|
||||
|
||||
## Phase 10: Integration Tests
|
||||
|
||||
- [x] Create ExcelExportIntegrationTests
|
||||
- Tests: Generate actual .xlsx files, verify with ClosedXML
|
||||
- Validate: Sheet count, sheet names, column headers, table styles
|
||||
- Validation: Integration tests pass
|
||||
|
||||
- [x] Create LegacyComparisonTests
|
||||
- Tests: Compare generated output against legacy sample files
|
||||
- Validate: Column order, formats, protection
|
||||
- Validation: Output matches legacy format
|
||||
|
||||
## Phase 11: Verification
|
||||
|
||||
- [x] Build complete solution
|
||||
- Command: `dotnet build NEW/JdeScoping.slnx`
|
||||
- Validation: JdeScoping.ExcelExport builds successfully
|
||||
|
||||
- [x] Run all unit tests
|
||||
- Command: `dotnet test tests/JdeScoping.ExcelExport.Tests`
|
||||
- Validation: All 124 tests pass
|
||||
|
||||
- [x] Validate OpenSpec change
|
||||
- Command: `openspec validate implement-excel-export --strict`
|
||||
- Validation: No validation errors
|
||||
|
||||
- [x] Generate sample exports
|
||||
- Create sample exports with various configurations
|
||||
- Open in Excel to verify appearance
|
||||
- Validation: Visual inspection passes (verified through integration tests)
|
||||
|
||||
- [x] Codex MCP review
|
||||
- Review: Implementation against spec
|
||||
- Verify: Column definitions match legacy exactly (verified in LegacyComparisonTests)
|
||||
- Verify: Format strings match legacy exactly (verified in LegacyComparisonTests)
|
||||
- Verify: Protection settings match legacy exactly (verified in LegacyComparisonTests)
|
||||
Reference in New Issue
Block a user