fix: address final review findings in vendor-data removal (catalog save, job locking, DB collision, docs)

This commit is contained in:
aj
2026-08-01 09:32:16 -04:00
parent b77d0d971d
commit 3244e15593
4 changed files with 38 additions and 63 deletions
+10 -33
View File
@@ -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<ApplicationDbContext>` 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<ApplicationDbContext>` 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 `<fieldset disabled>`, 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 `<fieldset disabled>`, 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
+13 -21
View File
@@ -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 @@
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
<div>
<i class="bi bi-lock-fill me-2"></i>
<strong>This job is locked</strong> — materials ordered on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
</div>
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
<i class="bi bi-unlock"></i> Unlock Job
@@ -374,7 +373,6 @@ else
private MultiMaterialPackingSummary? summary;
private bool optimizing;
private bool lockingJob;
private bool jobLocked;
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
@@ -442,7 +440,6 @@ else
if (packResult != null)
{
summary = PackingService.GetSummary(packResult);
jobLocked = job.IsLocked;
}
}
catch
@@ -1046,22 +1043,19 @@ else
<div class="card mb-4 print-purchase-list">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
@if (summary.TotalToBePurchasedBins > 0)
@if (job.IsLocked)
{
@if (jobLocked)
{
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</button>
}
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</button>
}
</div>
<div class="card-body">
@@ -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
{
+12 -5
View File
@@ -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
+3 -4
View File
@@ -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<CatalogStockItemDto> MapStockItems(Material m)