From 0d5742124eab722e1201c23a128f05c117cb7e91 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 08:47:11 -0500 Subject: [PATCH] feat: add revision tracking to CutTemplate and scope BOM items to export record Each export record now keeps a complete BOM snapshot instead of moving BomItems between records. CutTemplate gains a Revision field that auto-increments when the content hash changes across exports for the same drawing+item, and stays the same when the geometry is unchanged. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/ApiClient/FabWorksApiDtos.cs | 1 + .../Controllers/BomItemsController.cs | 44 ++- FabWorks.Api/Controllers/ExportsController.cs | 2 + FabWorks.Api/DTOs/ExportDetailDto.cs | 1 + ...9134027_AddCutTemplateRevision.Designer.cs | 273 ++++++++++++++++++ .../20260219134027_AddCutTemplateRevision.cs | 29 ++ .../FabWorksDbContextModelSnapshot.cs | 3 + FabWorks.Core/Models/CutTemplate.cs | 2 + 8 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs create mode 100644 FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs diff --git a/ExportDXF/ApiClient/FabWorksApiDtos.cs b/ExportDXF/ApiClient/FabWorksApiDtos.cs index 2893306..5e496c0 100644 --- a/ExportDXF/ApiClient/FabWorksApiDtos.cs +++ b/ExportDXF/ApiClient/FabWorksApiDtos.cs @@ -39,6 +39,7 @@ namespace ExportDXF.ApiClient public int Id { get; set; } public string DxfFilePath { get; set; } public string ContentHash { get; set; } + public int Revision { get; set; } public double? Thickness { get; set; } public double? KFactor { get; set; } public double? DefaultBendRadius { get; set; } diff --git a/FabWorks.Api/Controllers/BomItemsController.cs b/FabWorks.Api/Controllers/BomItemsController.cs index be41f59..f6027d1 100644 --- a/FabWorks.Api/Controllers/BomItemsController.cs +++ b/FabWorks.Api/Controllers/BomItemsController.cs @@ -53,12 +53,16 @@ namespace FabWorks.Api.Controllers var export = await _db.ExportRecords.FindAsync(exportId); if (export == null) return NotFound("Export record not found"); - // Look for existing BomItem with same PartName + ConfigurationName under the same drawing + // Look up the latest CutTemplate for this drawing+item across all previous exports + // to determine the revision number + var newContentHash = dto.CutTemplate?.ContentHash; + int revision = await ResolveRevisionAsync(export.DrawingNumber, dto.ItemNo, newContentHash); + + // Look for existing BomItem with same PartName + ConfigurationName within this export record var existing = await _db.BomItems .Include(b => b.CutTemplate) .Include(b => b.FormProgram) - .Include(b => b.ExportRecord) - .Where(b => b.ExportRecord.DrawingNumber == export.DrawingNumber + .Where(b => b.ExportRecordId == exportId && b.PartName == (dto.PartName ?? "") && b.ConfigurationName == (dto.ConfigurationName ?? "")) .OrderByDescending(b => b.ID) @@ -66,8 +70,7 @@ namespace FabWorks.Api.Controllers if (existing != null) { - // Update existing: move to new export record and refresh fields - existing.ExportRecordId = exportId; + // Update existing fields existing.PartNo = dto.PartNo ?? ""; existing.SortOrder = dto.SortOrder; existing.Qty = dto.Qty; @@ -81,6 +84,7 @@ namespace FabWorks.Api.Controllers { existing.CutTemplate.DxfFilePath = dto.CutTemplate.DxfFilePath ?? ""; existing.CutTemplate.ContentHash = dto.CutTemplate.ContentHash; + existing.CutTemplate.Revision = revision; existing.CutTemplate.Thickness = dto.CutTemplate.Thickness; existing.CutTemplate.KFactor = dto.CutTemplate.KFactor; existing.CutTemplate.DefaultBendRadius = dto.CutTemplate.DefaultBendRadius; @@ -91,6 +95,7 @@ namespace FabWorks.Api.Controllers { DxfFilePath = dto.CutTemplate.DxfFilePath ?? "", ContentHash = dto.CutTemplate.ContentHash, + Revision = revision, Thickness = dto.CutTemplate.Thickness, KFactor = dto.CutTemplate.KFactor, DefaultBendRadius = dto.CutTemplate.DefaultBendRadius @@ -156,6 +161,7 @@ namespace FabWorks.Api.Controllers { DxfFilePath = dto.CutTemplate.DxfFilePath ?? "", ContentHash = dto.CutTemplate.ContentHash, + Revision = revision, Thickness = dto.CutTemplate.Thickness, KFactor = dto.CutTemplate.KFactor, DefaultBendRadius = dto.CutTemplate.DefaultBendRadius @@ -185,6 +191,33 @@ namespace FabWorks.Api.Controllers return CreatedAtAction(nameof(GetByExport), new { exportId }, MapToDto(item)); } + /// + /// Determines the revision number for a CutTemplate by looking at the most recent + /// CutTemplate for the same drawing number and item number across all exports. + /// Returns 1 if no previous version exists, the same revision if the hash matches, + /// or previous revision + 1 if the hash changed. + /// + private async Task ResolveRevisionAsync(string drawingNumber, string itemNo, string contentHash) + { + if (string.IsNullOrEmpty(drawingNumber) || string.IsNullOrEmpty(itemNo) || string.IsNullOrEmpty(contentHash)) + return 1; + + var previous = await _db.CutTemplates + .Where(c => c.BomItem.ExportRecord.DrawingNumber == drawingNumber + && c.BomItem.ItemNo == itemNo + && c.ContentHash != null) + .OrderByDescending(c => c.Id) + .Select(c => new { c.ContentHash, c.Revision }) + .FirstOrDefaultAsync(); + + if (previous == null) + return 1; + + return previous.ContentHash == contentHash + ? previous.Revision + : previous.Revision + 1; + } + private static BomItemDto MapToDto(BomItem b) => new() { ID = b.ID, @@ -202,6 +235,7 @@ namespace FabWorks.Api.Controllers Id = b.CutTemplate.Id, DxfFilePath = b.CutTemplate.DxfFilePath, ContentHash = b.CutTemplate.ContentHash, + Revision = b.CutTemplate.Revision, Thickness = b.CutTemplate.Thickness, KFactor = b.CutTemplate.KFactor, DefaultBendRadius = b.CutTemplate.DefaultBendRadius diff --git a/FabWorks.Api/Controllers/ExportsController.cs b/FabWorks.Api/Controllers/ExportsController.cs index 1873aee..e1d9a48 100644 --- a/FabWorks.Api/Controllers/ExportsController.cs +++ b/FabWorks.Api/Controllers/ExportsController.cs @@ -239,6 +239,7 @@ namespace FabWorks.Api.Controllers Id = ct.Id, DxfFilePath = ct.DxfFilePath, ContentHash = ct.ContentHash, + Revision = ct.Revision, Thickness = ct.Thickness, KFactor = ct.KFactor, DefaultBendRadius = ct.DefaultBendRadius @@ -324,6 +325,7 @@ namespace FabWorks.Api.Controllers Id = b.CutTemplate.Id, DxfFilePath = b.CutTemplate.DxfFilePath, ContentHash = b.CutTemplate.ContentHash, + Revision = b.CutTemplate.Revision, Thickness = b.CutTemplate.Thickness, KFactor = b.CutTemplate.KFactor, DefaultBendRadius = b.CutTemplate.DefaultBendRadius diff --git a/FabWorks.Api/DTOs/ExportDetailDto.cs b/FabWorks.Api/DTOs/ExportDetailDto.cs index 870ae52..ed6eb78 100644 --- a/FabWorks.Api/DTOs/ExportDetailDto.cs +++ b/FabWorks.Api/DTOs/ExportDetailDto.cs @@ -39,6 +39,7 @@ namespace FabWorks.Api.DTOs public int Id { get; set; } public string DxfFilePath { get; set; } public string ContentHash { get; set; } + public int Revision { get; set; } public double? Thickness { get; set; } public double? KFactor { get; set; } public double? DefaultBendRadius { get; set; } diff --git a/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs new file mode 100644 index 0000000..59ad995 --- /dev/null +++ b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs @@ -0,0 +1,273 @@ +// +using System; +using FabWorks.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + [DbContext(typeof(FabWorksDbContext))] + [Migration("20260219134027_AddCutTemplateRevision")] + partial class AddCutTemplateRevision + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FabWorks.Core.Models.BomItem", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("ConfigurationName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ExportRecordId") + .HasColumnType("int"); + + b.Property("ItemNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalQty") + .HasColumnType("int"); + + b.HasKey("ID"); + + b.HasIndex("ExportRecordId"); + + b.ToTable("BomItems"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CutTemplateName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DefaultBendRadius") + .HasColumnType("float"); + + b.Property("DxfFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("Revision") + .HasColumnType("int"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("CutTemplates"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EquipmentNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ExportedAt") + .HasColumnType("datetime2"); + + b.Property("ExportedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OutputFolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ExportRecords"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BendCount") + .HasColumnType("int"); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("LowerToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaterialType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProgramFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ProgramName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SetupNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.Property("UpperToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("FormPrograms"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.BomItem", b => + { + b.HasOne("FabWorks.Core.Models.ExportRecord", "ExportRecord") + .WithMany("BomItems") + .HasForeignKey("ExportRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExportRecord"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.HasOne("FabWorks.Core.Models.BomItem", "BomItem") + .WithOne("CutTemplate") + .HasForeignKey("FabWorks.Core.Models.CutTemplate", "BomItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BomItem"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.HasOne("FabWorks.Core.Models.BomItem", "BomItem") + .WithOne("FormProgram") + .HasForeignKey("FabWorks.Core.Models.FormProgram", "BomItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BomItem"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.BomItem", b => + { + b.Navigation("CutTemplate"); + + b.Navigation("FormProgram"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Navigation("BomItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs new file mode 100644 index 0000000..4f8d959 --- /dev/null +++ b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + /// + public partial class AddCutTemplateRevision : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Revision", + table: "CutTemplates", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Revision", + table: "CutTemplates"); + } + } +} diff --git a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs index 72ce3fb..c6ebca9 100644 --- a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs +++ b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs @@ -102,6 +102,9 @@ namespace FabWorks.Core.Migrations b.Property("KFactor") .HasColumnType("float"); + b.Property("Revision") + .HasColumnType("int"); + b.Property("Thickness") .HasColumnType("float"); diff --git a/FabWorks.Core/Models/CutTemplate.cs b/FabWorks.Core/Models/CutTemplate.cs index 9a268fb..db0c9eb 100644 --- a/FabWorks.Core/Models/CutTemplate.cs +++ b/FabWorks.Core/Models/CutTemplate.cs @@ -16,6 +16,8 @@ namespace FabWorks.Core.Models set => _thickness = value.HasValue ? Math.Round(value.Value, 8) : null; } + public int Revision { get; set; } = 1; + public double? KFactor { get; set; } private double? _defaultBendRadius;