Compare commits

..
14 Commits
Author SHA1 Message Date
aj 7b210f518b docs: remove last stale StockItemService pricing reference; ignore playwright-mcp logs
Build CutList image / build-and-push (push) Successful in 1m11s
CLAUDE.md still listed GetAverageCostAsync/GetLastPurchasePriceAsync, which
Task 1 of the vendor-data removal already deleted. Also stop tracking the
Playwright MCP browser session logs left behind by Task 10's smoke test.
2026-08-01 09:33:53 -04:00
aj 3244e15593 fix: address final review findings in vendor-data removal (catalog save, job locking, DB collision, docs) 2026-08-01 09:32:16 -04:00
aj b77d0d971d feat: remove supplier MCP tools, add plain add_stock convenience tool 2026-08-01 08:55:49 -04:00
aj f75c205bc5 chore: strip vendor data from oneals-catalog.json seed file 2026-08-01 08:52:25 -04:00
aj 1b4d5f76a8 feat: remove Supplier/SupplierOffering/PurchaseItem entities and migrate schema 2026-08-01 08:49:05 -04:00
ajandClaude Sonnet 5 41944638c6 feat: drop supplier import/export from the catalog format
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 08:45:21 -04:00
aj 063c844310 feat: delete SupplierService, PurchaseItemService, and their API surface 2026-08-01 08:41:28 -04:00
aj 597181bd78 feat: replace Add to Order List with a direct Lock Job action 2026-08-01 08:38:20 -04:00
aj 8d6ada35e5 feat: remove Suppliers and Orders pages and nav entries 2026-08-01 08:34:47 -04:00
aj b51d936b78 fix: make stock item length editable and drop vendor UI from Stock Edit page 2026-08-01 08:31:40 -04:00
aj e7248be9a4 refactor: strip supplier/pricing from StockItemService and stock-items API 2026-08-01 08:27:55 -04:00
aj 06f0c2032d chore: ignore .superpowers/ SDD scratch workspace directory
Keeps subagent-driven-development's per-plan ledger/briefs/reports out
of version control.
2026-08-01 08:24:47 -04:00
aj 9296bea0bc docs: add implementation plan for removing vendor/supplier data
Ten-task plan ordered so the build stays green at each step: strip
consumers first (StockItemService/DTOs/Controller, Stock/Edit.razor,
Suppliers/Orders pages, Jobs/Edit.razor, CatalogService), delete the
now-unused services, then remove the entities and migrate the schema
last. Closes with CutList.Mcp cleanup and a manual smoke test.
2026-07-31 22:56:41 -04:00
aj 4e874df22f docs: add design spec for removing vendor/supplier data from CutList
Scopes out Supplier, SupplierOffering, PurchaseItem, and cost tracking
as out-of-scope for a 1D nesting optimizer, while fixing the actual
reported bug (read-only stock length field) and keeping a stripped
quantity-only transaction log.
2026-07-31 22:44:13 -04:00
34 changed files with 7701 additions and 11804 deletions
+6
View File
@@ -248,3 +248,9 @@ ModelManifest.xml
scripts/AlroCatalog/__pycache__/
scripts/AlroCatalog/pdfs/
scripts/AlroCatalog/screenshots/
# Superpowers subagent-driven-development scratch workspace
.superpowers/
# Playwright MCP browser session logs/traces
.playwright-mcp/
+11 -35
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
@@ -167,15 +156,10 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
- CRUD with soft delete
- Stock transactions: `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`
- `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
- Lock/Unlock: `LockAsync(id)`, `UnlockAsync(id)` — controls job editability
- Parts: `AddPartAsync`, `UpdatePartAsync`, `DeletePartAsync` (all update job timestamp + clear optimization results)
- Stock: `AddStockAsync`, `UpdateStockAsync`, `DeleteStockAsync` (all clear optimization results)
- Optimization: `SaveOptimizationResultAsync`, `ClearOptimizationResultAsync`
@@ -187,15 +171,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 +190,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 +200,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
-76
View File
@@ -14,23 +14,6 @@ public class ApiClient
_http = http;
}
#region Suppliers
public async Task<List<ApiSupplierDto>> GetSuppliersAsync(bool includeInactive = false)
{
var url = $"api/suppliers?includeInactive={includeInactive}";
return await _http.GetFromJsonAsync<List<ApiSupplierDto>>(url) ?? [];
}
public async Task<ApiSupplierDto?> CreateSupplierAsync(string name, string? contactInfo, string? notes)
{
var response = await _http.PostAsJsonAsync("api/suppliers", new { Name = name, ContactInfo = contactInfo, Notes = notes });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiSupplierDto>();
}
#endregion
#region Materials
public async Task<List<ApiMaterialDto>> GetMaterialsAsync(string? shape = null, bool includeInactive = false)
@@ -212,40 +195,6 @@ public class ApiClient
#endregion
#region Offerings
public async Task<List<ApiOfferingDto>> GetOfferingsForSupplierAsync(int supplierId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/suppliers/{supplierId}/offerings") ?? [];
}
public async Task<List<ApiOfferingDto>> GetOfferingsForStockItemAsync(int stockItemId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/stock-items/{stockItemId}/offerings") ?? [];
}
public async Task<ApiOfferingDto?> CreateOfferingAsync(int supplierId, int stockItemId,
string? partNumber, string? supplierDescription, decimal? price, string? notes)
{
var body = new
{
StockItemId = stockItemId,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price,
Notes = notes
};
var response = await _http.PostAsJsonAsync($"api/suppliers/{supplierId}/offerings", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiOfferingDto>();
}
#endregion
}
/// <summary>
@@ -385,15 +334,6 @@ public class ApiMaterialPackingSummaryDto
#region API Response DTOs Inventory
public class ApiSupplierDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class ApiMaterialDto
{
public int Id { get; set; }
@@ -425,20 +365,4 @@ public class ApiStockItemDto
public bool IsActive { get; set; }
}
public class ApiOfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string? SupplierName { get; set; }
public int StockItemId { get; set; }
public string? MaterialName { get; set; }
public decimal? LengthInches { get; set; }
public string? LengthFormatted { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
#endregion
+25 -287
View File
@@ -5,7 +5,7 @@ using ModelContextProtocol.Server;
namespace CutList.Mcp;
/// <summary>
/// MCP tools for inventory management - suppliers, materials, stock items, and offerings.
/// MCP tools for inventory management - materials and stock items.
/// All calls go through the CutList.Web REST API via ApiClient.
/// </summary>
[McpServerToolType]
@@ -18,56 +18,6 @@ public class InventoryTools
_api = api;
}
#region Suppliers
[McpServerTool(Name = "list_suppliers"), Description("Lists all suppliers in the system.")]
public async Task<SupplierListResult> ListSuppliers(
[Description("Include inactive suppliers (default false)")]
bool includeInactive = false)
{
var suppliers = await _api.GetSuppliersAsync(includeInactive);
return new SupplierListResult
{
Success = true,
Suppliers = suppliers.Select(s => new SupplierDto
{
Id = s.Id,
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes,
IsActive = s.IsActive
}).ToList()
};
}
[McpServerTool(Name = "add_supplier"), Description("Adds a new supplier to the system.")]
public async Task<SupplierResult> AddSupplier(
[Description("Supplier name (e.g., 'O'Neal Steel')")]
string name,
[Description("Contact info - website, phone, email, etc.")]
string? contactInfo = null,
[Description("Notes about the supplier")]
string? notes = null)
{
var supplier = await _api.CreateSupplierAsync(name, contactInfo, notes);
return new SupplierResult
{
Success = true,
Supplier = supplier != null ? new SupplierDto
{
Id = supplier.Id,
Name = supplier.Name,
ContactInfo = supplier.ContactInfo,
Notes = supplier.Notes,
IsActive = supplier.IsActive
} : null
};
}
#endregion
#region Materials
[McpServerTool(Name = "list_materials"), Description("Lists all materials (shape/size combinations) in the system.")]
@@ -285,151 +235,22 @@ public class InventoryTools
#endregion
#region Supplier Offerings
#region Convenience
[McpServerTool(Name = "list_supplier_offerings"), Description("Lists supplier offerings (what suppliers sell for each stock item).")]
public async Task<SupplierOfferingListResult> ListSupplierOfferings(
[Description("Filter by supplier ID")]
int? supplierId = null,
[Description("Filter by stock item ID")]
int? stockItemId = null,
[Description("Filter by material ID")]
int? materialId = null)
{
List<ApiOfferingDto> offerings;
if (supplierId.HasValue)
{
offerings = await _api.GetOfferingsForSupplierAsync(supplierId.Value);
// Apply additional filters client-side
if (stockItemId.HasValue)
offerings = offerings.Where(o => o.StockItemId == stockItemId.Value).ToList();
if (materialId.HasValue)
{
// Need to get stock items for this material to filter
var stockItems = await _api.GetStockItemsAsync(materialId);
var stockItemIds = stockItems.Select(s => s.Id).ToHashSet();
offerings = offerings.Where(o => stockItemIds.Contains(o.StockItemId)).ToList();
}
}
else if (stockItemId.HasValue)
{
offerings = await _api.GetOfferingsForStockItemAsync(stockItemId.Value);
}
else if (materialId.HasValue)
{
// Get stock items for this material, then aggregate offerings
var stockItems = await _api.GetStockItemsAsync(materialId);
var allOfferings = new List<ApiOfferingDto>();
foreach (var si in stockItems)
{
var siOfferings = await _api.GetOfferingsForStockItemAsync(si.Id);
allOfferings.AddRange(siOfferings);
}
offerings = allOfferings;
}
else
{
// No filter - get all suppliers then aggregate
var suppliers = await _api.GetSuppliersAsync();
var allOfferings = new List<ApiOfferingDto>();
foreach (var s in suppliers)
{
var sOfferings = await _api.GetOfferingsForSupplierAsync(s.Id);
allOfferings.AddRange(sOfferings);
}
offerings = allOfferings;
}
return new SupplierOfferingListResult
{
Success = true,
Offerings = offerings.Select(o => new SupplierOfferingDto
{
Id = o.Id,
SupplierId = o.SupplierId,
SupplierName = o.SupplierName ?? string.Empty,
StockItemId = o.StockItemId,
MaterialName = o.MaterialName ?? string.Empty,
LengthFormatted = o.LengthFormatted ?? string.Empty,
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes
}).ToList()
};
}
[McpServerTool(Name = "add_supplier_offering"), Description("Adds a supplier offering - links a supplier to a stock item with their part number and pricing.")]
public async Task<SupplierOfferingResult> AddSupplierOffering(
[Description("Supplier ID (use list_suppliers to find)")]
int supplierId,
[Description("Stock item ID (use list_stock_items to find)")]
int stockItemId,
[Description("Supplier's part number")]
string? partNumber = null,
[Description("Supplier's description of the item")]
string? supplierDescription = null,
[Description("Price per unit")]
decimal? price = null,
[Description("Notes")]
string? notes = null)
{
try
{
var offering = await _api.CreateOfferingAsync(supplierId, stockItemId, partNumber, supplierDescription, price, notes);
if (offering == null)
return new SupplierOfferingResult { Success = false, Error = "Failed to create offering" };
return new SupplierOfferingResult
{
Success = true,
Offering = new SupplierOfferingDto
{
Id = offering.Id,
SupplierId = offering.SupplierId,
SupplierName = offering.SupplierName ?? string.Empty,
StockItemId = offering.StockItemId,
MaterialName = offering.MaterialName ?? string.Empty,
LengthFormatted = offering.LengthFormatted ?? string.Empty,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
}
};
}
catch (ApiConflictException ex)
{
return new SupplierOfferingResult { Success = false, Error = ex.Message };
}
catch (HttpRequestException ex)
{
return new SupplierOfferingResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "add_stock_with_offering"), Description("Convenience method: adds a material (if needed), stock item (if needed), and supplier offering all at once.")]
public async Task<AddStockWithOfferingResult> AddStockWithOffering(
[Description("Supplier ID (use list_suppliers or add_supplier first)")]
int supplierId,
[McpServerTool(Name = "add_stock"), Description("Convenience method: adds a material (if needed) and a stock item (if needed) with an initial quantity, all in one call.")]
public async Task<AddStockResult> AddStock(
[Description("Material shape (e.g., 'Angle', 'FlatBar')")]
string shape,
[Description("Material size (e.g., '2 x 2 x 1/4')")]
string size,
[Description("Stock length (e.g., '20'', '240')")]
string length,
[Description("Quantity on hand (default 0)")]
int quantityOnHand = 0,
[Description("Material type: Steel, Aluminum, Stainless, Brass, Copper (default: Steel)")]
string type = "Steel",
[Description("Grade or specification (e.g., 'A36', 'Hot Roll', '304', '6061-T6')")]
string? grade = null,
[Description("Supplier's part number")]
string? partNumber = null,
[Description("Supplier's description")]
string? supplierDescription = null,
[Description("Price per unit")]
decimal? price = null)
string? grade = null)
{
// Parse length for formatted display
double lengthInches;
@@ -441,7 +262,7 @@ public class InventoryTools
}
catch
{
return new AddStockWithOfferingResult
return new AddStockResult
{
Success = false,
Error = $"Could not parse length: {length}"
@@ -478,12 +299,12 @@ public class InventoryTools
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult { Success = false, Error = $"Failed to create material: {ex.Message}" };
return new AddStockResult { Success = false, Error = $"Failed to create material: {ex.Message}" };
}
}
if (material == null)
return new AddStockWithOfferingResult { Success = false, Error = "Failed to find or create material" };
return new AddStockResult { Success = false, Error = "Failed to find or create material" };
// Step 2: Find or create stock item
bool stockItemCreated = false;
@@ -494,7 +315,7 @@ public class InventoryTools
{
try
{
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, 0, null);
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, quantityOnHand, null);
stockItemCreated = true;
}
catch (ApiConflictException)
@@ -505,7 +326,7 @@ public class InventoryTools
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult
return new AddStockResult
{
Success = false,
Error = $"Failed to create stock item: {ex.Message}",
@@ -515,53 +336,24 @@ public class InventoryTools
}
if (stockItem == null)
return new AddStockWithOfferingResult
return new AddStockResult
{
Success = false,
Error = "Failed to find or create stock item",
MaterialCreated = materialCreated
};
// Step 3: Create offering
try
return new AddStockResult
{
var offering = await _api.CreateOfferingAsync(supplierId, stockItem.Id, partNumber, supplierDescription, price, null);
return new AddStockWithOfferingResult
{
Success = true,
MaterialId = material.Id,
MaterialName = $"{material.Shape} - {material.Size}",
MaterialCreated = materialCreated,
StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
OfferingId = offering?.Id ?? 0,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price
};
}
catch (ApiConflictException)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Offering for this supplier and stock item already exists",
MaterialCreated = materialCreated,
StockItemCreated = stockItemCreated
};
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Failed to create offering: {ex.Message}",
MaterialCreated = materialCreated,
StockItemCreated = stockItemCreated
};
}
Success = true,
MaterialId = material.Id,
MaterialName = $"{material.Shape} - {material.Size}",
MaterialCreated = materialCreated,
StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
QuantityOnHand = stockItem.QuantityOnHand
};
}
#endregion
@@ -676,29 +468,6 @@ public class InventoryTools
#region DTOs
public class SupplierDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class SupplierListResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<SupplierDto> Suppliers { get; set; } = new();
}
public class SupplierResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public SupplierDto? Supplier { get; set; }
}
public class MaterialDimensionsDto
{
public double? Diameter { get; set; }
@@ -769,35 +538,7 @@ public class StockItemResult
public StockItemDto? StockItem { get; set; }
}
public class SupplierOfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string SupplierName { get; set; } = string.Empty;
public int StockItemId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public string LengthFormatted { get; set; } = string.Empty;
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class SupplierOfferingListResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<SupplierOfferingDto> Offerings { get; set; } = new();
}
public class SupplierOfferingResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public SupplierOfferingDto? Offering { get; set; }
}
public class AddStockWithOfferingResult
public class AddStockResult
{
public bool Success { get; set; }
public string? Error { get; set; }
@@ -807,10 +548,7 @@ public class AddStockWithOfferingResult
public int StockItemId { get; set; }
public bool StockItemCreated { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int OfferingId { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public int QuantityOnHand { get; set; }
}
#endregion
@@ -28,16 +28,6 @@
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="orders">
<span class="bi bi-cart-nav-menu" aria-hidden="true"></span> Orders
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="suppliers">
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Suppliers
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="tools">
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
+5 -14
View File
@@ -7,7 +7,7 @@
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
<div class="row mt-4">
<div class="col-md-6 col-lg-3 mb-4">
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Jobs</h5>
@@ -16,7 +16,7 @@
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Materials</h5>
@@ -25,16 +25,7 @@
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Suppliers</h5>
<p class="card-text">Track suppliers and their available stock lengths for quick project setup.</p>
<a href="suppliers" class="btn btn-outline-primary">Manage Suppliers</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Cutting Tools</h5>
@@ -50,9 +41,9 @@
<h4>How It Works</h4>
<ol>
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li>
<li><strong>Add suppliers</strong> - Track which stock lengths are available from your suppliers</li>
<li><strong>Add stock</strong> - Record which stock lengths you have on hand</li>
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li>
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from (import from supplier or add manually)</li>
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from</li>
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li>
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li>
</ol>
+17 -69
View File
@@ -2,9 +2,7 @@
@page "/jobs/{Id:int}"
@inject JobService JobService
@inject MaterialService MaterialService
@inject StockItemService StockItemService
@inject CutListPackingService PackingService
@inject PurchaseItemService PurchaseItemService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using CutList.Core
@@ -24,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,8 +372,7 @@ else
private MultiMaterialPackResult? packResult;
private MultiMaterialPackingSummary? summary;
private bool optimizing;
private bool addingToOrderList;
private bool addedToOrderList;
private bool lockingJob;
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
@@ -443,7 +440,6 @@ else
if (packResult != null)
{
summary = PackingService.GetSummary(packResult);
addedToOrderList = job.IsLocked;
}
}
catch
@@ -1047,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 (addedToOrderList)
{
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Added to orders</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus me-1"></i>Add to Order List
</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">
@@ -1072,12 +1065,6 @@ else
}
else
{
@if (addedToOrderList)
{
<div class="alert alert-success py-2 mb-3">
Items added to order list. <a href="orders">View Orders</a>
</div>
}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
@@ -1214,7 +1201,6 @@ else
// Refresh job to get updated OptimizedAt
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = job.IsLocked;
}
finally
{
@@ -1222,55 +1208,17 @@ else
}
}
private async Task AddToOrderList()
private async Task LockJob()
{
addingToOrderList = true;
lockingJob = true;
try
{
var purchaseItems = new List<PurchaseItem>();
var stockItems = await StockItemService.GetAllAsync();
foreach (var materialResult in packResult!.MaterialResults)
{
if (materialResult.ToBePurchasedBins.Count == 0) continue;
var materialId = materialResult.Material.Id;
// Group bins by length to consolidate quantities
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
{
var lengthInches = (decimal)group.Key;
var quantity = group.Count();
// Find the matching stock item
var stockItem = stockItems.FirstOrDefault(s =>
s.MaterialId == materialId && s.LengthInches == lengthInches);
if (stockItem != null)
{
purchaseItems.Add(new PurchaseItem
{
StockItemId = stockItem.Id,
Quantity = quantity,
JobId = Id!.Value,
Status = PurchaseItemStatus.Pending
});
}
}
}
if (purchaseItems.Count > 0)
{
await PurchaseItemService.CreateBulkAsync(purchaseItems);
}
await JobService.LockAsync(Id!.Value);
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = true;
}
finally
{
addingToOrderList = false;
lockingJob = false;
}
}
@@ -1,151 +0,0 @@
@page "/orders/add"
@inject PurchaseItemService PurchaseItemService
@inject StockItemService StockItemService
@inject SupplierService SupplierService
@inject JobService JobService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Add Order Item</PageTitle>
<h1>Add Order Item</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Order Item Details</h5>
</div>
<div class="card-body">
<EditForm Model="item" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Stock Item</label>
<select class="form-select" @bind="item.StockItemId">
<option value="0">-- Select Stock Item --</option>
@foreach (var group in stockItemGroups)
{
<optgroup label="@group.Key">
@foreach (var si in group.Value)
{
<option value="@si.Id">@si.Material.Size - @ArchUnits.FormatFromInches((double)si.LengthInches)</option>
}
</optgroup>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Quantity</label>
<InputNumber class="form-control" @bind-Value="item.Quantity" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Supplier (optional)</label>
<select class="form-select" @bind="item.SupplierId">
<option value="">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Job (optional)</label>
<select class="form-select" @bind="item.JobId">
<option value="">-- Select Job --</option>
@foreach (var job in jobs)
{
<option value="@job.Id">@job.DisplayName</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Notes (optional)</label>
<InputText class="form-control" @bind-Value="item.Notes" placeholder="Any notes about this order" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@saving">
@if (saving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Add to Order List
</button>
<a href="orders" class="btn btn-outline-secondary">Cancel</a>
</div>
</EditForm>
</div>
</div>
</div>
</div>
}
@code {
private PurchaseItem item = new() { Quantity = 1 };
private List<StockItem> stockItems = new();
private Dictionary<string, List<StockItem>> stockItemGroups = new();
private List<Supplier> suppliers = new();
private List<Job> jobs = new();
private bool loading = true;
private bool saving;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
stockItems = await StockItemService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
jobs = await JobService.GetAllAsync();
stockItemGroups = stockItems
.GroupBy(s => s.Material.Shape.GetDisplayName())
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.OrderBy(s => s.Material.Size).ThenBy(s => s.LengthInches).ToList());
loading = false;
}
private async Task SaveAsync()
{
errorMessage = null;
saving = true;
try
{
if (item.StockItemId == 0)
{
errorMessage = "Please select a stock item";
return;
}
if (item.Quantity <= 0)
{
errorMessage = "Quantity must be at least 1";
return;
}
await PurchaseItemService.CreateAsync(item);
Navigation.NavigateTo("orders");
}
finally
{
saving = false;
}
}
}
@@ -1,291 +0,0 @@
@page "/orders"
@inject PurchaseItemService PurchaseItemService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Orders</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>To Be Ordered</h1>
<a href="orders/add" class="btn btn-primary">Add Item</a>
</div>
<p class="text-muted mb-4">
Track material that needs to be ordered from suppliers. Items are added manually or automatically from job optimization results.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(activeTab == "pending" ? "active" : "")" @onclick='() => SetTab("pending")'>
Pending
@if (pendingCount > 0)
{
<span class="badge bg-warning text-dark ms-1">@pendingCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "ordered" ? "active" : "")" @onclick='() => SetTab("ordered")'>
Ordered
@if (orderedCount > 0)
{
<span class="badge bg-primary ms-1">@orderedCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "all" ? "active" : "")" @onclick='() => SetTab("all")'>All</button>
</li>
</ul>
@if (tabItems.Count == 0)
{
<div class="alert alert-info">
@if (activeTab == "pending")
{
<span>No pending items. <a href="orders/add">Add an item</a> or use "Add to Order List" from a job's results page.</span>
}
else if (activeTab == "ordered")
{
<span>No ordered items.</span>
}
else
{
<span>No order items found. <a href="orders/add">Add your first item</a>.</span>
}
</div>
}
else
{
<MaterialFilter AvailableGrades="availableGrades" Value="filterState" ValueChanged="OnFilterChanged" />
@if (filteredItems.Count == 0)
{
<div class="alert alert-warning">
No items match your filters.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Supplier</th>
<th>Job</th>
<th>Status</th>
<th>Notes</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in pagedItems)
{
<tr>
<td>@item.StockItem.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)</td>
<td>@item.Quantity</td>
<td>
<select class="form-select form-select-sm" style="min-width: 140px;"
value="@(item.SupplierId?.ToString() ?? "")"
@onchange="(e) => OnSupplierChanged(item, e)">
<option value="">-- Select --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</td>
<td>
@if (item.Job != null)
{
<a href="jobs/@item.Job.Id">@item.Job.DisplayName</a>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td>
<span class="badge @GetStatusBadgeClass(item.Status)">@item.Status</span>
</td>
<td>
<span class="text-muted small">@(item.Notes ?? "-")</span>
</td>
<td>
<div class="d-flex gap-1">
@if (item.Status == PurchaseItemStatus.Pending)
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => MarkOrdered(item)" title="Mark Ordered">
<i class="bi bi-truck"></i>
</button>
}
@if (item.Status == PurchaseItemStatus.Ordered)
{
<button class="btn btn-sm btn-outline-success" @onclick="() => MarkReceived(item)" title="Mark Received">
<i class="bi bi-check-lg"></i>
</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="filteredItems.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Order Item"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<PurchaseItem> allItems = new();
private List<Supplier> suppliers = new();
private bool loading = true;
private string activeTab = "pending";
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private PurchaseItem? itemToDelete;
private string deleteMessage = "";
private MaterialFilterState filterState = new();
private int pendingCount => allItems.Count(i => i.Status == PurchaseItemStatus.Pending);
private int orderedCount => allItems.Count(i => i.Status == PurchaseItemStatus.Ordered);
private List<PurchaseItem> tabItems => activeTab switch
{
"pending" => allItems.Where(i => i.Status == PurchaseItemStatus.Pending).ToList(),
"ordered" => allItems.Where(i => i.Status == PurchaseItemStatus.Ordered).ToList(),
_ => allItems
};
private List<PurchaseItem> filteredItems => tabItems.Where(i =>
{
var m = i.StockItem.Material;
if (filterState.Shape.HasValue && m.Shape != filterState.Shape.Value)
return false;
if (filterState.Type.HasValue && m.Type != filterState.Type.Value)
return false;
if (!string.IsNullOrEmpty(filterState.Grade) && m.Grade != filterState.Grade)
return false;
if (!string.IsNullOrWhiteSpace(filterState.SearchText))
{
var search = filterState.SearchText.Trim();
if (!Contains(m.Size, search)
&& !Contains(m.Grade, search)
&& !Contains(m.Shape.GetDisplayName(), search)
&& !Contains(i.Notes, search)
&& !Contains(i.Job?.DisplayName, search)
&& !Contains(i.Supplier?.Name, search))
return false;
}
return true;
}).ToList();
private IEnumerable<string> availableGrades => tabItems
.Select(i => i.StockItem.Material.Grade)
.Where(g => !string.IsNullOrEmpty(g))
.Distinct()
.OrderBy(g => g)!;
private IEnumerable<PurchaseItem> pagedItems => filteredItems
.Skip((currentPage - 1) * pageSize)
.Take(pageSize);
protected override async Task OnInitializedAsync()
{
allItems = await PurchaseItemService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
loading = false;
}
private void SetTab(string tab)
{
activeTab = tab;
currentPage = 1;
filterState = new();
}
private void OnFilterChanged(MaterialFilterState state)
{
filterState = state;
currentPage = 1;
}
private async Task OnSupplierChanged(PurchaseItem item, ChangeEventArgs e)
{
int? supplierId = int.TryParse(e.Value?.ToString(), out var id) && id > 0 ? id : null;
await PurchaseItemService.UpdateSupplierAsync(item.Id, supplierId);
item.SupplierId = supplierId;
item.Supplier = supplierId.HasValue ? suppliers.FirstOrDefault(s => s.Id == supplierId.Value) : null;
}
private async Task MarkOrdered(PurchaseItem item)
{
await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Ordered);
item.Status = PurchaseItemStatus.Ordered;
}
private async Task MarkReceived(PurchaseItem item)
{
await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Received);
allItems = await PurchaseItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
private void ConfirmDelete(PurchaseItem item)
{
itemToDelete = item;
deleteMessage = $"Are you sure you want to delete this order item ({item.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)} x{item.Quantity})?";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (itemToDelete != null)
{
await PurchaseItemService.DeleteAsync(itemToDelete.Id);
allItems = await PurchaseItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private static string GetStatusBadgeClass(PurchaseItemStatus status) => status switch
{
PurchaseItemStatus.Pending => "bg-warning text-dark",
PurchaseItemStatus.Ordered => "bg-primary",
PurchaseItemStatus.Received => "bg-success",
_ => "bg-secondary"
};
private static bool Contains(string? value, string search) =>
value != null && value.Contains(search, StringComparison.OrdinalIgnoreCase);
}
+15 -259
View File
@@ -2,7 +2,6 @@
@page "/stock/{Id:int}"
@inject StockItemService StockItemService
@inject MaterialService MaterialService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@@ -39,14 +38,7 @@ else
<div class="mb-3">
<label class="form-label">Length</label>
@if (IsNew)
{
<LengthInput @bind-Value="stockItem.LengthInches" />
}
else
{
<input type="text" class="form-control" value="@ArchUnits.FormatFromInches((double)stockItem.LengthInches)" readonly />
}
<LengthInput @bind-Value="stockItem.LengthInches" />
</div>
<div class="mb-3">
@@ -82,7 +74,7 @@ else
@if (!IsNew)
{
<div class="col-lg-6 mb-4">
<div class="card mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
Inventory
@@ -112,23 +104,6 @@ else
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="stockNotes" />
</div>
@if (stockTransactionType == "add")
{
<div class="col-md-6">
<label class="form-label">Supplier (optional)</label>
<select class="form-select" @bind="stockSupplierId">
<option value="0">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Unit Price (optional)</label>
<input type="number" class="form-control" @bind="stockUnitPrice" step="0.01" min="0" placeholder="0.00" />
</div>
}
</div>
@if (!string.IsNullOrEmpty(stockFormErrorMessage))
{
@@ -159,8 +134,6 @@ else
<th>Date</th>
<th>Type</th>
<th>Qty</th>
<th>Supplier</th>
<th>Price</th>
<th>Notes</th>
</tr>
</thead>
@@ -175,8 +148,6 @@ else
<td class="@(txn.Quantity >= 0 ? "text-success" : "text-danger")">
@(txn.Quantity >= 0 ? "+" : "")@txn.Quantity
</td>
<td>@(txn.Supplier?.Name ?? "-")</td>
<td>@(txn.UnitPrice.HasValue ? txn.UnitPrice.Value.ToString("C") : "-")</td>
<td>@(txn.Notes ?? "-")</td>
</tr>
}
@@ -185,145 +156,35 @@ else
}
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Supplier Offerings</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
</div>
<div class="card-body">
@if (showOfferingForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
<div class="row g-2">
<div class="col-12">
<label class="form-label">Supplier</label>
<select class="form-select" @bind="newOffering.SupplierId">
<option value="0">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Part Number</label>
<InputText class="form-control" @bind-Value="newOffering.PartNumber" />
</div>
<div class="col-md-6">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
</div>
<div class="col-12">
<label class="form-label">Supplier Description</label>
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="newOffering.Notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(offeringErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
@if (savingOffering)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingOffering == null ? "Add" : "Save")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
</div>
</div>
}
@if (offerings.Count == 0)
{
<p class="text-muted">No supplier offerings configured yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Supplier</th>
<th>Part #</th>
<th>Price</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var offering in offerings)
{
<tr>
<td>@offering.Supplier.Name</td>
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
}
</div>
}
<ConfirmDialog @ref="deleteOfferingDialog"
Title="Delete Offering"
Message="@deleteOfferingMessage"
ConfirmText="Delete"
OnConfirm="DeleteOfferingConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private StockItem stockItem = new();
private List<Material> materials = new();
private List<Supplier> suppliers = new();
private List<SupplierOffering> offerings = new();
private List<StockTransaction> transactions = new();
private bool loading = true;
private bool saving;
private bool savingOffering;
private string? errorMessage;
private string? offeringErrorMessage;
private bool showOfferingForm;
private SupplierOffering newOffering = new();
private SupplierOffering? editingOffering;
// Stock transaction form
private bool showStockForm;
private bool savingStockTransaction;
private string stockTransactionType = "add";
private int stockQuantity;
private int stockSupplierId;
private decimal? stockUnitPrice;
private string? stockNotes;
private string? stockFormErrorMessage;
private ConfirmDialog deleteOfferingDialog = null!;
private SupplierOffering? offeringToDelete;
private string deleteOfferingMessage = "";
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
if (Id.HasValue)
{
@@ -334,7 +195,6 @@ else
return;
}
stockItem = existing;
offerings = existing.SupplierOfferings.Where(o => o.IsActive).ToList();
transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20);
}
loading = false;
@@ -354,8 +214,6 @@ else
{
stockTransactionType = "add";
stockQuantity = 0;
stockSupplierId = 0;
stockUnitPrice = null;
stockNotes = null;
stockFormErrorMessage = null;
showStockForm = true;
@@ -389,12 +247,7 @@ else
switch (stockTransactionType)
{
case "add":
await StockItemService.AddStockAsync(
Id!.Value,
stockQuantity,
stockSupplierId > 0 ? stockSupplierId : null,
stockUnitPrice,
stockNotes);
await StockItemService.AddStockAsync(Id!.Value, stockQuantity, stockNotes);
break;
case "adjust":
await StockItemService.AdjustStockAsync(Id!.Value, stockQuantity, stockNotes);
@@ -449,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
@@ -464,108 +324,4 @@ else
saving = false;
}
}
private void ShowAddOfferingForm()
{
editingOffering = null;
newOffering = new SupplierOffering { StockItemId = Id!.Value };
showOfferingForm = true;
offeringErrorMessage = null;
}
private void EditOffering(SupplierOffering offering)
{
editingOffering = offering;
newOffering = new SupplierOffering
{
Id = offering.Id,
StockItemId = offering.StockItemId,
SupplierId = offering.SupplierId,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
};
showOfferingForm = true;
offeringErrorMessage = null;
}
private void CancelOfferingForm()
{
showOfferingForm = false;
editingOffering = null;
offeringErrorMessage = null;
}
private async Task SaveOfferingAsync()
{
offeringErrorMessage = null;
savingOffering = true;
try
{
if (newOffering.SupplierId == 0)
{
offeringErrorMessage = "Please select a supplier";
return;
}
var exists = await SupplierService.OfferingExistsAsync(
newOffering.SupplierId,
newOffering.StockItemId,
editingOffering?.Id);
if (exists)
{
offeringErrorMessage = "This supplier already has an offering for this stock item";
return;
}
if (editingOffering == null)
{
await SupplierService.AddOfferingAsync(newOffering);
}
else
{
await SupplierService.UpdateOfferingAsync(newOffering);
}
// Refresh the stock item to get updated offerings
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
}
showOfferingForm = false;
editingOffering = null;
}
finally
{
savingOffering = false;
}
}
private void ConfirmDeleteOffering(SupplierOffering offering)
{
offeringToDelete = offering;
deleteOfferingMessage = $"Are you sure you want to delete the offering from {offering.Supplier.Name}?";
deleteOfferingDialog.Show();
}
private async Task DeleteOfferingConfirmed()
{
if (offeringToDelete != null)
{
await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
// Refresh the stock item to get updated offerings
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
}
}
}
}
@@ -1,333 +0,0 @@
@page "/suppliers/new"
@page "/suppliers/{Id:int}"
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@inject CutList.Web.Data.ApplicationDbContext DbContext
@using CutList.Core.Formatting
@using Microsoft.EntityFrameworkCore
<PageTitle>@(IsNew ? "Add Supplier" : "Edit Supplier")</PageTitle>
<h1>@(IsNew ? "Add Supplier" : supplier.Name)</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Supplier Details</h5>
</div>
<div class="card-body">
<EditForm Model="supplier" OnValidSubmit="SaveSupplierAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="supplier.Name" />
<ValidationMessage For="@(() => supplier.Name)" />
</div>
<div class="mb-3">
<label class="form-label">Contact Info</label>
<InputTextArea class="form-control" @bind-Value="supplier.ContactInfo" rows="2" placeholder="Phone, email, address..." />
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="supplier.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(supplierErrorMessage))
{
<div class="alert alert-danger">@supplierErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingSupplier">
@if (savingSupplier)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Supplier" : "Save Changes")
</button>
<a href="suppliers" class="btn btn-outline-secondary">@(IsNew ? "Cancel" : "Back to List")</a>
</div>
</EditForm>
</div>
</div>
</div>
@if (!IsNew)
{
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Supplier Offerings</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
</div>
<div class="card-body">
@if (showOfferingForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
<div class="row g-2">
<div class="col-12">
<label class="form-label">Stock Item</label>
<select class="form-select" @bind="newOffering.StockItemId">
<option value="0">-- Select Stock Item --</option>
@foreach (var stockItem in stockItems)
{
<option value="@stockItem.Id">@stockItem.Material.DisplayName - @ArchUnits.FormatFromInches((double)stockItem.LengthInches)</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Part Number</label>
<InputText class="form-control" @bind-Value="newOffering.PartNumber" />
</div>
<div class="col-md-6">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
</div>
<div class="col-12">
<label class="form-label">Supplier Description</label>
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="newOffering.Notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(offeringErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
@if (savingOffering)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingOffering == null ? "Add" : "Save")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
</div>
</div>
}
@if (offerings.Count == 0)
{
<p class="text-muted">No offerings configured yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Stock Item</th>
<th>Part #</th>
<th>Price</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var offering in offerings)
{
<tr>
<td>@offering.StockItem.Material.DisplayName - @ArchUnits.FormatFromInches((double)offering.StockItem.LengthInches)</td>
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
}
</div>
}
<ConfirmDialog @ref="deleteOfferingDialog"
Title="Delete Offering"
Message="@deleteOfferingMessage"
ConfirmText="Delete"
OnConfirm="DeleteOfferingConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private Supplier supplier = new();
private List<SupplierOffering> offerings = new();
private List<StockItem> stockItems = new();
private bool loading = true;
private bool savingSupplier;
private bool savingOffering;
private string? supplierErrorMessage;
private string? offeringErrorMessage;
private bool showOfferingForm;
private SupplierOffering newOffering = new();
private SupplierOffering? editingOffering;
private ConfirmDialog deleteOfferingDialog = null!;
private SupplierOffering? offeringToDelete;
private string deleteOfferingMessage = "";
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
stockItems = await DbContext.StockItems
.Include(si => si.Material)
.Where(si => si.IsActive)
.OrderBy(si => si.Material.Shape)
.ThenBy(si => si.Material.Size)
.ThenBy(si => si.LengthInches)
.ToListAsync();
if (Id.HasValue)
{
var existing = await SupplierService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("suppliers");
return;
}
supplier = existing;
offerings = await SupplierService.GetOfferingsForSupplierAsync(Id.Value);
}
loading = false;
}
private async Task SaveSupplierAsync()
{
supplierErrorMessage = null;
savingSupplier = true;
try
{
if (string.IsNullOrWhiteSpace(supplier.Name))
{
supplierErrorMessage = "Name is required";
return;
}
if (IsNew)
{
var created = await SupplierService.CreateAsync(supplier);
Navigation.NavigateTo($"suppliers/{created.Id}");
}
else
{
await SupplierService.UpdateAsync(supplier);
}
}
finally
{
savingSupplier = false;
}
}
private void ShowAddOfferingForm()
{
editingOffering = null;
newOffering = new SupplierOffering { SupplierId = Id!.Value };
showOfferingForm = true;
offeringErrorMessage = null;
}
private void EditOffering(SupplierOffering offering)
{
editingOffering = offering;
newOffering = new SupplierOffering
{
Id = offering.Id,
SupplierId = offering.SupplierId,
StockItemId = offering.StockItemId,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
};
showOfferingForm = true;
offeringErrorMessage = null;
}
private void CancelOfferingForm()
{
showOfferingForm = false;
editingOffering = null;
offeringErrorMessage = null;
}
private async Task SaveOfferingAsync()
{
offeringErrorMessage = null;
savingOffering = true;
try
{
if (newOffering.StockItemId == 0)
{
offeringErrorMessage = "Please select a stock item";
return;
}
var exists = await SupplierService.OfferingExistsAsync(
newOffering.SupplierId,
newOffering.StockItemId,
editingOffering?.Id);
if (exists)
{
offeringErrorMessage = "This supplier already has an offering for this stock item";
return;
}
if (editingOffering == null)
{
await SupplierService.AddOfferingAsync(newOffering);
}
else
{
await SupplierService.UpdateOfferingAsync(newOffering);
}
offerings = await SupplierService.GetOfferingsForSupplierAsync(Id!.Value);
showOfferingForm = false;
editingOffering = null;
}
finally
{
savingOffering = false;
}
}
private void ConfirmDeleteOffering(SupplierOffering offering)
{
offeringToDelete = offering;
deleteOfferingMessage = $"Are you sure you want to delete the offering for {offering.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)offering.StockItem.LengthInches)}?";
deleteOfferingDialog.Show();
}
private async Task DeleteOfferingConfirmed()
{
if (offeringToDelete != null)
{
await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
offerings = await SupplierService.GetOfferingsForSupplierAsync(Id!.Value);
}
}
}
@@ -1,105 +0,0 @@
@page "/suppliers"
@inject SupplierService SupplierService
@inject NavigationManager Navigation
<PageTitle>Suppliers</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Suppliers</h1>
<a href="suppliers/new" class="btn btn-primary">Add Supplier</a>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (suppliers.Count == 0)
{
<div class="alert alert-info">
No suppliers found. <a href="suppliers/new">Add your first supplier</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Contact Info</th>
<th>Notes</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var supplier in pagedSuppliers)
{
<tr>
<td><a href="suppliers/@supplier.Id">@supplier.Name</a></td>
<td>@supplier.ContactInfo</td>
<td>@TruncateText(supplier.Notes, 50)</td>
<td>
<div class="d-flex gap-1">
<a href="suppliers/@supplier.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(supplier)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="suppliers.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Supplier"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<Supplier> suppliers = new();
private bool loading = true;
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private Supplier? supplierToDelete;
private string deleteMessage = "";
private IEnumerable<Supplier> pagedSuppliers => suppliers.Skip((currentPage - 1) * pageSize).Take(pageSize);
protected override async Task OnInitializedAsync()
{
suppliers = await SupplierService.GetAllAsync();
loading = false;
}
private void ConfirmDelete(Supplier supplier)
{
supplierToDelete = supplier;
deleteMessage = $"Are you sure you want to delete \"{supplier.Name}\"? This will also remove all stock lengths associated with this supplier.";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (supplierToDelete != null)
{
await SupplierService.DeleteAsync(supplierToDelete.Id);
suppliers = await SupplierService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)suppliers.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private string? TruncateText(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
return text;
return text.Substring(0, maxLength) + "...";
}
}
@@ -11,12 +11,10 @@ namespace CutList.Web.Controllers;
public class StockItemsController : ControllerBase
{
private readonly StockItemService _stockItemService;
private readonly SupplierService _supplierService;
public StockItemsController(StockItemService stockItemService, SupplierService supplierService)
public StockItemsController(StockItemService stockItemService)
{
_stockItemService = stockItemService;
_supplierService = supplierService;
}
[HttpGet]
@@ -120,34 +118,6 @@ public class StockItemsController : ControllerBase
return Ok(items.Select(MapToDto).ToList());
}
[HttpGet("{id}/offerings")]
public async Task<ActionResult<List<OfferingDto>>> GetOfferings(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
var offerings = await _supplierService.GetOfferingsForStockItemAsync(id);
return Ok(offerings.Select(MapOfferingToDto).ToList());
}
[HttpGet("{id}/pricing")]
public async Task<ActionResult<StockPricingDto>> GetPricing(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
var avgCost = await _stockItemService.GetAverageCostAsync(id);
var lastPrice = await _stockItemService.GetLastPurchasePriceAsync(id);
return Ok(new StockPricingDto
{
AverageCost = avgCost,
LastPurchasePrice = lastPrice
});
}
[HttpGet("{id}/transactions")]
public async Task<ActionResult<List<StockTransactionDto>>> GetTransactions(int id, [FromQuery] int? limit = null)
{
@@ -164,7 +134,7 @@ public class StockItemsController : ControllerBase
{
try
{
var transaction = await _stockItemService.AddStockAsync(id, dto.Quantity, dto.SupplierId, dto.UnitPrice, dto.Notes);
var transaction = await _stockItemService.AddStockAsync(id, dto.Quantity, dto.Notes);
return Ok(MapTransactionToDto(transaction));
}
catch (InvalidOperationException)
@@ -250,23 +220,7 @@ public class StockItemsController : ControllerBase
Type = t.Type.ToString(),
JobId = t.JobId,
JobNumber = t.Job?.JobNumber,
SupplierId = t.SupplierId,
SupplierName = t.Supplier?.Name,
UnitPrice = t.UnitPrice,
Notes = t.Notes,
CreatedAt = t.CreatedAt
};
private static OfferingDto MapOfferingToDto(SupplierOffering o) => new()
{
Id = o.Id,
SupplierId = o.SupplierId,
SupplierName = o.Supplier?.Name,
StockItemId = o.StockItemId,
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes,
IsActive = o.IsActive
};
}
@@ -1,172 +0,0 @@
using CutList.Core.Formatting;
using CutList.Web.Data.Entities;
using CutList.Web.DTOs;
using CutList.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace CutList.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private readonly SupplierService _supplierService;
public SuppliersController(SupplierService supplierService)
{
_supplierService = supplierService;
}
[HttpGet]
public async Task<ActionResult<List<SupplierDto>>> GetAll([FromQuery] bool includeInactive = false)
{
var suppliers = await _supplierService.GetAllAsync(includeInactive);
return Ok(suppliers.Select(MapToDto).ToList());
}
[HttpGet("{id}")]
public async Task<ActionResult<SupplierDto>> GetById(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
return Ok(MapToDto(supplier));
}
[HttpPost]
public async Task<ActionResult<SupplierDto>> Create(CreateSupplierDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name))
return BadRequest("Name is required");
var supplier = new Supplier
{
Name = dto.Name,
ContactInfo = dto.ContactInfo,
Notes = dto.Notes
};
await _supplierService.CreateAsync(supplier);
return CreatedAtAction(nameof(GetById), new { id = supplier.Id }, MapToDto(supplier));
}
[HttpPut("{id}")]
public async Task<ActionResult<SupplierDto>> Update(int id, UpdateSupplierDto dto)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
if (dto.Name != null) supplier.Name = dto.Name;
if (dto.ContactInfo != null) supplier.ContactInfo = dto.ContactInfo;
if (dto.Notes != null) supplier.Notes = dto.Notes;
await _supplierService.UpdateAsync(supplier);
return Ok(MapToDto(supplier));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
await _supplierService.DeleteAsync(id);
return NoContent();
}
// --- Offerings ---
[HttpGet("{id}/offerings")]
public async Task<ActionResult<List<OfferingDto>>> GetOfferings(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
var offerings = await _supplierService.GetOfferingsForSupplierAsync(id);
return Ok(offerings.Select(MapOfferingToDto).ToList());
}
[HttpPost("{id}/offerings")]
public async Task<ActionResult<OfferingDto>> CreateOffering(int id, CreateOfferingDto dto)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
var exists = await _supplierService.OfferingExistsAsync(id, dto.StockItemId);
if (exists)
return Conflict("An offering for this supplier and stock item already exists");
var offering = new SupplierOffering
{
SupplierId = id,
StockItemId = dto.StockItemId,
PartNumber = dto.PartNumber,
SupplierDescription = dto.SupplierDescription,
Price = dto.Price,
Notes = dto.Notes
};
await _supplierService.AddOfferingAsync(offering);
// Reload with includes
var created = await _supplierService.GetOfferingByIdAsync(offering.Id);
return CreatedAtAction(nameof(GetOfferings), new { id }, MapOfferingToDto(created!));
}
[HttpPut("{supplierId}/offerings/{offeringId}")]
public async Task<ActionResult<OfferingDto>> UpdateOffering(int supplierId, int offeringId, UpdateOfferingDto dto)
{
var offering = await _supplierService.GetOfferingByIdAsync(offeringId);
if (offering == null || offering.SupplierId != supplierId)
return NotFound();
if (dto.PartNumber != null) offering.PartNumber = dto.PartNumber;
if (dto.SupplierDescription != null) offering.SupplierDescription = dto.SupplierDescription;
if (dto.Price.HasValue) offering.Price = dto.Price;
if (dto.Notes != null) offering.Notes = dto.Notes;
await _supplierService.UpdateOfferingAsync(offering);
return Ok(MapOfferingToDto(offering));
}
[HttpDelete("{supplierId}/offerings/{offeringId}")]
public async Task<IActionResult> DeleteOffering(int supplierId, int offeringId)
{
var offering = await _supplierService.GetOfferingByIdAsync(offeringId);
if (offering == null || offering.SupplierId != supplierId)
return NotFound();
await _supplierService.DeleteOfferingAsync(offeringId);
return NoContent();
}
private static SupplierDto MapToDto(Supplier s) => new()
{
Id = s.Id,
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes,
IsActive = s.IsActive
};
private static OfferingDto MapOfferingToDto(SupplierOffering o) => new()
{
Id = o.Id,
SupplierId = o.SupplierId,
SupplierName = o.Supplier?.Name,
StockItemId = o.StockItemId,
MaterialName = o.StockItem?.Material?.DisplayName,
LengthInches = o.StockItem?.LengthInches,
LengthFormatted = o.StockItem != null ? ArchUnits.FormatFromInches((double)o.StockItem.LengthInches) : null,
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes,
IsActive = o.IsActive
};
}
-22
View File
@@ -3,18 +3,10 @@ namespace CutList.Web.DTOs;
public class CatalogData
{
public DateTime ExportedAt { get; set; }
public List<CatalogSupplierDto> Suppliers { get; set; } = [];
public List<CatalogCuttingToolDto> CuttingTools { get; set; } = [];
public CatalogMaterialsDto Materials { get; set; } = new();
}
public class CatalogSupplierDto
{
public string Name { get; set; } = "";
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class CatalogCuttingToolDto
{
public string Name { get; set; } = "";
@@ -113,30 +105,16 @@ public class CatalogStockItemDto
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public List<CatalogSupplierOfferingDto> SupplierOfferings { get; set; } = [];
}
public class CatalogSupplierOfferingDto
{
public string SupplierName { get; set; } = "";
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class ImportResultDto
{
public int SuppliersCreated { get; set; }
public int SuppliersUpdated { get; set; }
public int CuttingToolsCreated { get; set; }
public int CuttingToolsUpdated { get; set; }
public int MaterialsCreated { get; set; }
public int MaterialsUpdated { get; set; }
public int StockItemsCreated { get; set; }
public int StockItemsUpdated { get; set; }
public int OfferingsCreated { get; set; }
public int OfferingsUpdated { get; set; }
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
}
-11
View File
@@ -37,9 +37,6 @@ public class StockTransactionDto
public string Type { get; set; } = string.Empty;
public int? JobId { get; set; }
public string? JobNumber { get; set; }
public int? SupplierId { get; set; }
public string? SupplierName { get; set; }
public decimal? UnitPrice { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -47,8 +44,6 @@ public class StockTransactionDto
public class AddStockDto
{
public int Quantity { get; set; }
public int? SupplierId { get; set; }
public decimal? UnitPrice { get; set; }
public string? Notes { get; set; }
}
@@ -70,9 +65,3 @@ public class ScrapStockDto
public int Quantity { get; set; }
public string? Notes { get; set; }
}
public class StockPricingDto
{
public decimal? AverageCost { get; set; }
public decimal? LastPurchasePrice { get; set; }
}
-57
View File
@@ -1,57 +0,0 @@
namespace CutList.Web.DTOs;
public class SupplierDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class CreateSupplierDto
{
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class UpdateSupplierDto
{
public string? Name { get; set; }
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class OfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string? SupplierName { get; set; }
public int StockItemId { get; set; }
public string? MaterialName { get; set; }
public decimal? LengthInches { get; set; }
public string? LengthFormatted { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class CreateOfferingDto
{
public int StockItemId { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class UpdateOfferingDto
{
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
-68
View File
@@ -12,15 +12,12 @@ public class ApplicationDbContext : DbContext
public DbSet<Material> Materials => Set<Material>();
public DbSet<MaterialDimensions> MaterialDimensions => Set<MaterialDimensions>();
public DbSet<Supplier> Suppliers => Set<Supplier>();
public DbSet<StockItem> StockItems => Set<StockItem>();
public DbSet<SupplierOffering> SupplierOfferings => Set<SupplierOffering>();
public DbSet<StockTransaction> StockTransactions => Set<StockTransaction>();
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
public DbSet<Job> Jobs => Set<Job>();
public DbSet<JobPart> JobParts => Set<JobPart>();
public DbSet<JobStock> JobStocks => Set<JobStock>();
public DbSet<PurchaseItem> PurchaseItems => Set<PurchaseItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -143,15 +140,6 @@ public class ApplicationDbContext : DbContext
entity.HasIndex(e => e.NominalSize);
});
// Supplier
modelBuilder.Entity<Supplier>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
entity.Property(e => e.ContactInfo).HasMaxLength(500);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
});
// StockItem
modelBuilder.Entity<StockItem>(entity =>
{
@@ -174,7 +162,6 @@ public class ApplicationDbContext : DbContext
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.UnitPrice).HasPrecision(10, 2);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.HasOne(e => e.StockItem)
@@ -186,33 +173,6 @@ public class ApplicationDbContext : DbContext
.WithMany()
.HasForeignKey(e => e.JobId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Supplier)
.WithMany()
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.SetNull);
});
// SupplierOffering
modelBuilder.Entity<SupplierOffering>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.PartNumber).HasMaxLength(100);
entity.Property(e => e.SupplierDescription).HasMaxLength(255);
entity.Property(e => e.Price).HasPrecision(10, 2);
entity.Property(e => e.Notes).HasMaxLength(255);
entity.HasOne(e => e.StockItem)
.WithMany(s => s.SupplierOfferings)
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Supplier)
.WithMany(s => s.Offerings)
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => new { e.SupplierId, e.StockItemId }).IsUnique();
});
// CuttingTool
@@ -282,34 +242,6 @@ public class ApplicationDbContext : DbContext
.OnDelete(DeleteBehavior.SetNull);
});
// PurchaseItem
modelBuilder.Entity<PurchaseItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.Status)
.HasMaxLength(20)
.HasConversion(
v => v.ToString(),
v => Enum.Parse<PurchaseItemStatus>(v));
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.HasOne(e => e.StockItem)
.WithMany()
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Supplier)
.WithMany()
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Job)
.WithMany()
.HasForeignKey(e => e.JobId)
.OnDelete(DeleteBehavior.SetNull);
});
// Seed default cutting tools
modelBuilder.Entity<CuttingTool>().HasData(
new CuttingTool { Id = 1, Name = "Bandsaw", KerfInches = 0.0625m, IsDefault = true, IsActive = true },
-25
View File
@@ -1,25 +0,0 @@
namespace CutList.Web.Data.Entities;
public enum PurchaseItemStatus
{
Pending,
Ordered,
Received
}
public class PurchaseItem
{
public int Id { get; set; }
public int StockItemId { get; set; }
public int? SupplierId { get; set; }
public int Quantity { get; set; }
public int? JobId { get; set; }
public string? Notes { get; set; }
public PurchaseItemStatus Status { get; set; } = PurchaseItemStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public StockItem StockItem { get; set; } = null!;
public Supplier? Supplier { get; set; }
public Job? Job { get; set; }
}
-1
View File
@@ -13,6 +13,5 @@ public class StockItem
public DateTime? UpdatedAt { get; set; }
public Material Material { get; set; } = null!;
public ICollection<SupplierOffering> SupplierOfferings { get; set; } = new List<SupplierOffering>();
public ICollection<StockTransaction> Transactions { get; set; } = new List<StockTransaction>();
}
@@ -7,14 +7,11 @@ public class StockTransaction
public int Quantity { get; set; }
public StockTransactionType Type { get; set; }
public int? JobId { get; set; }
public int? SupplierId { get; set; }
public decimal? UnitPrice { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public StockItem StockItem { get; set; } = null!;
public Job? Job { get; set; }
public Supplier? Supplier { get; set; }
}
public enum StockTransactionType
-13
View File
@@ -1,13 +0,0 @@
namespace CutList.Web.Data.Entities;
public class Supplier
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SupplierOffering> Offerings { get; set; } = new List<SupplierOffering>();
}
@@ -1,16 +0,0 @@
namespace CutList.Web.Data.Entities;
public class SupplierOffering
{
public int Id { get; set; }
public int StockItemId { get; set; }
public int SupplierId { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public StockItem StockItem { get; set; } = null!;
public Supplier Supplier { get; set; } = null!;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,684 @@
// <auto-generated />
using System;
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CutList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260801124832_RemoveVendorData")]
partial class RemoveVendorData
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.HasSequence("MaterialDimensionsSequence");
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<decimal>("KerfInches")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("CuttingTools");
b.HasData(
new
{
Id = 1,
IsActive = true,
IsDefault = true,
KerfInches = 0.0625m,
Name = "Bandsaw"
},
new
{
Id = 2,
IsActive = true,
IsDefault = false,
KerfInches = 0.125m,
Name = "Chop Saw"
},
new
{
Id = 3,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Cold Cut Saw"
},
new
{
Id = 4,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Hacksaw"
});
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Customer")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("CuttingToolId")
.HasColumnType("int");
b.Property<string>("JobNumber")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("LockedAt")
.HasColumnType("datetime2");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OptimizationResultJson")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("OptimizedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("CuttingToolId");
b.HasIndex("JobNumber")
.IsUnique();
b.ToTable("Jobs");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("JobId")
.HasColumnType("int");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("MaterialId");
b.ToTable("JobParts");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsCustomLength")
.HasColumnType("bit");
b.Property<int>("JobId")
.HasColumnType("int");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int?>("StockItemId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("MaterialId");
b.HasIndex("StockItemId");
b.ToTable("JobStocks");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Description")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("Grade")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Shape")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Size")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("Materials");
});
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialDimensions", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValueSql("NEXT VALUE FOR [MaterialDimensionsSequence]");
SqlServerPropertyBuilderExtensions.UseSequence(b.Property<int>("Id"));
b.Property<int>("MaterialId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId")
.IsUnique();
b.ToTable((string)null);
b.UseTpcMappingStrategy();
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("QuantityOnHand")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MaterialId", "LengthInches")
.IsUnique();
b.ToTable("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("JobId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.ToTable("StockTransactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Leg1")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Leg2")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Thickness")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Leg1");
b.ToTable("DimAngle", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.ChannelDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Flange")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Web")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("DimChannel", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Thickness")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("DimFlatBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("WeightPerFoot")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("DimIBeam", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.PipeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("NominalSize")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<string>("Schedule")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal?>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("NominalSize");
b.ToTable("DimPipe", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("DimRectangularTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Diameter")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Diameter");
b.ToTable("DimRoundBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("OuterDiameter")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("OuterDiameter");
b.ToTable("DimRoundTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("DimSquareBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("DimSquareTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
{
b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool")
.WithMany("Jobs")
.HasForeignKey("CuttingToolId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CuttingTool");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany("Parts")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("JobParts")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Job");
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany("Stock")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany()
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany()
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("Material");
b.Navigation("StockItem");
});
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithOne("Dimensions")
.HasForeignKey("CutList.Web.Data.Entities.MaterialDimensions", "MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("StockItems")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("Transactions")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
b.Navigation("StockItem");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
{
b.Navigation("Parts");
b.Navigation("Stock");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Navigation("Dimensions");
b.Navigation("JobParts");
b.Navigation("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("Transactions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,183 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class RemoveVendorData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_StockTransactions_Suppliers_SupplierId",
table: "StockTransactions");
migrationBuilder.DropTable(
name: "PurchaseItems");
migrationBuilder.DropTable(
name: "SupplierOfferings");
migrationBuilder.DropTable(
name: "Suppliers");
migrationBuilder.DropIndex(
name: "IX_StockTransactions_SupplierId",
table: "StockTransactions");
migrationBuilder.DropColumn(
name: "SupplierId",
table: "StockTransactions");
migrationBuilder.DropColumn(
name: "UnitPrice",
table: "StockTransactions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SupplierId",
table: "StockTransactions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "UnitPrice",
table: "StockTransactions",
type: "decimal(10,2)",
precision: 10,
scale: 2,
nullable: true);
migrationBuilder.CreateTable(
name: "Suppliers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ContactInfo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Suppliers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PurchaseItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
JobId = table.Column<int>(type: "int", nullable: true),
StockItemId = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Quantity = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseItems_Jobs_JobId",
column: x => x.JobId,
principalTable: "Jobs",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PurchaseItems_StockItems_StockItemId",
column: x => x.StockItemId,
principalTable: "StockItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PurchaseItems_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "SupplierOfferings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StockItemId = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
PartNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Price = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true),
SupplierDescription = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierOfferings", x => x.Id);
table.ForeignKey(
name: "FK_SupplierOfferings_StockItems_StockItemId",
column: x => x.StockItemId,
principalTable: "StockItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SupplierOfferings_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StockTransactions_SupplierId",
table: "StockTransactions",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_JobId",
table: "PurchaseItems",
column: "JobId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_StockItemId",
table: "PurchaseItems",
column: "StockItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_SupplierId",
table: "PurchaseItems",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_SupplierOfferings_StockItemId",
table: "SupplierOfferings",
column: "StockItemId");
migrationBuilder.CreateIndex(
name: "IX_SupplierOfferings_SupplierId_StockItemId",
table: "SupplierOfferings",
columns: new[] { "SupplierId", "StockItemId" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_StockTransactions_Suppliers_SupplierId",
table: "StockTransactions",
column: "SupplierId",
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}
@@ -17,7 +17,7 @@ namespace CutList.Web.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -294,54 +294,6 @@ namespace CutList.Web.Migrations
b.UseTpcMappingStrategy();
});
modelBuilder.Entity("CutList.Web.Data.Entities.PurchaseItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("JobId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int?>("SupplierId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId");
b.ToTable("PurchaseItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Property<int>("Id")
@@ -413,103 +365,18 @@ namespace CutList.Web.Migrations
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int?>("SupplierId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<decimal?>("UnitPrice")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId");
b.ToTable("StockTransactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContactInfo")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Suppliers");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("PartNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<string>("SupplierDescription")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("SupplierId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId", "StockItemId")
.IsUnique();
b.ToTable("SupplierOfferings");
});
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
@@ -754,31 +621,6 @@ namespace CutList.Web.Migrations
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.PurchaseItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany()
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
@@ -803,35 +645,9 @@ namespace CutList.Web.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("SupplierOfferings")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Offerings")
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
@@ -857,15 +673,8 @@ namespace CutList.Web.Migrations
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("SupplierOfferings");
b.Navigation("Transactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Navigation("Offerings");
});
#pragma warning restore 612, 618
}
}
-2
View File
@@ -25,12 +25,10 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
// Add application services
builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<JobService>();
builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<PurchaseItemService>();
builder.Services.AddScoped<CatalogService>();
var app = builder.Build();
+23 -148
View File
@@ -20,12 +20,6 @@ public class CatalogService
{
await using var context = _factory.CreateDbContext();
var suppliers = await context.Suppliers
.Where(s => s.IsActive)
.OrderBy(s => s.Name)
.AsNoTracking()
.ToListAsync();
var cuttingTools = await context.CuttingTools
.Where(t => t.IsActive)
.OrderBy(t => t.Name)
@@ -35,7 +29,6 @@ public class CatalogService
var materials = await context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems.Where(s => s.IsActive))
.ThenInclude(s => s.SupplierOfferings.Where(o => o.IsActive))
.Where(m => m.IsActive)
.OrderBy(m => m.Shape).ThenBy(m => m.SortOrder)
.AsNoTracking()
@@ -48,7 +41,7 @@ public class CatalogService
{
foreach (var m in group)
{
var stockItems = MapStockItems(m, suppliers);
var stockItems = MapStockItems(m);
switch (m.Shape)
{
@@ -139,12 +132,6 @@ public class CatalogService
return new CatalogData
{
ExportedAt = DateTime.UtcNow,
Suppliers = suppliers.Select(s => new CatalogSupplierDto
{
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes
}).ToList(),
CuttingTools = cuttingTools.Select(t => new CatalogCuttingToolDto
{
Name = t.Name,
@@ -164,14 +151,11 @@ public class CatalogService
try
{
// 1. Suppliers - upsert by name
var supplierMap = await ImportSuppliersAsync(context, data.Suppliers, result);
// 2. Cutting tools - upsert by name
// 1. Cutting tools - upsert by name
await ImportCuttingToolsAsync(context, data.CuttingTools, result);
// 3. Materials + stock items + offerings
await ImportAllMaterialsAsync(context, data.Materials, supplierMap, result);
// 2. Materials + stock items
await ImportAllMaterialsAsync(context, data.Materials, result);
await transaction.CommitAsync();
}
@@ -184,54 +168,6 @@ public class CatalogService
return result;
}
private async Task<Dictionary<string, int>> ImportSuppliersAsync(
ApplicationDbContext context, List<CatalogSupplierDto> suppliers, ImportResultDto result)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var existingSuppliers = await context.Suppliers.ToListAsync();
foreach (var dto in suppliers)
{
try
{
var existing = existingSuppliers.FirstOrDefault(
s => s.Name.Equals(dto.Name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.ContactInfo = dto.ContactInfo ?? existing.ContactInfo;
existing.Notes = dto.Notes ?? existing.Notes;
existing.IsActive = true;
map[dto.Name] = existing.Id;
result.SuppliersUpdated++;
}
else
{
var supplier = new Supplier
{
Name = dto.Name,
ContactInfo = dto.ContactInfo,
Notes = dto.Notes,
CreatedAt = DateTime.UtcNow
};
context.Suppliers.Add(supplier);
await context.SaveChangesAsync();
existingSuppliers.Add(supplier);
map[dto.Name] = supplier.Id;
result.SuppliersCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Supplier '{dto.Name}': {ex.Message}");
}
}
await context.SaveChangesAsync();
return map;
}
private async Task ImportCuttingToolsAsync(
ApplicationDbContext context, List<CatalogCuttingToolDto> tools, ImportResultDto result)
{
@@ -273,68 +209,67 @@ public class CatalogService
}
private async Task ImportAllMaterialsAsync(
ApplicationDbContext context, CatalogMaterialsDto materials, Dictionary<string, int> supplierMap, ImportResultDto result)
ApplicationDbContext context, CatalogMaterialsDto materials, ImportResultDto result)
{
var existingMaterials = await context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems)
.ThenInclude(s => s.SupplierOfferings)
.ToListAsync();
foreach (var dto in materials.Angles)
await ImportMaterialAsync(context, dto, MaterialShape.Angle, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.Angle, existingMaterials, result,
() => new AngleDimensions { Leg1 = dto.Leg1, Leg2 = dto.Leg2, Thickness = dto.Thickness },
dim => { var d = (AngleDimensions)dim; d.Leg1 = dto.Leg1; d.Leg2 = dto.Leg2; d.Thickness = dto.Thickness; });
foreach (var dto in materials.Channels)
await ImportMaterialAsync(context, dto, MaterialShape.Channel, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.Channel, existingMaterials, result,
() => new ChannelDimensions { Height = dto.Height, Flange = dto.Flange, Web = dto.Web },
dim => { var d = (ChannelDimensions)dim; d.Height = dto.Height; d.Flange = dto.Flange; d.Web = dto.Web; });
foreach (var dto in materials.FlatBars)
await ImportMaterialAsync(context, dto, MaterialShape.FlatBar, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.FlatBar, existingMaterials, result,
() => new FlatBarDimensions { Width = dto.Width, Thickness = dto.Thickness },
dim => { var d = (FlatBarDimensions)dim; d.Width = dto.Width; d.Thickness = dto.Thickness; });
foreach (var dto in materials.IBeams)
await ImportMaterialAsync(context, dto, MaterialShape.IBeam, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.IBeam, existingMaterials, result,
() => new IBeamDimensions { Height = dto.Height, WeightPerFoot = dto.WeightPerFoot },
dim => { var d = (IBeamDimensions)dim; d.Height = dto.Height; d.WeightPerFoot = dto.WeightPerFoot; });
foreach (var dto in materials.Pipes)
await ImportMaterialAsync(context, dto, MaterialShape.Pipe, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.Pipe, existingMaterials, result,
() => new PipeDimensions { NominalSize = dto.NominalSize, Wall = dto.Wall, Schedule = dto.Schedule },
dim => { var d = (PipeDimensions)dim; d.NominalSize = dto.NominalSize; d.Wall = (decimal?)dto.Wall; d.Schedule = dto.Schedule; });
foreach (var dto in materials.RectangularTubes)
await ImportMaterialAsync(context, dto, MaterialShape.RectangularTube, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.RectangularTube, existingMaterials, result,
() => new RectangularTubeDimensions { Width = dto.Width, Height = dto.Height, Wall = dto.Wall },
dim => { var d = (RectangularTubeDimensions)dim; d.Width = dto.Width; d.Height = dto.Height; d.Wall = dto.Wall; });
foreach (var dto in materials.RoundBars)
await ImportMaterialAsync(context, dto, MaterialShape.RoundBar, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.RoundBar, existingMaterials, result,
() => new RoundBarDimensions { Diameter = dto.Diameter },
dim => { var d = (RoundBarDimensions)dim; d.Diameter = dto.Diameter; });
foreach (var dto in materials.RoundTubes)
await ImportMaterialAsync(context, dto, MaterialShape.RoundTube, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.RoundTube, existingMaterials, result,
() => new RoundTubeDimensions { OuterDiameter = dto.OuterDiameter, Wall = dto.Wall },
dim => { var d = (RoundTubeDimensions)dim; d.OuterDiameter = dto.OuterDiameter; d.Wall = dto.Wall; });
foreach (var dto in materials.SquareBars)
await ImportMaterialAsync(context, dto, MaterialShape.SquareBar, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.SquareBar, existingMaterials, result,
() => new SquareBarDimensions { Size = dto.SideLength },
dim => { var d = (SquareBarDimensions)dim; d.Size = dto.SideLength; });
foreach (var dto in materials.SquareTubes)
await ImportMaterialAsync(context, dto, MaterialShape.SquareTube, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.SquareTube, existingMaterials, result,
() => new SquareTubeDimensions { Size = dto.SideLength, Wall = dto.Wall },
dim => { var d = (SquareTubeDimensions)dim; d.Size = dto.SideLength; d.Wall = dto.Wall; });
}
private async Task ImportMaterialAsync(
ApplicationDbContext context, CatalogMaterialBaseDto dto, MaterialShape shape,
List<Material> existingMaterials, Dictionary<string, int> supplierMap,
List<Material> existingMaterials,
ImportResultDto result,
Func<MaterialDimensions> createDimensions,
Action<MaterialDimensions> updateDimensions)
@@ -389,7 +324,7 @@ public class CatalogService
await context.SaveChangesAsync();
await ImportStockItemsAsync(context, material, dto.StockItems, supplierMap, result);
await ImportStockItemsAsync(context, material, dto.StockItems, result);
}
catch (Exception ex)
{
@@ -399,10 +334,9 @@ public class CatalogService
private async Task ImportStockItemsAsync(
ApplicationDbContext context, Material material, List<CatalogStockItemDto> stockItems,
Dictionary<string, int> supplierMap, ImportResultDto result)
ImportResultDto result)
{
var existingStockItems = await context.StockItems
.Include(s => s.SupplierOfferings)
.Where(s => s.MaterialId == material.Id)
.ToListAsync();
@@ -413,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,
@@ -440,56 +371,6 @@ public class CatalogService
existingStockItems.Add(stockItem);
result.StockItemsCreated++;
}
foreach (var offeringDto in dto.SupplierOfferings)
{
try
{
if (!supplierMap.TryGetValue(offeringDto.SupplierName, out var supplierId))
{
result.Warnings.Add(
$"Offering for stock '{material.DisplayName} @ {dto.LengthInches}\"': " +
$"Unknown supplier '{offeringDto.SupplierName}', skipped");
continue;
}
var existingOffering = stockItem.SupplierOfferings.FirstOrDefault(
o => o.SupplierId == supplierId);
if (existingOffering != null)
{
existingOffering.PartNumber = offeringDto.PartNumber ?? existingOffering.PartNumber;
existingOffering.SupplierDescription = offeringDto.SupplierDescription ?? existingOffering.SupplierDescription;
existingOffering.Price = offeringDto.Price ?? existingOffering.Price;
existingOffering.Notes = offeringDto.Notes ?? existingOffering.Notes;
existingOffering.IsActive = true;
result.OfferingsUpdated++;
}
else
{
var offering = new SupplierOffering
{
StockItemId = stockItem.Id,
SupplierId = supplierId,
PartNumber = offeringDto.PartNumber,
SupplierDescription = offeringDto.SupplierDescription,
Price = offeringDto.Price,
Notes = offeringDto.Notes
};
context.SupplierOfferings.Add(offering);
stockItem.SupplierOfferings.Add(offering);
result.OfferingsCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add(
$"Offering for '{material.DisplayName} @ {dto.LengthInches}\"' " +
$"from '{offeringDto.SupplierName}': {ex.Message}");
}
}
await context.SaveChangesAsync();
}
catch (Exception ex)
{
@@ -497,24 +378,18 @@ public class CatalogService
$"Stock item '{material.DisplayName} @ {dto.LengthInches}\"': {ex.Message}");
}
}
await context.SaveChangesAsync();
}
private static List<CatalogStockItemDto> MapStockItems(Material m, List<Supplier> suppliers)
private static List<CatalogStockItemDto> MapStockItems(Material m)
{
return m.StockItems.OrderBy(s => s.LengthInches).Select(s => new CatalogStockItemDto
{
LengthInches = s.LengthInches,
Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes,
SupplierOfferings = s.SupplierOfferings.Select(o => new CatalogSupplierOfferingDto
{
SupplierName = suppliers.FirstOrDefault(sup => sup.Id == o.SupplierId)?.Name ?? "Unknown",
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes
}).ToList()
Notes = s.Notes
}).ToList();
}
}
-119
View File
@@ -1,119 +0,0 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class PurchaseItemService
{
private readonly IDbContextFactory<ApplicationDbContext> _factory;
public PurchaseItemService(IDbContextFactory<ApplicationDbContext> factory)
{
_factory = factory;
}
public async Task<List<PurchaseItem>> GetAllAsync(PurchaseItemStatus? status = null)
{
await using var context = _factory.CreateDbContext();
var query = context.PurchaseItems
.Include(p => p.StockItem)
.ThenInclude(s => s.Material)
.Include(p => p.Supplier)
.Include(p => p.Job)
.AsQueryable();
if (status.HasValue)
{
query = query.Where(p => p.Status == status.Value);
}
return await query
.OrderBy(p => p.Status)
.ThenByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<PurchaseItem?> GetByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.PurchaseItems
.Include(p => p.StockItem)
.ThenInclude(s => s.Material)
.Include(p => p.Supplier)
.Include(p => p.Job)
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<PurchaseItem> CreateAsync(PurchaseItem item)
{
await using var context = _factory.CreateDbContext();
item.CreatedAt = DateTime.UtcNow;
context.PurchaseItems.Add(item);
await context.SaveChangesAsync();
return item;
}
public async Task CreateBulkAsync(List<PurchaseItem> items)
{
await using var context = _factory.CreateDbContext();
var now = DateTime.UtcNow;
foreach (var item in items)
{
item.CreatedAt = now;
}
context.PurchaseItems.AddRange(items);
await context.SaveChangesAsync();
}
public async Task UpdateAsync(PurchaseItem item)
{
await using var context = _factory.CreateDbContext();
item.UpdatedAt = DateTime.UtcNow;
context.PurchaseItems.Update(item);
await context.SaveChangesAsync();
}
public async Task UpdateStatusAsync(int id, PurchaseItemStatus status)
{
await using var context = _factory.CreateDbContext();
var item = await context.PurchaseItems.FindAsync(id);
if (item != null)
{
item.Status = status;
item.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
}
}
public async Task UpdateSupplierAsync(int id, int? supplierId)
{
await using var context = _factory.CreateDbContext();
var item = await context.PurchaseItems.FindAsync(id);
if (item != null)
{
item.SupplierId = supplierId;
item.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
}
}
public async Task DeleteAsync(int id)
{
await using var context = _factory.CreateDbContext();
var item = await context.PurchaseItems.FindAsync(id);
if (item != null)
{
context.PurchaseItems.Remove(item);
await context.SaveChangesAsync();
}
}
}
+1 -34
View File
@@ -57,8 +57,6 @@ public class StockItemService
return await context.StockItems
.Include(s => s.Material)
.Include(s => s.SupplierOfferings)
.ThenInclude(o => o.Supplier)
.FirstOrDefaultAsync(s => s.Id == id);
}
@@ -112,7 +110,7 @@ public class StockItemService
}
// Stock transaction methods
public async Task<StockTransaction> AddStockAsync(int stockItemId, int quantity, int? supplierId = null, decimal? unitPrice = null, string? notes = null)
public async Task<StockTransaction> AddStockAsync(int stockItemId, int quantity, string? notes = null)
{
await using var context = _factory.CreateDbContext();
@@ -124,8 +122,6 @@ public class StockItemService
StockItemId = stockItemId,
Quantity = quantity,
Type = StockTransactionType.Received,
SupplierId = supplierId,
UnitPrice = unitPrice,
Notes = notes,
CreatedAt = DateTime.UtcNow
};
@@ -139,34 +135,6 @@ public class StockItemService
return transaction;
}
public async Task<decimal?> GetAverageCostAsync(int stockItemId)
{
await using var context = _factory.CreateDbContext();
var transactions = await context.StockTransactions
.Where(t => t.StockItemId == stockItemId && t.Type == StockTransactionType.Received && t.UnitPrice.HasValue)
.ToListAsync();
if (transactions.Count == 0)
return null;
var totalCost = transactions.Sum(t => t.Quantity * t.UnitPrice!.Value);
var totalQty = transactions.Sum(t => t.Quantity);
return totalQty > 0 ? totalCost / totalQty : null;
}
public async Task<decimal?> GetLastPurchasePriceAsync(int stockItemId)
{
await using var context = _factory.CreateDbContext();
return await context.StockTransactions
.Where(t => t.StockItemId == stockItemId && t.Type == StockTransactionType.Received && t.UnitPrice.HasValue)
.OrderByDescending(t => t.CreatedAt)
.Select(t => t.UnitPrice)
.FirstOrDefaultAsync();
}
public async Task<StockTransaction> UseStockAsync(int stockItemId, int quantity, int? jobId = null, string? notes = null)
{
await using var context = _factory.CreateDbContext();
@@ -251,7 +219,6 @@ public class StockItemService
var query = context.StockTransactions
.Include(t => t.Job)
.Include(t => t.Supplier)
.Where(t => t.StockItemId == stockItemId)
.OrderByDescending(t => t.CreatedAt)
.AsQueryable();
-139
View File
@@ -1,139 +0,0 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class SupplierService
{
private readonly IDbContextFactory<ApplicationDbContext> _factory;
public SupplierService(IDbContextFactory<ApplicationDbContext> factory)
{
_factory = factory;
}
public async Task<List<Supplier>> GetAllAsync(bool includeInactive = false)
{
await using var context = _factory.CreateDbContext();
var query = context.Suppliers.AsQueryable();
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query.OrderBy(s => s.Name).ToListAsync();
}
public async Task<Supplier?> GetByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.Suppliers
.Include(s => s.Offerings)
.ThenInclude(o => o.StockItem)
.ThenInclude(si => si.Material)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<Supplier> CreateAsync(Supplier supplier)
{
await using var context = _factory.CreateDbContext();
supplier.CreatedAt = DateTime.UtcNow;
context.Suppliers.Add(supplier);
await context.SaveChangesAsync();
return supplier;
}
public async Task UpdateAsync(Supplier supplier)
{
await using var context = _factory.CreateDbContext();
context.Suppliers.Update(supplier);
await context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
await using var context = _factory.CreateDbContext();
var supplier = await context.Suppliers.FindAsync(id);
if (supplier != null)
{
supplier.IsActive = false;
await context.SaveChangesAsync();
}
}
// Offering management
public async Task<List<SupplierOffering>> GetOfferingsForSupplierAsync(int supplierId)
{
await using var context = _factory.CreateDbContext();
return await context.SupplierOfferings
.Include(o => o.StockItem)
.ThenInclude(si => si.Material)
.Where(o => o.SupplierId == supplierId && o.IsActive)
.OrderBy(o => o.StockItem.Material.Shape)
.ThenBy(o => o.StockItem.Material.Size)
.ThenBy(o => o.StockItem.LengthInches)
.ToListAsync();
}
public async Task<List<SupplierOffering>> GetOfferingsForStockItemAsync(int stockItemId)
{
await using var context = _factory.CreateDbContext();
return await context.SupplierOfferings
.Include(o => o.Supplier)
.Where(o => o.StockItemId == stockItemId && o.IsActive && o.Supplier.IsActive)
.OrderBy(o => o.Supplier.Name)
.ToListAsync();
}
public async Task<SupplierOffering?> GetOfferingByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.SupplierOfferings
.Include(o => o.StockItem)
.ThenInclude(si => si.Material)
.Include(o => o.Supplier)
.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task<SupplierOffering> AddOfferingAsync(SupplierOffering offering)
{
await using var context = _factory.CreateDbContext();
context.SupplierOfferings.Add(offering);
await context.SaveChangesAsync();
return offering;
}
public async Task UpdateOfferingAsync(SupplierOffering offering)
{
await using var context = _factory.CreateDbContext();
context.SupplierOfferings.Update(offering);
await context.SaveChangesAsync();
}
public async Task DeleteOfferingAsync(int id)
{
await using var context = _factory.CreateDbContext();
var offering = await context.SupplierOfferings.FindAsync(id);
if (offering != null)
{
offering.IsActive = false;
await context.SaveChangesAsync();
}
}
public async Task<bool> OfferingExistsAsync(int supplierId, int stockItemId, int? excludeId = null)
{
await using var context = _factory.CreateDbContext();
var query = context.SupplierOfferings.Where(o =>
o.SupplierId == supplierId &&
o.StockItemId == stockItemId &&
o.IsActive);
if (excludeId.HasValue)
{
query = query.Where(o => o.Id != excludeId.Value);
}
return await query.AnyAsync();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# Remove vendor/supplier data from CutList
## Background
CutList's stated purpose is a 1D bin-packing optimizer: given a parts list and available stock, compute an efficient cutting plan. Supplier/vendor management (contacts, part numbers, pricing, purchase orders) was built alongside it but never touched by the packing engine itself (`AdvancedFitEngine`, `MultiBinEngine`, `CutListPackingService` never reference `Supplier`). It only entered through inventory-management pages.
This surfaced concretely in the Alro Steel catalog scraper: every scraped stock item was wrapped in a `supplierOfferings` array with empty `partNumber`/`supplierDescription` placeholders (Alro's SmartGrid never exposes part numbers), making it needlessly verbose to add or change a stock length. Investigating further surfaced the actual reported bug — a user (Carrol) couldn't figure out how to change an existing stock item's length, because `Stock/Edit.razor` hard-locks the Length field to `readonly` outside of creation.
Decision: fix the length-editing bug, and separately, pull vendor/purchasing concepts out of CutList entirely. Procurement (who to buy from, at what price, PO status) is a different domain than nesting optimization. If a "what should we order" feature is needed later, it can live in a layer above CutList that queries the existing Materials/StockItems REST API (`MaterialsController`, `StockItemsController`) to compare `JobStock` requirements against `StockItem.QuantityOnHand` — nothing in this change forecloses that.
## Scope
### Kept in CutList (nesting/inventory-relevant)
- **`StockItem.QuantityOnHand`** — becomes directly editable via a stripped-down `StockTransaction` log:
- Keeps: `Quantity`, `Type` (`Received`/`Used`/`Adjustment`/`Scrapped`; `Returned` is dead code today — never constructed anywhere, only referenced in a UI badge-color switch — and stays dead, out of scope to touch), `JobId`, `Notes`, `CreatedAt`.
- Drops: `SupplierId`, `UnitPrice`, and the `Supplier` navigation.
- `StockItemService` keeps `AddStockAsync/UseStockAsync/AdjustStockAsync/ScrapStockAsync/GetTransactionHistoryAsync/RecalculateQuantityAsync`, with `supplierId`/`unitPrice` parameters removed from `AddStockAsync`.
- Drops `GetAverageCostAsync`/`GetLastPurchasePriceAsync` and `StockPricingDto` entirely — there's no non-vendor version of "cost."
- **`Job.LockedAt`/`IsLocked`** — kept. Becomes a direct manual Lock/Unlock action instead of a side effect of "Add to Order List." Still production-relevant: don't let someone edit a parts list after material's been committed/cut against it.
- **Bug fix**: `Stock/Edit.razor`'s Length field becomes editable in both create and edit modes. The backend already supports this — `StockItemService.ExistsAsync` already excludes the current row's ID for the edit case in its `(MaterialId, LengthInches)` uniqueness check — so this is a front-end-only fix.
### Removed entirely (procurement layer, not CutList's job)
- **Entities**: `Supplier`, `SupplierOffering`, `PurchaseItem`.
- **Services/Controllers/DTOs**: `SupplierService`, `PurchaseItemService`, `SuppliersController`, `SupplierDtos.cs`. `StockItemsController` drops its `/offerings` and `/pricing` endpoints.
- **Pages**: `Suppliers/Index.razor`, `Suppliers/Edit.razor`, `Orders/Index.razor`, `Orders/Add.razor`. `NavMenu.razor` loses the Suppliers and Orders links. `Home.razor` loses the Suppliers dashboard card and supplier/order mentions in the getting-started copy.
- **`Jobs/Edit.razor`**: "Add to Order List" button (which created `PurchaseItem`s and locked the job) is replaced with a plain "Lock Job" button that calls `JobService.LockAsync` directly. Drops the `PurchaseItemService` dependency and bulk-create logic.
- **Catalog import/export**: `CatalogDtos.cs` drops `CatalogSupplierDto`, `CatalogSupplierOfferingDto`, `CatalogData.Suppliers`, and `CatalogStockItemDto.SupplierOfferings`. `CatalogService` drops `ImportSuppliersAsync` and all offering-import logic. Stock items in the catalog format become `{ lengthInches, quantityOnHand }`.
- **Seed data**: `alro-catalog.json` already regenerated without vendor wrappers. `oneals-catalog.json` gets the same treatment via a one-off transform script (not checked in as a permanent tool) — it currently has *real* O'Neal part numbers/prices, which will be discarded along with the empty Alro placeholders, for consistency.
- **`CutList.Mcp`**: removes `list_suppliers`, `add_supplier`, `list_supplier_offerings`, `add_supplier_offering`, `add_stock_with_offering` (tools, DTOs, and the corresponding `ApiClient` methods). Adds a plain `add_stock` tool: find-or-create material → find-or-create stock item → set quantity — same convenience flow as `add_stock_with_offering` minus the offering step. Republished to `~/.claude/mcp/CutList.Mcp/` per the standard MCP publishing workflow.
### Migration
One new EF Core migration:
- Drops `Suppliers`, `SupplierOfferings`, `PurchaseItems` tables.
- Drops `SupplierId`/`UnitPrice` columns and the associated FK from `StockTransactions`.
This is destructive to whatever currently lives in those tables (e.g., any real Supplier/Offering/PurchaseItem rows in the dev DB). Applied immediately after creation per the usual EF workflow, no separate confirmation gate beyond this design doc.
## Out of scope
- Building the "layer above" that would query stock levels for purchasing decisions — not needed now, and the existing Materials/StockItems REST API already exposes what such a layer would need later.
- Any change to the packing algorithm itself (`CutList.Core`) — untouched by this work, confirming it never depended on vendor data in the first place.
- Removing the dead `StockTransactionType.Returned` enum value — unused, harmless, unrelated to vendor data.