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

39 KiB

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

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

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:

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:

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:

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):

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:

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

del FabWorks.Core\Class1.cs
dotnet build FabWorks.Core/FabWorks.Core.csproj

Step 6: Commit

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

dotnet build FabWorks.Core/FabWorks.Core.csproj

Step 4: Commit

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

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:

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

dotnet test FabWorks.Tests/FabWorks.Tests.csproj -v normal

Expected: All 5 tests PASS.

Step 5: Commit

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

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:

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:

{
  "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:

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:

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:

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.

dotnet build FabWorks.Api/FabWorks.Api.csproj

Step 6: Commit

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:

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:

builder.Services.AddSingleton<FormProgramService>();

Step 3: Create BomItemsController

FabWorks.Api/Controllers/BomItemsController.cs:

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:

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

dotnet build FabWorks.Api/FabWorks.Api.csproj

Step 6: Commit

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

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

dotnet ef database update --project ../FabWorks.Core/FabWorks.Core.csproj --startup-project .

Step 3: Commit

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:

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

cd C:\Users\aisaacs\Desktop\Projects\ExportDXF\FabWorks.Tests
dotnet add reference ../FabWorks.Api/FabWorks.Api.csproj

Step 3: Run all tests

dotnet test FabWorks.Tests/FabWorks.Tests.csproj -v normal

Expected: All 10 tests PASS.

Step 4: Commit

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

cd C:\Users\aisaacs\Desktop\Projects\ExportDXF\FabWorks.Api
dotnet run

Step 2: Test endpoints with curl/Invoke-WebRequest

# 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