From 3244e155931f94def711199d3a2ae7252eda1ecf Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 1 Aug 2026 09:32:16 -0400 Subject: [PATCH] fix: address final review findings in vendor-data removal (catalog save, job locking, DB collision, docs) --- CLAUDE.md | 43 +++++-------------- CutList.Web/Components/Pages/Jobs/Edit.razor | 34 ++++++--------- CutList.Web/Components/Pages/Stock/Edit.razor | 17 +++++--- CutList.Web/Services/CatalogService.cs | 7 ++- 4 files changed, 38 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88f20ac..247c3bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,9 +91,9 @@ CutList.Mcp is an stdio MCP server, not a hosted service — it's published to ` **Database**: SQL Server via Entity Framework Core (connection string: `DefaultConnection`) -**Service Registration** (Program.cs): All services registered as Scoped — MaterialService, SupplierService, StockItemService, JobService, CutListPackingService, ReportService, PurchaseItemService, CatalogService. `IDbContextFactory` is used (not a scoped `DbContext` directly) for Blazor Server circuit safety. +**Service Registration** (Program.cs): All services registered as Scoped — MaterialService, StockItemService, JobService, CutListPackingService, ReportService, CatalogService. `IDbContextFactory` is used (not a scoped `DbContext` directly) for Blazor Server circuit safety. -**REST API** (`Controllers/`): `JobsController`, `MaterialsController`, `StockItemsController`, `SuppliersController`, `CuttingToolsController`, `PackingController`, `CatalogController` — Swagger/OpenAPI enabled in Development. This API is the integration surface `CutList.Mcp` calls into; the Blazor UI talks to the services directly and does not go through it. +**REST API** (`Controllers/`): `JobsController`, `MaterialsController`, `StockItemsController`, `CuttingToolsController`, `PackingController`, `CatalogController` — Swagger/OpenAPI enabled in Development. This API is the integration surface `CutList.Mcp` calls into; the Blazor UI talks to the services directly and does not go through it. **Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`. @@ -103,7 +103,7 @@ Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's R - `ApiClient.cs` — typed `HttpClient` wrapper for CutList.Web's REST API (`BaseAddress` hardcoded to `http://localhost:5270`) - `JobTools.cs` — job CRUD, parts/stock, optimization (`OptimizeJob`), cutting tools -- `InventoryTools.cs` — suppliers, materials, stock items, supplier offerings +- `InventoryTools.cs` — materials, stock items (`add_stock`, etc.) - `CutListTools.cs` — static helpers shared across tool classes - `Models.cs` — shared DTOs distinct from CutList.Web's own DTOs (kept intentionally thin for MCP tool responses) @@ -122,19 +122,11 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its ### StockItem - `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive` - **Unique constraint**: (MaterialId, LengthInches) -- **Relationships**: `Material`, `SupplierOfferings` (1:many), `Transactions` (1:many StockTransaction) +- **Relationships**: `Material`, `Transactions` (1:many StockTransaction) ### StockTransaction - `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned) -- Optional: `JobId`, `SupplierId`, `UnitPrice` - -### Supplier -- `Name` (required), `ContactInfo`, `Notes`, `IsActive` -- **Relationships**: `Offerings` (1:many SupplierOffering) - -### SupplierOffering -- Links Supplier to StockItem with optional `PartNumber`, `Price`, `Notes` -- **Unique constraint**: (SupplierId, StockItemId) +- Optional: `JobId` ### CuttingTool - `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive` @@ -153,9 +145,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its ### JobStock - `JobId`, `MaterialId`, `StockItemId?`, `LengthInches`, `Quantity` (-1 = unlimited), `IsCustomLength`, `Priority` (lower = used first), `SortOrder` -### PurchaseItem -- `StockItemId`, `SupplierId?`, `JobId?`, `Quantity`, `Status` (Pending/Ordered/Received), `Notes` - ## CutList.Web Services ### MaterialService @@ -169,10 +158,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its - `GetTransactionHistoryAsync`, `RecalculateQuantityAsync` - Pricing: `GetAverageCostAsync`, `GetLastPurchasePriceAsync` -### SupplierService -- CRUD for suppliers and offerings -- `GetOfferingsForStockItemAsync` — all supplier options for a stock item - ### JobService - Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync` - Lock/Unlock: `LockAsync(id)`, `UnlockAsync(id)` — controls job editability after ordering @@ -187,15 +172,11 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its - `GetSummary(result)` — calculates total bins, pieces, waste, efficiency % - `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.) -### PurchaseItemService -- CRUD + `CreateBulkAsync` for batch creation from optimization results -- `UpdateStatusAsync(id, status)`, `UpdateSupplierAsync(id, supplierId)` - ### ReportService - `FormatLength(inches)`, `GroupItems(items)` for print report formatting ### CatalogService -- `ExportAsync()` — dumps active suppliers, cutting tools, and materials (with dimensions + stock items + supplier offerings) into a shape-grouped `CatalogData` DTO for bulk export/import tooling +- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling - Backs the `CatalogController` REST endpoint and the `scripts/ExportData` / `scripts/AlroCatalog` data-loading workflows ## CutList.Web Pages @@ -210,10 +191,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its | `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) | | `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges | | `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form | -| `/orders` | Orders/Index | Tabbed (Pending/Ordered/All), supplier assignment, status transitions | -| `/orders/add` | Orders/Add | Manual purchase item creation | -| `/suppliers` | Suppliers/Index | Supplier list with CRUD | -| `/suppliers/{Id}` | Suppliers/Edit | Supplier + offerings management | | `/tools` | Tools/Index | Cutting tools CRUD | | `/Error` | Error | Unhandled exception page (registered via `UseExceptionHandler`) | @@ -224,20 +201,20 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its | `ConfirmDialog` | Modal confirmation for destructive actions (Show/Hide methods, OnConfirm callback) | | `LengthInput` | Architectural unit input — parses "12'", "6\"", "12 1/2\""; reformats on blur; two-way binding via `Value` or `NullableValue` | | `Pager` | Pagination with "Showing X-Y of Z", prev/next, smart page window with ellipsis | -| `MaterialFilter` | Reusable filter: Shape, Type, Grade dropdowns + search text; used on Materials, Stock, Orders pages | +| `MaterialFilter` | Reusable filter: Shape, Type, Grade dropdowns + search text; used on Materials, Stock pages | ## Key Patterns & Conventions - **Nullable reference types enabled** — handle nulls explicitly -- **Soft deletes** — Materials, Suppliers, StockItems, CuttingTools use `IsActive` flag -- **Job locking** — `LockedAt` timestamp set when materials ordered; Edit page disables all modification via `
`, hides add/edit/delete buttons; Unlock button to re-enable editing +- **Soft deletes** — Materials, StockItems, CuttingTools use `IsActive` flag +- **Job locking** — `LockedAt` timestamp set via a manual Lock Job action (always available, regardless of whether the job needs purchases); Edit page disables all modification via `
`, hides add/edit/delete buttons; Unlock button to re-enable editing - **Pagination** — All list pages use `Pager` with `pageSize = 25` - **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component - **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns) - **Stock priority** — Lower number = used first; `-1` quantity = unlimited - **Job stock** — Jobs can use auto-discovered inventory OR define custom stock lengths - **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change -- **Purchase flow** — Optimize job -> "Add to Order List" creates PurchaseItems + locks job -> Orders page manages status (Pending -> Ordered -> Received) +- **Job lock flow** — Optimize job -> Lock Job (manual action, available whether or not purchases are needed) -> job becomes read-only until Unlock - **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications - **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods - **Priority system** — Lower priority bins used first in packing algorithm diff --git a/CutList.Web/Components/Pages/Jobs/Edit.razor b/CutList.Web/Components/Pages/Jobs/Edit.razor index 84cf971..8a82c5b 100644 --- a/CutList.Web/Components/Pages/Jobs/Edit.razor +++ b/CutList.Web/Components/Pages/Jobs/Edit.razor @@ -2,7 +2,6 @@ @page "/jobs/{Id:int}" @inject JobService JobService @inject MaterialService MaterialService -@inject StockItemService StockItemService @inject CutListPackingService PackingService @inject NavigationManager Navigation @inject IJSRuntime JS @@ -23,7 +22,7 @@
- This job is locked — materials ordered on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes. + This job is locked — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
- } + Job Locked + } + else + { + }
@@ -1207,7 +1201,6 @@ else // Refresh job to get updated OptimizedAt job = (await JobService.GetByIdAsync(Id!.Value))!; - jobLocked = job.IsLocked; } finally { @@ -1222,7 +1215,6 @@ else { await JobService.LockAsync(Id!.Value); job = (await JobService.GetByIdAsync(Id!.Value))!; - jobLocked = true; } finally { diff --git a/CutList.Web/Components/Pages/Stock/Edit.razor b/CutList.Web/Components/Pages/Stock/Edit.razor index 09d2b0a..a865518 100644 --- a/CutList.Web/Components/Pages/Stock/Edit.razor +++ b/CutList.Web/Components/Pages/Stock/Edit.razor @@ -302,14 +302,21 @@ else return; } - if (IsNew) + try { - var created = await StockItemService.CreateAsync(stockItem); - Navigation.NavigateTo($"stock/{created.Id}"); + if (IsNew) + { + var created = await StockItemService.CreateAsync(stockItem); + Navigation.NavigateTo($"stock/{created.Id}"); + } + else + { + await StockItemService.UpdateAsync(stockItem); + } } - else + catch (Microsoft.EntityFrameworkCore.DbUpdateException) { - await StockItemService.UpdateAsync(stockItem); + errorMessage = "A stock item with this material and length already exists (it may have been previously deleted)."; } } finally diff --git a/CutList.Web/Services/CatalogService.cs b/CutList.Web/Services/CatalogService.cs index 00d8593..363271b 100644 --- a/CutList.Web/Services/CatalogService.cs +++ b/CutList.Web/Services/CatalogService.cs @@ -347,20 +347,17 @@ public class CatalogService var existing = existingStockItems.FirstOrDefault( s => s.LengthInches == dto.LengthInches); - StockItem stockItem; - if (existing != null) { existing.Name = dto.Name ?? existing.Name; existing.Notes = dto.Notes ?? existing.Notes; existing.IsActive = true; existing.UpdatedAt = DateTime.UtcNow; - stockItem = existing; result.StockItemsUpdated++; } else { - stockItem = new StockItem + var stockItem = new StockItem { MaterialId = material.Id, LengthInches = dto.LengthInches, @@ -381,6 +378,8 @@ public class CatalogService $"Stock item '{material.DisplayName} @ {dto.LengthInches}\"': {ex.Message}"); } } + + await context.SaveChangesAsync(); } private static List MapStockItems(Material m)