fix: address final review findings in vendor-data removal (catalog save, job locking, DB collision, docs)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user