Compare commits
14
Commits
7574476d7f
...
7b210f518b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b210f518b | ||
|
|
3244e15593 | ||
|
|
b77d0d971d | ||
|
|
f75c205bc5 | ||
|
|
1b4d5f76a8 | ||
|
|
41944638c6 | ||
|
|
063c844310 | ||
|
|
597181bd78 | ||
|
|
8d6ada35e5 | ||
|
|
b51d936b78 | ||
|
|
e7248be9a4 | ||
|
|
06f0c2032d | ||
|
|
9296bea0bc | ||
|
|
4e874df22f |
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-279
@@ -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,19 +336,14 @@ 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
|
||||
{
|
||||
var offering = await _api.CreateOfferingAsync(supplierId, stockItem.Id, partNumber, supplierDescription, price, null);
|
||||
|
||||
return new AddStockWithOfferingResult
|
||||
return new AddStockResult
|
||||
{
|
||||
Success = true,
|
||||
MaterialId = material.Id,
|
||||
@@ -536,33 +352,9 @@ public class InventoryTools
|
||||
StockItemId = stockItem.Id,
|
||||
StockItemCreated = stockItemCreated,
|
||||
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
|
||||
OfferingId = offering?.Id ?? 0,
|
||||
PartNumber = partNumber,
|
||||
SupplierDescription = supplierDescription,
|
||||
Price = price
|
||||
QuantityOnHand = stockItem.QuantityOnHand
|
||||
};
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,23 +1043,20 @@ 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>
|
||||
<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="AddToOrderList" disabled="@addingToOrderList">
|
||||
@if (addingToOrderList)
|
||||
<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-cart-plus me-1"></i>Add to Order List
|
||||
<i class="bi bi-lock me-1"></i>Lock Job
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (summary.TotalToBePurchasedBins == 0)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
</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,6 +302,8 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (IsNew)
|
||||
{
|
||||
var created = await StockItemService.CreateAsync(stockItem);
|
||||
@@ -459,113 +314,14 @@ else
|
||||
await StockItemService.UpdateAsync(stockItem);
|
||||
}
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException)
|
||||
{
|
||||
errorMessage = "A stock item with this material and length already exists (it may have been previously deleted).";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user