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`)
|
**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`.
|
**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`)
|
- `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
|
- `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
|
- `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)
|
- `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
|
### StockItem
|
||||||
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive`
|
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive`
|
||||||
- **Unique constraint**: (MaterialId, LengthInches)
|
- **Unique constraint**: (MaterialId, LengthInches)
|
||||||
- **Relationships**: `Material`, `SupplierOfferings` (1:many), `Transactions` (1:many StockTransaction)
|
- **Relationships**: `Material`, `Transactions` (1:many StockTransaction)
|
||||||
|
|
||||||
### StockTransaction
|
### StockTransaction
|
||||||
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
|
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
|
||||||
- Optional: `JobId`, `SupplierId`, `UnitPrice`
|
- Optional: `JobId`
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
### CuttingTool
|
### CuttingTool
|
||||||
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
|
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
|
||||||
@@ -153,9 +145,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
### JobStock
|
### JobStock
|
||||||
- `JobId`, `MaterialId`, `StockItemId?`, `LengthInches`, `Quantity` (-1 = unlimited), `IsCustomLength`, `Priority` (lower = used first), `SortOrder`
|
- `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
|
## CutList.Web Services
|
||||||
|
|
||||||
### MaterialService
|
### MaterialService
|
||||||
@@ -169,10 +158,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
|
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
|
||||||
- Pricing: `GetAverageCostAsync`, `GetLastPurchasePriceAsync`
|
- Pricing: `GetAverageCostAsync`, `GetLastPurchasePriceAsync`
|
||||||
|
|
||||||
### SupplierService
|
|
||||||
- CRUD for suppliers and offerings
|
|
||||||
- `GetOfferingsForStockItemAsync` — all supplier options for a stock item
|
|
||||||
|
|
||||||
### JobService
|
### JobService
|
||||||
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync`
|
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync`
|
||||||
- Lock/Unlock: `LockAsync(id)`, `UnlockAsync(id)` — controls job editability after ordering
|
- 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 %
|
- `GetSummary(result)` — calculates total bins, pieces, waste, efficiency %
|
||||||
- `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.)
|
- `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
|
### ReportService
|
||||||
- `FormatLength(inches)`, `GroupItems(items)` for print report formatting
|
- `FormatLength(inches)`, `GroupItems(items)` for print report formatting
|
||||||
|
|
||||||
### CatalogService
|
### 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
|
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` / `scripts/AlroCatalog` data-loading workflows
|
||||||
|
|
||||||
## CutList.Web Pages
|
## 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) |
|
| `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) |
|
||||||
| `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges |
|
| `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges |
|
||||||
| `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form |
|
| `/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 |
|
| `/tools` | Tools/Index | Cutting tools CRUD |
|
||||||
| `/Error` | Error | Unhandled exception page (registered via `UseExceptionHandler`) |
|
| `/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) |
|
| `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` |
|
| `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 |
|
| `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
|
## Key Patterns & Conventions
|
||||||
|
|
||||||
- **Nullable reference types enabled** — handle nulls explicitly
|
- **Nullable reference types enabled** — handle nulls explicitly
|
||||||
- **Soft deletes** — Materials, Suppliers, StockItems, CuttingTools use `IsActive` flag
|
- **Soft deletes** — Materials, 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
|
- **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`
|
- **Pagination** — All list pages use `Pager` with `pageSize = 25`
|
||||||
- **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component
|
- **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component
|
||||||
- **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns)
|
- **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns)
|
||||||
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
||||||
- **Job stock** — Jobs can use auto-discovered inventory OR define custom stock lengths
|
- **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
|
- **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
|
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
|
||||||
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
||||||
- **Priority system** — Lower priority bins used first in packing algorithm
|
- **Priority system** — Lower priority bins used first in packing algorithm
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
@page "/jobs/{Id:int}"
|
@page "/jobs/{Id:int}"
|
||||||
@inject JobService JobService
|
@inject JobService JobService
|
||||||
@inject MaterialService MaterialService
|
@inject MaterialService MaterialService
|
||||||
@inject StockItemService StockItemService
|
|
||||||
@inject CutListPackingService PackingService
|
@inject CutListPackingService PackingService
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@@ -23,7 +22,7 @@
|
|||||||
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-lock-fill me-2"></i>
|
<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>
|
</div>
|
||||||
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
|
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
|
||||||
<i class="bi bi-unlock"></i> Unlock Job
|
<i class="bi bi-unlock"></i> Unlock Job
|
||||||
@@ -374,7 +373,6 @@ else
|
|||||||
private MultiMaterialPackingSummary? summary;
|
private MultiMaterialPackingSummary? summary;
|
||||||
private bool optimizing;
|
private bool optimizing;
|
||||||
private bool lockingJob;
|
private bool lockingJob;
|
||||||
private bool jobLocked;
|
|
||||||
|
|
||||||
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
||||||
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
||||||
@@ -442,7 +440,6 @@ else
|
|||||||
if (packResult != null)
|
if (packResult != null)
|
||||||
{
|
{
|
||||||
summary = PackingService.GetSummary(packResult);
|
summary = PackingService.GetSummary(packResult);
|
||||||
jobLocked = job.IsLocked;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -1046,22 +1043,19 @@ else
|
|||||||
<div class="card mb-4 print-purchase-list">
|
<div class="card mb-4 print-purchase-list">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<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>
|
<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>
|
||||||
{
|
}
|
||||||
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
else
|
||||||
}
|
{
|
||||||
else
|
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
|
||||||
{
|
@if (lockingJob)
|
||||||
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
|
{
|
||||||
@if (lockingJob)
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
{
|
}
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
<i class="bi bi-lock me-1"></i>Lock Job
|
||||||
}
|
</button>
|
||||||
<i class="bi bi-lock me-1"></i>Lock Job
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -1207,7 +1201,6 @@ else
|
|||||||
|
|
||||||
// Refresh job to get updated OptimizedAt
|
// Refresh job to get updated OptimizedAt
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
jobLocked = job.IsLocked;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -1222,7 +1215,6 @@ else
|
|||||||
{
|
{
|
||||||
await JobService.LockAsync(Id!.Value);
|
await JobService.LockAsync(Id!.Value);
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
jobLocked = true;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -302,14 +302,21 @@ else
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsNew)
|
try
|
||||||
{
|
{
|
||||||
var created = await StockItemService.CreateAsync(stockItem);
|
if (IsNew)
|
||||||
Navigation.NavigateTo($"stock/{created.Id}");
|
{
|
||||||
|
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
|
finally
|
||||||
|
|||||||
@@ -347,20 +347,17 @@ public class CatalogService
|
|||||||
var existing = existingStockItems.FirstOrDefault(
|
var existing = existingStockItems.FirstOrDefault(
|
||||||
s => s.LengthInches == dto.LengthInches);
|
s => s.LengthInches == dto.LengthInches);
|
||||||
|
|
||||||
StockItem stockItem;
|
|
||||||
|
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
existing.Name = dto.Name ?? existing.Name;
|
existing.Name = dto.Name ?? existing.Name;
|
||||||
existing.Notes = dto.Notes ?? existing.Notes;
|
existing.Notes = dto.Notes ?? existing.Notes;
|
||||||
existing.IsActive = true;
|
existing.IsActive = true;
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
stockItem = existing;
|
|
||||||
result.StockItemsUpdated++;
|
result.StockItemsUpdated++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stockItem = new StockItem
|
var stockItem = new StockItem
|
||||||
{
|
{
|
||||||
MaterialId = material.Id,
|
MaterialId = material.Id,
|
||||||
LengthInches = dto.LengthInches,
|
LengthInches = dto.LengthInches,
|
||||||
@@ -381,6 +378,8 @@ public class CatalogService
|
|||||||
$"Stock item '{material.DisplayName} @ {dto.LengthInches}\"': {ex.Message}");
|
$"Stock item '{material.DisplayName} @ {dto.LengthInches}\"': {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<CatalogStockItemDto> MapStockItems(Material m)
|
private static List<CatalogStockItemDto> MapStockItems(Material m)
|
||||||
|
|||||||
Reference in New Issue
Block a user