Files
ExportDXF/docs/plans/2026-02-17-fabworks-api.md
T
2026-02-20 08:54:21 -05:00

1192 lines
39 KiB
Markdown

# FabWorks.Api Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Create a standalone ASP.NET Web API (`FabWorks.Api`) that owns all fabrication data — export records, BOM items, cut templates, form programs — so ExportDXF and other tools communicate via HTTP instead of direct database access.
**Architecture:** FabWorks.Api is a minimal ASP.NET Web API targeting net8.0. It uses EF Core with SQL Server (same database ExportDXF already uses). A shared class library (`FabWorks.Core`) holds the entity models and DbContext so both the API and ExportDXF can share the schema during the migration period. CincyLib's press brake models are copied into FabWorks.Core as a net8.0 port (just the PressBrake folder — pure XML parsing, no dependencies). The API exposes RESTful endpoints for export records, BOM items, cut templates, and form programs.
**Tech Stack:** ASP.NET Web API (net8.0), EF Core 8 + SQL Server, CincyLib PressBrake parser (ported to net8.0), xUnit for tests
---
## Project Layout
```
ExportDXF/ (solution root)
├── ExportDXF.sln (add new projects here)
├── ExportDXF/ (existing WinForms app)
├── FabWorks.Core/ ← NEW shared library
│ ├── FabWorks.Core.csproj
│ ├── Data/
│ │ └── FabWorksDbContext.cs
│ ├── Models/
│ │ ├── ExportRecord.cs
│ │ ├── BomItem.cs
│ │ ├── CutTemplate.cs
│ │ └── FormProgram.cs ← NEW entity
│ └── PressBrake/ ← Ported from CincyLib
│ ├── Program.cs
│ ├── ProgramReader.cs
│ ├── Step.cs
│ ├── ToolSetup.cs
│ ├── SegEntry.cs
│ ├── MatType.cs
│ └── Extensions.cs
├── FabWorks.Api/ ← NEW web API
│ ├── FabWorks.Api.csproj
│ ├── Program.cs
│ ├── Controllers/
│ │ ├── ExportsController.cs
│ │ ├── BomItemsController.cs
│ │ └── FormProgramsController.cs
│ ├── DTOs/
│ │ ├── CreateExportRequest.cs
│ │ ├── ExportDetailDto.cs
│ │ ├── BomItemDto.cs
│ │ └── FormProgramDto.cs
│ └── Services/
│ └── FormProgramService.cs
└── FabWorks.Tests/ ← NEW test project
├── FabWorks.Tests.csproj
├── FormProgramServiceTests.cs
├── ExportsControllerTests.cs
└── PressBrake/
└── ProgramReaderTests.cs
```
---
## Task 1: Create FabWorks.Core with Shared Models
**Files:**
- Create: `FabWorks.Core/FabWorks.Core.csproj`
- Create: `FabWorks.Core/Models/ExportRecord.cs`
- Create: `FabWorks.Core/Models/BomItem.cs`
- Create: `FabWorks.Core/Models/CutTemplate.cs`
- Create: `FabWorks.Core/Models/FormProgram.cs`
- Create: `FabWorks.Core/Data/FabWorksDbContext.cs`
**Step 1: Create the class library project**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF
dotnet new classlib -n FabWorks.Core --framework net8.0
dotnet sln ExportDXF.sln add FabWorks.Core/FabWorks.Core.csproj
```
**Step 2: Add EF Core package to FabWorks.Core**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF\FabWorks.Core
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 8.0.11
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 8.0.11
```
**Step 3: Create entity models**
Move/recreate the existing entity models from `ExportDXF/Models/` into `FabWorks.Core/Models/` under namespace `FabWorks.Core.Models`. Keep the exact same schema so the existing database is compatible.
`FabWorks.Core/Models/ExportRecord.cs`:
```csharp
using System;
using System.Collections.Generic;
namespace FabWorks.Core.Models
{
public class ExportRecord
{
public int Id { get; set; }
public string DrawingNumber { get; set; }
public string SourceFilePath { get; set; }
public string OutputFolder { get; set; }
public DateTime ExportedAt { get; set; }
public string ExportedBy { get; set; }
public string PdfContentHash { get; set; }
public virtual ICollection<BomItem> BomItems { get; set; } = new List<BomItem>();
}
}
```
`FabWorks.Core/Models/BomItem.cs`:
```csharp
namespace FabWorks.Core.Models
{
public class BomItem
{
public int ID { get; set; }
public string ItemNo { get; set; } = "";
public string PartNo { get; set; } = "";
public int SortOrder { get; set; }
public int? Qty { get; set; }
public int? TotalQty { get; set; }
public string Description { get; set; } = "";
public string PartName { get; set; } = "";
public string ConfigurationName { get; set; } = "";
public string Material { get; set; } = "";
public int ExportRecordId { get; set; }
public virtual ExportRecord ExportRecord { get; set; }
public virtual CutTemplate CutTemplate { get; set; }
public virtual FormProgram FormProgram { get; set; }
}
}
```
`FabWorks.Core/Models/CutTemplate.cs`:
```csharp
using System;
namespace FabWorks.Core.Models
{
public class CutTemplate
{
public int Id { get; set; }
public string DxfFilePath { get; set; } = "";
public string ContentHash { get; set; }
public string CutTemplateName { get; set; } = "";
private double? _thickness;
public double? Thickness
{
get => _thickness;
set => _thickness = value.HasValue ? Math.Round(value.Value, 8) : null;
}
public double? KFactor { get; set; }
private double? _defaultBendRadius;
public double? DefaultBendRadius
{
get => _defaultBendRadius;
set => _defaultBendRadius = value.HasValue ? Math.Round(value.Value, 8) : null;
}
public int BomItemId { get; set; }
public virtual BomItem BomItem { get; set; }
}
}
```
`FabWorks.Core/Models/FormProgram.cs` (NEW):
```csharp
namespace FabWorks.Core.Models
{
public class FormProgram
{
public int Id { get; set; }
public string ProgramFilePath { get; set; } = "";
public string ContentHash { get; set; }
public string ProgramName { get; set; } = "";
public double? Thickness { get; set; }
public string MaterialType { get; set; } = "";
public double? KFactor { get; set; }
public int BendCount { get; set; }
public string UpperToolNames { get; set; } = "";
public string LowerToolNames { get; set; } = "";
public string SetupNotes { get; set; } = "";
public int BomItemId { get; set; }
public virtual BomItem BomItem { get; set; }
}
}
```
**Step 4: Create FabWorksDbContext**
`FabWorks.Core/Data/FabWorksDbContext.cs`:
```csharp
using FabWorks.Core.Models;
using Microsoft.EntityFrameworkCore;
namespace FabWorks.Core.Data
{
public class FabWorksDbContext : DbContext
{
public DbSet<ExportRecord> ExportRecords { get; set; }
public DbSet<BomItem> BomItems { get; set; }
public DbSet<CutTemplate> CutTemplates { get; set; }
public DbSet<FormProgram> FormPrograms { get; set; }
public FabWorksDbContext(DbContextOptions<FabWorksDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ExportRecord>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.DrawingNumber).HasMaxLength(100);
entity.Property(e => e.SourceFilePath).HasMaxLength(500);
entity.Property(e => e.OutputFolder).HasMaxLength(500);
entity.Property(e => e.ExportedBy).HasMaxLength(100);
entity.Property(e => e.PdfContentHash).HasMaxLength(64);
entity.HasMany(e => e.BomItems)
.WithOne(b => b.ExportRecord)
.HasForeignKey(b => b.ExportRecordId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<BomItem>(entity =>
{
entity.HasKey(e => e.ID);
entity.Property(e => e.ItemNo).HasMaxLength(50);
entity.Property(e => e.PartNo).HasMaxLength(100);
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.PartName).HasMaxLength(200);
entity.Property(e => e.ConfigurationName).HasMaxLength(100);
entity.Property(e => e.Material).HasMaxLength(100);
entity.HasOne(e => e.CutTemplate)
.WithOne(ct => ct.BomItem)
.HasForeignKey<CutTemplate>(ct => ct.BomItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.FormProgram)
.WithOne(fp => fp.BomItem)
.HasForeignKey<FormProgram>(fp => fp.BomItemId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<CutTemplate>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.DxfFilePath).HasMaxLength(500);
entity.Property(e => e.CutTemplateName).HasMaxLength(100);
entity.Property(e => e.ContentHash).HasMaxLength(64);
});
modelBuilder.Entity<FormProgram>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.ProgramFilePath).HasMaxLength(500);
entity.Property(e => e.ContentHash).HasMaxLength(64);
entity.Property(e => e.ProgramName).HasMaxLength(200);
entity.Property(e => e.MaterialType).HasMaxLength(50);
entity.Property(e => e.UpperToolNames).HasMaxLength(500);
entity.Property(e => e.LowerToolNames).HasMaxLength(500);
entity.Property(e => e.SetupNotes).HasMaxLength(2000);
});
}
}
}
```
**Step 5: Delete auto-generated Class1.cs and build**
```bash
del FabWorks.Core\Class1.cs
dotnet build FabWorks.Core/FabWorks.Core.csproj
```
**Step 6: Commit**
```bash
git add FabWorks.Core/
git commit -m "feat: add FabWorks.Core shared library with entity models and FormProgram"
```
---
## Task 2: Port CincyLib PressBrake Parser into FabWorks.Core
**Files:**
- Create: `FabWorks.Core/PressBrake/Program.cs`
- Create: `FabWorks.Core/PressBrake/ProgramReader.cs`
- Create: `FabWorks.Core/PressBrake/Step.cs`
- Create: `FabWorks.Core/PressBrake/ToolSetup.cs`
- Create: `FabWorks.Core/PressBrake/SegEntry.cs`
- Create: `FabWorks.Core/PressBrake/MatType.cs`
- Create: `FabWorks.Core/PressBrake/Extensions.cs`
**Step 1: Copy CincyLib PressBrake files**
Copy the following files from `C:\Users\aisaacs\Desktop\Projects\CincyLib\CincyLib\PressBrake\` into `FabWorks.Core\PressBrake\`:
- `Program.cs`
- `ProgramReader.cs`
- `Step.cs`
- `ToolSetup.cs`
- `SegEntry.cs`
- `MatType.cs`
Also copy `C:\Users\aisaacs\Desktop\Projects\CincyLib\CincyLib\Extensions.cs` into `FabWorks.Core\PressBrake\Extensions.cs`.
**Step 2: Update namespaces**
Change all `namespace CincyLib.PressBrake` to `namespace FabWorks.Core.PressBrake`.
Change `namespace CincyLib` (in Extensions.cs) to `namespace FabWorks.Core.PressBrake`.
No other code changes needed — these files only use `System`, `System.IO`, `System.Linq`, `System.Xml.Linq`, `System.Collections.Generic`, all available in net8.0.
**Step 3: Build to verify port**
```bash
dotnet build FabWorks.Core/FabWorks.Core.csproj
```
**Step 4: Commit**
```bash
git add FabWorks.Core/PressBrake/
git commit -m "feat: port CincyLib PressBrake parser to FabWorks.Core (net8.0)"
```
---
## Task 3: Create FabWorks.Tests and Validate PressBrake Parser
**Files:**
- Create: `FabWorks.Tests/FabWorks.Tests.csproj`
- Create: `FabWorks.Tests/PressBrake/ProgramReaderTests.cs`
- Create: `FabWorks.Tests/TestData/` (copy a sample .pgm file)
**Step 1: Create test project**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF
dotnet new xunit -n FabWorks.Tests --framework net8.0
dotnet sln ExportDXF.sln add FabWorks.Tests/FabWorks.Tests.csproj
cd FabWorks.Tests
dotnet add reference ../FabWorks.Core/FabWorks.Core.csproj
```
**Step 2: Copy test data**
Copy a sample `.pgm` file into `FabWorks.Tests/TestData/sample.pgm`. Use the file from `S:\4980 GMCH Lockport\4980 A05 Degreaser Casing\4980 A05-1 Access door\Forms\4980 A05-1 PT02.pgm`. Add it to the csproj as `<Content Include="TestData\**" CopyToOutputDirectory="PreserveNewest" />`.
**Step 3: Write ProgramReader test**
`FabWorks.Tests/PressBrake/ProgramReaderTests.cs`:
```csharp
using FabWorks.Core.PressBrake;
using Xunit;
namespace FabWorks.Tests.PressBrake
{
public class ProgramReaderTests
{
[Fact]
public void Load_SamplePgm_ParsesProgramName()
{
var pgm = Program.Load("TestData/sample.pgm");
Assert.False(string.IsNullOrEmpty(pgm.ProgName));
}
[Fact]
public void Load_SamplePgm_ParsesThickness()
{
var pgm = Program.Load("TestData/sample.pgm");
Assert.True(pgm.MatThick > 0);
}
[Fact]
public void Load_SamplePgm_ParsesSteps()
{
var pgm = Program.Load("TestData/sample.pgm");
Assert.NotEmpty(pgm.Steps);
}
[Fact]
public void Load_SamplePgm_ParsesToolSetups()
{
var pgm = Program.Load("TestData/sample.pgm");
Assert.NotEmpty(pgm.UpperToolSets);
Assert.NotEmpty(pgm.LowerToolSets);
}
[Fact]
public void Load_SamplePgm_ResolvesStepToolReferences()
{
var pgm = Program.Load("TestData/sample.pgm");
var step = pgm.Steps[0];
Assert.NotNull(step.UpperTool);
Assert.NotNull(step.LowerTool);
}
}
}
```
**Step 4: Run tests**
```bash
dotnet test FabWorks.Tests/FabWorks.Tests.csproj -v normal
```
Expected: All 5 tests PASS.
**Step 5: Commit**
```bash
git add FabWorks.Tests/
git commit -m "test: add ProgramReader tests validating CincyLib port"
```
---
## Task 4: Create FabWorks.Api Project with Exports Endpoint
**Files:**
- Create: `FabWorks.Api/FabWorks.Api.csproj`
- Create: `FabWorks.Api/Program.cs`
- Create: `FabWorks.Api/Controllers/ExportsController.cs`
- Create: `FabWorks.Api/DTOs/CreateExportRequest.cs`
- Create: `FabWorks.Api/DTOs/ExportDetailDto.cs`
**Step 1: Create web API project**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF
dotnet new webapi -n FabWorks.Api --framework net8.0 --no-openapi
dotnet sln ExportDXF.sln add FabWorks.Api/FabWorks.Api.csproj
cd FabWorks.Api
dotnet add reference ../FabWorks.Core/FabWorks.Core.csproj
```
**Step 2: Configure Program.cs with EF Core**
`FabWorks.Api/Program.cs`:
```csharp
using FabWorks.Core.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<FabWorksDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("FabWorksDb")));
var app = builder.Build();
app.MapControllers();
app.Run();
```
Add connection string to `appsettings.json`:
```json
{
"ConnectionStrings": {
"FabWorksDb": "Server=localhost;Database=ExportDxfDb;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
```
Note: Points at the same `ExportDxfDb` database. Same schema, just adding the FormPrograms table.
**Step 3: Create DTOs**
`FabWorks.Api/DTOs/CreateExportRequest.cs`:
```csharp
namespace FabWorks.Api.DTOs
{
public class CreateExportRequest
{
public string DrawingNumber { get; set; }
public string SourceFilePath { get; set; }
public string OutputFolder { get; set; }
}
}
```
`FabWorks.Api/DTOs/ExportDetailDto.cs`:
```csharp
using System;
using System.Collections.Generic;
namespace FabWorks.Api.DTOs
{
public class ExportDetailDto
{
public int Id { get; set; }
public string DrawingNumber { get; set; }
public string SourceFilePath { get; set; }
public string OutputFolder { get; set; }
public DateTime ExportedAt { get; set; }
public string ExportedBy { get; set; }
public string PdfContentHash { get; set; }
public List<BomItemDto> BomItems { get; set; } = new();
}
public class BomItemDto
{
public int ID { get; set; }
public string ItemNo { get; set; }
public string PartNo { get; set; }
public int SortOrder { get; set; }
public int? Qty { get; set; }
public int? TotalQty { get; set; }
public string Description { get; set; }
public string PartName { get; set; }
public string ConfigurationName { get; set; }
public string Material { get; set; }
public CutTemplateDto CutTemplate { get; set; }
public FormProgramDto FormProgram { get; set; }
}
public class CutTemplateDto
{
public int Id { get; set; }
public string DxfFilePath { get; set; }
public string ContentHash { get; set; }
public double? Thickness { get; set; }
public double? KFactor { get; set; }
public double? DefaultBendRadius { get; set; }
}
public class FormProgramDto
{
public int Id { get; set; }
public string ProgramFilePath { get; set; }
public string ContentHash { get; set; }
public string ProgramName { get; set; }
public double? Thickness { get; set; }
public string MaterialType { get; set; }
public double? KFactor { get; set; }
public int BendCount { get; set; }
public string UpperToolNames { get; set; }
public string LowerToolNames { get; set; }
public string SetupNotes { get; set; }
}
}
```
**Step 4: Create ExportsController**
`FabWorks.Api/Controllers/ExportsController.cs`:
```csharp
using FabWorks.Api.DTOs;
using FabWorks.Core.Data;
using FabWorks.Core.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FabWorks.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ExportsController : ControllerBase
{
private readonly FabWorksDbContext _db;
public ExportsController(FabWorksDbContext db)
{
_db = db;
}
[HttpPost]
public async Task<ActionResult<ExportDetailDto>> Create(CreateExportRequest request)
{
var record = new ExportRecord
{
DrawingNumber = request.DrawingNumber,
SourceFilePath = request.SourceFilePath,
OutputFolder = request.OutputFolder,
ExportedAt = DateTime.Now,
ExportedBy = Environment.UserName
};
_db.ExportRecords.Add(record);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = record.Id }, MapToDto(record));
}
[HttpGet("{id}")]
public async Task<ActionResult<ExportDetailDto>> GetById(int id)
{
var record = await _db.ExportRecords
.Include(r => r.BomItems).ThenInclude(b => b.CutTemplate)
.Include(r => r.BomItems).ThenInclude(b => b.FormProgram)
.FirstOrDefaultAsync(r => r.Id == id);
if (record == null) return NotFound();
return MapToDto(record);
}
[HttpGet("by-source")]
public async Task<ActionResult<ExportDetailDto>> GetBySourceFile([FromQuery] string path)
{
var record = await _db.ExportRecords
.Where(r => r.SourceFilePath.ToLower() == path.ToLower()
&& !string.IsNullOrEmpty(r.DrawingNumber))
.OrderByDescending(r => r.Id)
.FirstOrDefaultAsync();
if (record == null) return NotFound();
return MapToDto(record);
}
[HttpGet("by-drawing")]
public async Task<ActionResult<List<ExportDetailDto>>> GetByDrawing([FromQuery] string drawingNumber)
{
var records = await _db.ExportRecords
.Include(r => r.BomItems).ThenInclude(b => b.CutTemplate)
.Include(r => r.BomItems).ThenInclude(b => b.FormProgram)
.Where(r => r.DrawingNumber == drawingNumber)
.OrderByDescending(r => r.ExportedAt)
.ToListAsync();
return records.Select(MapToDto).ToList();
}
[HttpGet("next-item-number")]
public async Task<ActionResult<string>> GetNextItemNumber([FromQuery] string drawingNumber)
{
if (string.IsNullOrEmpty(drawingNumber)) return "1";
var existingItems = await _db.ExportRecords
.Where(r => r.DrawingNumber == drawingNumber)
.SelectMany(r => r.BomItems)
.Select(b => b.ItemNo)
.ToListAsync();
int maxNum = 0;
foreach (var itemNo in existingItems)
{
if (int.TryParse(itemNo, out var num) && num > maxNum)
maxNum = num;
}
return (maxNum + 1).ToString();
}
private static ExportDetailDto MapToDto(ExportRecord r) => new()
{
Id = r.Id,
DrawingNumber = r.DrawingNumber,
SourceFilePath = r.SourceFilePath,
OutputFolder = r.OutputFolder,
ExportedAt = r.ExportedAt,
ExportedBy = r.ExportedBy,
PdfContentHash = r.PdfContentHash,
BomItems = r.BomItems?.Select(b => new BomItemDto
{
ID = b.ID,
ItemNo = b.ItemNo,
PartNo = b.PartNo,
SortOrder = b.SortOrder,
Qty = b.Qty,
TotalQty = b.TotalQty,
Description = b.Description,
PartName = b.PartName,
ConfigurationName = b.ConfigurationName,
Material = b.Material,
CutTemplate = b.CutTemplate == null ? null : new CutTemplateDto
{
Id = b.CutTemplate.Id,
DxfFilePath = b.CutTemplate.DxfFilePath,
ContentHash = b.CutTemplate.ContentHash,
Thickness = b.CutTemplate.Thickness,
KFactor = b.CutTemplate.KFactor,
DefaultBendRadius = b.CutTemplate.DefaultBendRadius
},
FormProgram = b.FormProgram == null ? null : new FormProgramDto
{
Id = b.FormProgram.Id,
ProgramFilePath = b.FormProgram.ProgramFilePath,
ContentHash = b.FormProgram.ContentHash,
ProgramName = b.FormProgram.ProgramName,
Thickness = b.FormProgram.Thickness,
MaterialType = b.FormProgram.MaterialType,
KFactor = b.FormProgram.KFactor,
BendCount = b.FormProgram.BendCount,
UpperToolNames = b.FormProgram.UpperToolNames,
LowerToolNames = b.FormProgram.LowerToolNames,
SetupNotes = b.FormProgram.SetupNotes
}
}).ToList() ?? new()
};
}
}
```
**Step 5: Clean up auto-generated files, build**
Delete the auto-generated `WeatherForecast.cs` and `Controllers/WeatherForecastController.cs` if present.
```bash
dotnet build FabWorks.Api/FabWorks.Api.csproj
```
**Step 6: Commit**
```bash
git add FabWorks.Api/
git commit -m "feat: add FabWorks.Api with ExportsController and DTOs"
```
---
## Task 5: Add BomItems and FormPrograms Controllers
**Files:**
- Create: `FabWorks.Api/Controllers/BomItemsController.cs`
- Create: `FabWorks.Api/Controllers/FormProgramsController.cs`
- Create: `FabWorks.Api/Services/FormProgramService.cs`
**Step 1: Create FormProgramService (wraps CincyLib parser)**
`FabWorks.Api/Services/FormProgramService.cs`:
```csharp
using FabWorks.Core.Models;
using FabWorks.Core.PressBrake;
using System.Security.Cryptography;
namespace FabWorks.Api.Services
{
public class FormProgramService
{
/// <summary>
/// Parse a .pgm file and return a FormProgram entity with metadata populated.
/// </summary>
public FormProgram ParseFromFile(string filePath)
{
var pgm = Program.Load(filePath);
var hash = ComputeFileHash(filePath);
return new FormProgram
{
ProgramFilePath = filePath,
ContentHash = hash,
ProgramName = pgm.ProgName ?? "",
Thickness = pgm.MatThick > 0 ? pgm.MatThick : null,
MaterialType = pgm.MatType.ToString(),
KFactor = pgm.KFactor > 0 ? pgm.KFactor : null,
BendCount = pgm.Steps.Count,
UpperToolNames = string.Join(", ", pgm.UpperToolSets
.Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n)).Distinct()),
LowerToolNames = string.Join(", ", pgm.LowerToolSets
.Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n)).Distinct()),
SetupNotes = pgm.SetupNotes ?? ""
};
}
private static string ComputeFileHash(string filePath)
{
using var sha = SHA256.Create();
using var stream = File.OpenRead(filePath);
var bytes = sha.ComputeHash(stream);
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
}
}
}
```
**Step 2: Register FormProgramService in DI**
In `FabWorks.Api/Program.cs`, add:
```csharp
builder.Services.AddSingleton<FormProgramService>();
```
**Step 3: Create BomItemsController**
`FabWorks.Api/Controllers/BomItemsController.cs`:
```csharp
using FabWorks.Api.DTOs;
using FabWorks.Core.Data;
using FabWorks.Core.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FabWorks.Api.Controllers
{
[ApiController]
[Route("api/exports/{exportId}/bom-items")]
public class BomItemsController : ControllerBase
{
private readonly FabWorksDbContext _db;
public BomItemsController(FabWorksDbContext db) => _db = db;
[HttpGet]
public async Task<ActionResult<List<BomItemDto>>> GetByExport(int exportId)
{
var items = await _db.BomItems
.Include(b => b.CutTemplate)
.Include(b => b.FormProgram)
.Where(b => b.ExportRecordId == exportId)
.OrderBy(b => b.SortOrder)
.ToListAsync();
return items.Select(MapToDto).ToList();
}
[HttpPost]
public async Task<ActionResult<BomItemDto>> Create(int exportId, BomItemDto dto)
{
var export = await _db.ExportRecords.FindAsync(exportId);
if (export == null) return NotFound("Export record not found");
var item = new BomItem
{
ExportRecordId = exportId,
ItemNo = dto.ItemNo ?? "",
PartNo = dto.PartNo ?? "",
SortOrder = dto.SortOrder,
Qty = dto.Qty,
TotalQty = dto.TotalQty,
Description = dto.Description ?? "",
PartName = dto.PartName ?? "",
ConfigurationName = dto.ConfigurationName ?? "",
Material = dto.Material ?? ""
};
if (dto.CutTemplate != null)
{
item.CutTemplate = new CutTemplate
{
DxfFilePath = dto.CutTemplate.DxfFilePath ?? "",
ContentHash = dto.CutTemplate.ContentHash,
Thickness = dto.CutTemplate.Thickness,
KFactor = dto.CutTemplate.KFactor,
DefaultBendRadius = dto.CutTemplate.DefaultBendRadius
};
}
if (dto.FormProgram != null)
{
item.FormProgram = new FormProgram
{
ProgramFilePath = dto.FormProgram.ProgramFilePath ?? "",
ContentHash = dto.FormProgram.ContentHash,
ProgramName = dto.FormProgram.ProgramName ?? "",
Thickness = dto.FormProgram.Thickness,
MaterialType = dto.FormProgram.MaterialType ?? "",
KFactor = dto.FormProgram.KFactor,
BendCount = dto.FormProgram.BendCount,
UpperToolNames = dto.FormProgram.UpperToolNames ?? "",
LowerToolNames = dto.FormProgram.LowerToolNames ?? "",
SetupNotes = dto.FormProgram.SetupNotes ?? ""
};
}
_db.BomItems.Add(item);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetByExport), new { exportId }, MapToDto(item));
}
private static BomItemDto MapToDto(BomItem b) => new()
{
ID = b.ID,
ItemNo = b.ItemNo,
PartNo = b.PartNo,
SortOrder = b.SortOrder,
Qty = b.Qty,
TotalQty = b.TotalQty,
Description = b.Description,
PartName = b.PartName,
ConfigurationName = b.ConfigurationName,
Material = b.Material,
CutTemplate = b.CutTemplate == null ? null : new CutTemplateDto
{
Id = b.CutTemplate.Id,
DxfFilePath = b.CutTemplate.DxfFilePath,
ContentHash = b.CutTemplate.ContentHash,
Thickness = b.CutTemplate.Thickness,
KFactor = b.CutTemplate.KFactor,
DefaultBendRadius = b.CutTemplate.DefaultBendRadius
},
FormProgram = b.FormProgram == null ? null : new FormProgramDto
{
Id = b.FormProgram.Id,
ProgramFilePath = b.FormProgram.ProgramFilePath,
ContentHash = b.FormProgram.ContentHash,
ProgramName = b.FormProgram.ProgramName,
Thickness = b.FormProgram.Thickness,
MaterialType = b.FormProgram.MaterialType,
KFactor = b.FormProgram.KFactor,
BendCount = b.FormProgram.BendCount,
UpperToolNames = b.FormProgram.UpperToolNames,
LowerToolNames = b.FormProgram.LowerToolNames,
SetupNotes = b.FormProgram.SetupNotes
}
};
}
}
```
**Step 4: Create FormProgramsController**
`FabWorks.Api/Controllers/FormProgramsController.cs`:
```csharp
using FabWorks.Api.DTOs;
using FabWorks.Api.Services;
using FabWorks.Core.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FabWorks.Api.Controllers
{
[ApiController]
[Route("api/form-programs")]
public class FormProgramsController : ControllerBase
{
private readonly FabWorksDbContext _db;
private readonly FormProgramService _formService;
public FormProgramsController(FabWorksDbContext db, FormProgramService formService)
{
_db = db;
_formService = formService;
}
[HttpGet("by-drawing")]
public async Task<ActionResult<List<FormProgramDto>>> GetByDrawing([FromQuery] string drawingNumber)
{
var programs = await _db.FormPrograms
.Include(fp => fp.BomItem)
.ThenInclude(b => b.ExportRecord)
.Where(fp => fp.BomItem.ExportRecord.DrawingNumber == drawingNumber)
.ToListAsync();
return programs.Select(fp => new FormProgramDto
{
Id = fp.Id,
ProgramFilePath = fp.ProgramFilePath,
ContentHash = fp.ContentHash,
ProgramName = fp.ProgramName,
Thickness = fp.Thickness,
MaterialType = fp.MaterialType,
KFactor = fp.KFactor,
BendCount = fp.BendCount,
UpperToolNames = fp.UpperToolNames,
LowerToolNames = fp.LowerToolNames,
SetupNotes = fp.SetupNotes
}).ToList();
}
[HttpPost("parse")]
public ActionResult<FormProgramDto> Parse([FromQuery] string filePath)
{
if (!System.IO.File.Exists(filePath))
return NotFound($"File not found: {filePath}");
var fp = _formService.ParseFromFile(filePath);
return new FormProgramDto
{
ProgramFilePath = fp.ProgramFilePath,
ContentHash = fp.ContentHash,
ProgramName = fp.ProgramName,
Thickness = fp.Thickness,
MaterialType = fp.MaterialType,
KFactor = fp.KFactor,
BendCount = fp.BendCount,
UpperToolNames = fp.UpperToolNames,
LowerToolNames = fp.LowerToolNames,
SetupNotes = fp.SetupNotes
};
}
[HttpPost("{bomItemId}")]
public async Task<ActionResult<FormProgramDto>> AttachToItem(int bomItemId, [FromQuery] string filePath)
{
var bomItem = await _db.BomItems
.Include(b => b.FormProgram)
.FirstOrDefaultAsync(b => b.ID == bomItemId);
if (bomItem == null) return NotFound("BOM item not found");
if (!System.IO.File.Exists(filePath))
return NotFound($"File not found: {filePath}");
var fp = _formService.ParseFromFile(filePath);
fp.BomItemId = bomItemId;
// Replace existing if present
if (bomItem.FormProgram != null)
_db.FormPrograms.Remove(bomItem.FormProgram);
bomItem.FormProgram = fp;
await _db.SaveChangesAsync();
return new FormProgramDto
{
Id = fp.Id,
ProgramFilePath = fp.ProgramFilePath,
ContentHash = fp.ContentHash,
ProgramName = fp.ProgramName,
Thickness = fp.Thickness,
MaterialType = fp.MaterialType,
KFactor = fp.KFactor,
BendCount = fp.BendCount,
UpperToolNames = fp.UpperToolNames,
LowerToolNames = fp.LowerToolNames,
SetupNotes = fp.SetupNotes
};
}
}
}
```
**Step 5: Build**
```bash
dotnet build FabWorks.Api/FabWorks.Api.csproj
```
**Step 6: Commit**
```bash
git add FabWorks.Api/
git commit -m "feat: add BomItems and FormPrograms controllers with parse service"
```
---
## Task 6: Add EF Core Migration for FormPrograms Table
**Step 1: Add migration from FabWorks.Api**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF\FabWorks.Api
dotnet ef migrations add AddFormPrograms --project ../FabWorks.Core/FabWorks.Core.csproj --startup-project .
```
Note: If the migration tooling complains about the existing tables, we may need a baseline migration. The existing ExportDxfDb already has ExportRecords, BomItems, CutTemplates tables. The migration should only add the FormPrograms table and the FK on BomItems.
**Step 2: Apply migration**
```bash
dotnet ef database update --project ../FabWorks.Core/FabWorks.Core.csproj --startup-project .
```
**Step 3: Commit**
```bash
git add FabWorks.Core/Migrations/
git commit -m "feat: add EF migration for FormPrograms table"
```
---
## Task 7: Add FormProgramService Tests
**Files:**
- Create: `FabWorks.Tests/FormProgramServiceTests.cs`
**Step 1: Write tests**
`FabWorks.Tests/FormProgramServiceTests.cs`:
```csharp
using FabWorks.Api.Services;
using Xunit;
namespace FabWorks.Tests
{
public class FormProgramServiceTests
{
[Fact]
public void ParseFromFile_SamplePgm_PopulatesProgramName()
{
var service = new FormProgramService();
var fp = service.ParseFromFile("TestData/sample.pgm");
Assert.False(string.IsNullOrEmpty(fp.ProgramName));
}
[Fact]
public void ParseFromFile_SamplePgm_PopulatesThickness()
{
var service = new FormProgramService();
var fp = service.ParseFromFile("TestData/sample.pgm");
Assert.NotNull(fp.Thickness);
Assert.True(fp.Thickness > 0);
}
[Fact]
public void ParseFromFile_SamplePgm_PopulatesBendCount()
{
var service = new FormProgramService();
var fp = service.ParseFromFile("TestData/sample.pgm");
Assert.True(fp.BendCount > 0);
}
[Fact]
public void ParseFromFile_SamplePgm_PopulatesToolNames()
{
var service = new FormProgramService();
var fp = service.ParseFromFile("TestData/sample.pgm");
Assert.False(string.IsNullOrEmpty(fp.UpperToolNames));
Assert.False(string.IsNullOrEmpty(fp.LowerToolNames));
}
[Fact]
public void ParseFromFile_SamplePgm_ComputesContentHash()
{
var service = new FormProgramService();
var fp = service.ParseFromFile("TestData/sample.pgm");
Assert.NotNull(fp.ContentHash);
Assert.Equal(64, fp.ContentHash.Length); // SHA256 hex = 64 chars
}
}
}
```
**Step 2: Add project reference to FabWorks.Api**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF\FabWorks.Tests
dotnet add reference ../FabWorks.Api/FabWorks.Api.csproj
```
**Step 3: Run all tests**
```bash
dotnet test FabWorks.Tests/FabWorks.Tests.csproj -v normal
```
Expected: All 10 tests PASS.
**Step 4: Commit**
```bash
git add FabWorks.Tests/
git commit -m "test: add FormProgramService tests"
```
---
## Task 8: Smoke Test — Run API and Hit Endpoints
**Step 1: Run the API**
```bash
cd C:\Users\aisaacs\Desktop\Projects\ExportDXF\FabWorks.Api
dotnet run
```
**Step 2: Test endpoints with curl/Invoke-WebRequest**
```powershell
# Create export record
Invoke-RestMethod -Uri "http://localhost:5000/api/exports" -Method Post -ContentType "application/json" -Body '{"drawingNumber":"4980 A05-1","sourceFilePath":"C:\\test.slddrw","outputFolder":"C:\\output"}'
# Get by ID
Invoke-RestMethod -Uri "http://localhost:5000/api/exports/1"
# Lookup by source file
Invoke-RestMethod -Uri "http://localhost:5000/api/exports/by-source?path=C:\test.slddrw"
# Parse a .pgm file
Invoke-RestMethod -Uri "http://localhost:5000/api/form-programs/parse?filePath=S:\4980 GMCH Lockport\4980 A05 Degreaser Casing\4980 A05-1 Access door\Forms\4980 A05-1 PT02.pgm"
```
**Step 3: Verify JSON responses look correct**
**Step 4: Commit any fixes needed**
---
## Future Tasks (Not In This Plan)
These are follow-up items after the API is proven out:
1. **Wire ExportDXF to call FabWorks.Api** — Add `IFabWorksClient` interface, replace `_dbContextFactory` usage in `DxfExportService` with HTTP calls
2. **Deploy as Windows Service** — Add `Microsoft.Extensions.Hosting.WindowsServices`, create `deploy.ps1` per deploy script guide
3. **Add Swagger/OpenAPI** — For discoverability
4. **LaserQuote integration** — Point LaserQuote at FabWorks.Api for BOM/material lookups
5. **Batch form program scanner** — Endpoint to scan a directory tree and auto-link .pgm files to BOM items by naming convention