Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec52834cd4 | ||
|
|
d8656cc454 | ||
|
|
0333942cb3 | ||
|
|
49a539d7e5 | ||
|
|
6bdbdf4969 | ||
|
|
383fa19cf2 | ||
|
|
843ba24788 | ||
|
|
41283987b9 | ||
|
|
41dda11062 | ||
|
|
f71cf8ab0a | ||
|
|
fd6e8d632c | ||
|
|
69c993fcb2 |
@@ -120,13 +120,10 @@ Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's R
|
||||
Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its own standalone table (`DimAngle`, `DimChannel`, `DimFlatBar`, `DimIBeam`, `DimPipe`, `DimRectangularTube`, `DimRoundBar`, `DimRoundTube`, `DimSquareBar`, `DimSquareTube`) with no base table. Each table has its own `Id` (shared sequence) and `MaterialId` FK. Each generates its own `SizeString` and `SortOrder`.
|
||||
|
||||
### StockItem
|
||||
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive`
|
||||
- `MaterialId`, `LengthInches` (decimal), `IsActive`
|
||||
- **Unique constraint**: (MaterialId, LengthInches)
|
||||
- **Relationships**: `Material`, `Transactions` (1:many StockTransaction)
|
||||
|
||||
### StockTransaction
|
||||
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
|
||||
- Optional: `JobId`
|
||||
- **Relationships**: `Material`
|
||||
- No quantity tracking — a StockItem represents a length of material you can cut from, not a counted inventory record
|
||||
|
||||
### CuttingTool
|
||||
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
|
||||
@@ -154,8 +151,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
|
||||
### StockItemService
|
||||
- CRUD with soft delete
|
||||
- Stock transactions: `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`
|
||||
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
|
||||
|
||||
### JobService
|
||||
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync`
|
||||
@@ -167,7 +162,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
|
||||
### CutListPackingService
|
||||
- `PackAsync(parts, kerfInches, jobStock?)` — runs optimization per material group
|
||||
- Separates results into `InStockBins` (from inventory) and `ToBePurchasedBins`
|
||||
- Separates results into `InStockBins` (from catalog-sourced job stock) and `ToBePurchasedBins`
|
||||
- `GetSummary(result)` — calculates total bins, pieces, waste, efficiency %
|
||||
- `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.)
|
||||
|
||||
@@ -188,7 +183,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
| `/jobs/{Id}` | Jobs/Edit | Tabbed editor (Details, Parts, Stock, Results); locked jobs show banner + disable editing |
|
||||
| `/materials` | Materials/Index | Material list with MaterialFilter, pagination |
|
||||
| `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) |
|
||||
| `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges |
|
||||
| `/stock` | Stock/Index | Stock items with MaterialFilter, pagination |
|
||||
| `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form |
|
||||
| `/tools` | Tools/Index | Cutting tools CRUD |
|
||||
| `/Error` | Error | Unhandled exception page (registered via `UseExceptionHandler`) |
|
||||
@@ -211,7 +206,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
- **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
|
||||
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -70,14 +70,13 @@ public class ApiClient
|
||||
return await _http.GetFromJsonAsync<List<ApiStockItemDto>>(url) ?? [];
|
||||
}
|
||||
|
||||
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, int quantityOnHand, string? notes)
|
||||
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, string? notes)
|
||||
{
|
||||
var body = new
|
||||
{
|
||||
MaterialId = materialId,
|
||||
Length = length,
|
||||
Name = name,
|
||||
QuantityOnHand = quantityOnHand,
|
||||
Notes = notes
|
||||
};
|
||||
var response = await _http.PostAsJsonAsync("api/stock-items", body);
|
||||
@@ -360,7 +359,6 @@ public class ApiStockItemDto
|
||||
public decimal LengthInches { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ public class InventoryTools
|
||||
LengthInches = s.LengthInches,
|
||||
LengthFormatted = s.LengthFormatted,
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
Notes = s.Notes,
|
||||
IsActive = s.IsActive
|
||||
}).ToList()
|
||||
@@ -194,14 +193,12 @@ public class InventoryTools
|
||||
string length,
|
||||
[Description("Optional name/label for this stock item")]
|
||||
string? name = null,
|
||||
[Description("Initial quantity on hand (default 0)")]
|
||||
int quantityOnHand = 0,
|
||||
[Description("Notes")]
|
||||
string? notes = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, quantityOnHand, notes);
|
||||
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, notes);
|
||||
|
||||
if (stockItem == null)
|
||||
return new StockItemResult { Success = false, Error = "Failed to create stock item" };
|
||||
@@ -217,7 +214,6 @@ public class InventoryTools
|
||||
LengthInches = stockItem.LengthInches,
|
||||
LengthFormatted = stockItem.LengthFormatted,
|
||||
Name = stockItem.Name,
|
||||
QuantityOnHand = stockItem.QuantityOnHand,
|
||||
Notes = stockItem.Notes,
|
||||
IsActive = stockItem.IsActive
|
||||
}
|
||||
@@ -237,7 +233,7 @@ public class InventoryTools
|
||||
|
||||
#region Convenience
|
||||
|
||||
[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.")]
|
||||
[McpServerTool(Name = "add_stock"), Description("Convenience method: adds a material (if needed) and a stock item (if needed), all in one call.")]
|
||||
public async Task<AddStockResult> AddStock(
|
||||
[Description("Material shape (e.g., 'Angle', 'FlatBar')")]
|
||||
string shape,
|
||||
@@ -245,8 +241,6 @@ public class InventoryTools
|
||||
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')")]
|
||||
@@ -315,7 +309,7 @@ public class InventoryTools
|
||||
{
|
||||
try
|
||||
{
|
||||
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, quantityOnHand, null);
|
||||
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, null);
|
||||
stockItemCreated = true;
|
||||
}
|
||||
catch (ApiConflictException)
|
||||
@@ -351,8 +345,7 @@ public class InventoryTools
|
||||
MaterialCreated = materialCreated,
|
||||
StockItemId = stockItem.Id,
|
||||
StockItemCreated = stockItemCreated,
|
||||
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
|
||||
QuantityOnHand = stockItem.QuantityOnHand
|
||||
LengthFormatted = ArchUnits.FormatFromInches(lengthInches)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -519,7 +512,6 @@ public class StockItemDto
|
||||
public decimal LengthInches { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -548,7 +540,6 @@ public class AddStockResult
|
||||
public int StockItemId { get; set; }
|
||||
public bool StockItemCreated { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public int QuantityOnHand { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -257,9 +257,9 @@ public class JobTools
|
||||
string length,
|
||||
[Description("Quantity available (-1 for unlimited, default -1)")]
|
||||
int quantity = -1,
|
||||
[Description("Stock item ID from inventory (optional - links to tracked inventory)")]
|
||||
[Description("Stock item ID from the stock catalog (optional - links to a specific catalog stock item)")]
|
||||
int? stockItemId = null,
|
||||
[Description("True if this is a custom length not from inventory (default false)")]
|
||||
[Description("True if this is a custom length not sourced from the stock catalog (default false)")]
|
||||
bool isCustomLength = false,
|
||||
[Description("Priority - lower number = used first (default 10)")]
|
||||
int priority = 10)
|
||||
@@ -304,7 +304,7 @@ public class JobTools
|
||||
|
||||
#region Optimization
|
||||
|
||||
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined. If stock is defined, it will be used; otherwise the optimizer uses available inventory. Returns optimized cut layouts per material with efficiency stats.")]
|
||||
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined, and stock must be explicitly configured on the job (via add_job_stock) for each material used by its parts - there is no fallback to inventory; parts with no matching stock configured come back as items not placed. Returns optimized cut layouts per material with efficiency stats.")]
|
||||
public async Task<OptimizeJobResult> OptimizeJob(
|
||||
[Description("Job ID")]
|
||||
int jobId,
|
||||
|
||||
@@ -276,7 +276,6 @@ else
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th>Length</th>
|
||||
<th>On Hand</th>
|
||||
<th style="width: 120px;">Qty to Use</th>
|
||||
<th style="width: 100px;">Priority</th>
|
||||
</tr>
|
||||
@@ -289,7 +288,6 @@ else
|
||||
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
|
||||
</td>
|
||||
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
|
||||
<td>@candidate.StockItem.QuantityOnHand</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
|
||||
min="-1" disabled="@(!candidate.Selected)" />
|
||||
@@ -810,13 +808,14 @@ else
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var stock in availableStockItems)
|
||||
{
|
||||
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches) (@stock.QuantityOnHand available)</option>
|
||||
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Qty to Use</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
|
||||
<small class="text-muted">-1 = unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
@@ -870,7 +869,7 @@ else
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
|
||||
<small class="text-muted">Use -1 for unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1125,7 +1124,7 @@ else
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> —
|
||||
No stock lengths available or parts too long.
|
||||
not enough stock quantity entered for this job, no stock lengths configured, or parts too long.
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1321,9 +1320,9 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStock.Quantity < 1)
|
||||
if (newStock.Quantity < -1 || newStock.Quantity == 0)
|
||||
{
|
||||
stockErrorMessage = "Quantity must be at least 1";
|
||||
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,94 +70,6 @@ else
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!IsNew)
|
||||
{
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
Inventory
|
||||
<span class="badge @(stockItem.QuantityOnHand > 0 ? "bg-success" : "bg-secondary") ms-2">@stockItem.QuantityOnHand on hand</span>
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-primary" @onclick="ShowStockForm">Add/Adjust Stock</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showStockForm)
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>Stock Transaction</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" @bind="stockTransactionType">
|
||||
<option value="add">Receive Stock</option>
|
||||
<option value="adjust">Set Quantity</option>
|
||||
<option value="scrap">Scrap/Waste</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">@(stockTransactionType == "adjust" ? "New Quantity" : "Quantity")</label>
|
||||
<input type="number" class="form-control" @bind="stockQuantity" min="0" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Notes</label>
|
||||
<InputText class="form-control" @bind-Value="stockNotes" />
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockFormErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-2 mb-0">@stockFormErrorMessage</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" @onclick="SaveStockTransactionAsync" disabled="@savingStockTransaction">
|
||||
@if (savingStockTransaction)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelStockForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (transactions.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No transaction history yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Qty</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var txn in transactions)
|
||||
{
|
||||
<tr>
|
||||
<td>@txn.CreatedAt.ToLocalTime().ToString("MM/dd/yy HH:mm")</td>
|
||||
<td>
|
||||
<span class="badge @GetTransactionBadgeClass(txn.Type)">@txn.Type</span>
|
||||
</td>
|
||||
<td class="@(txn.Quantity >= 0 ? "text-success" : "text-danger")">
|
||||
@(txn.Quantity >= 0 ? "+" : "")@txn.Quantity
|
||||
</td>
|
||||
<td>@(txn.Notes ?? "-")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -167,19 +79,10 @@ else
|
||||
|
||||
private StockItem stockItem = new();
|
||||
private List<Material> materials = new();
|
||||
private List<StockTransaction> transactions = new();
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private string? errorMessage;
|
||||
|
||||
// Stock transaction form
|
||||
private bool showStockForm;
|
||||
private bool savingStockTransaction;
|
||||
private string stockTransactionType = "add";
|
||||
private int stockQuantity;
|
||||
private string? stockNotes;
|
||||
private string? stockFormErrorMessage;
|
||||
|
||||
private bool IsNew => !Id.HasValue;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -195,83 +98,10 @@ else
|
||||
return;
|
||||
}
|
||||
stockItem = existing;
|
||||
transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private string GetTransactionBadgeClass(StockTransactionType type) => type switch
|
||||
{
|
||||
StockTransactionType.Received => "bg-success",
|
||||
StockTransactionType.Used => "bg-primary",
|
||||
StockTransactionType.Adjustment => "bg-warning text-dark",
|
||||
StockTransactionType.Scrapped => "bg-danger",
|
||||
StockTransactionType.Returned => "bg-info",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private void ShowStockForm()
|
||||
{
|
||||
stockTransactionType = "add";
|
||||
stockQuantity = 0;
|
||||
stockNotes = null;
|
||||
stockFormErrorMessage = null;
|
||||
showStockForm = true;
|
||||
}
|
||||
|
||||
private void CancelStockForm()
|
||||
{
|
||||
showStockForm = false;
|
||||
stockFormErrorMessage = null;
|
||||
}
|
||||
|
||||
private async Task SaveStockTransactionAsync()
|
||||
{
|
||||
stockFormErrorMessage = null;
|
||||
savingStockTransaction = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (stockQuantity <= 0 && stockTransactionType != "adjust")
|
||||
{
|
||||
stockFormErrorMessage = "Quantity must be greater than zero";
|
||||
return;
|
||||
}
|
||||
|
||||
if (stockTransactionType == "adjust" && stockQuantity < 0)
|
||||
{
|
||||
stockFormErrorMessage = "Quantity cannot be negative";
|
||||
return;
|
||||
}
|
||||
|
||||
switch (stockTransactionType)
|
||||
{
|
||||
case "add":
|
||||
await StockItemService.AddStockAsync(Id!.Value, stockQuantity, stockNotes);
|
||||
break;
|
||||
case "adjust":
|
||||
await StockItemService.AdjustStockAsync(Id!.Value, stockQuantity, stockNotes);
|
||||
break;
|
||||
case "scrap":
|
||||
await StockItemService.ScrapStockAsync(Id!.Value, stockQuantity, stockNotes);
|
||||
break;
|
||||
}
|
||||
|
||||
// Refresh
|
||||
var updated = await StockItemService.GetByIdAsync(Id!.Value);
|
||||
if (updated != null)
|
||||
{
|
||||
stockItem = updated;
|
||||
}
|
||||
transactions = await StockItemService.GetTransactionHistoryAsync(Id!.Value, 20);
|
||||
showStockForm = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingStockTransaction = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveStockItemAsync()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Stock items represent the specific lengths of material you have available for cutting. Each stock item links
|
||||
a material to a length and tracks how many pieces you have on hand.
|
||||
Stock items represent the specific lengths of material you can cut from. Add the lengths you typically
|
||||
work with here, then pick from them when adding stock to a job.
|
||||
</p>
|
||||
|
||||
@if (loading)
|
||||
@@ -46,7 +46,6 @@ else
|
||||
<th>Grade</th>
|
||||
<th>Size</th>
|
||||
<th>Length</th>
|
||||
<th>On Hand</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -59,16 +58,6 @@ else
|
||||
<td>@item.Material.Grade</td>
|
||||
<td>@item.Material.Size</td>
|
||||
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
||||
<td>
|
||||
@if (item.QuantityOnHand > 0)
|
||||
{
|
||||
<span class="badge bg-success">@item.QuantityOnHand</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">0</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||
|
||||
@@ -310,7 +310,6 @@ public class JobsController : ControllerBase
|
||||
LengthInches = s.LengthInches,
|
||||
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
IsActive = s.IsActive
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ public class StockItemsController : ControllerBase
|
||||
MaterialId = dto.MaterialId,
|
||||
LengthInches = (decimal)lengthInches,
|
||||
Name = dto.Name,
|
||||
QuantityOnHand = dto.QuantityOnHand,
|
||||
Notes = dto.Notes
|
||||
};
|
||||
|
||||
@@ -118,87 +117,6 @@ public class StockItemsController : ControllerBase
|
||||
return Ok(items.Select(MapToDto).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{id}/transactions")]
|
||||
public async Task<ActionResult<List<StockTransactionDto>>> GetTransactions(int id, [FromQuery] int? limit = null)
|
||||
{
|
||||
var item = await _stockItemService.GetByIdAsync(id);
|
||||
if (item == null)
|
||||
return NotFound();
|
||||
|
||||
var transactions = await _stockItemService.GetTransactionHistoryAsync(id, limit);
|
||||
return Ok(transactions.Select(MapTransactionToDto).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id}/receive")]
|
||||
public async Task<ActionResult<StockTransactionDto>> ReceiveStock(int id, AddStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.AddStockAsync(id, dto.Quantity, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/use")]
|
||||
public async Task<ActionResult<StockTransactionDto>> UseStock(int id, UseStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.UseStockAsync(id, dto.Quantity, dto.JobId, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/adjust")]
|
||||
public async Task<ActionResult<StockTransactionDto>> AdjustStock(int id, AdjustStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.AdjustStockAsync(id, dto.NewQuantity, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/scrap")]
|
||||
public async Task<ActionResult<StockTransactionDto>> ScrapStock(int id, ScrapStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.ScrapStockAsync(id, dto.Quantity, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/recalculate")]
|
||||
public async Task<ActionResult<object>> RecalculateStock(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newQuantity = await _stockItemService.RecalculateQuantityAsync(id);
|
||||
return Ok(new { QuantityOnHand = newQuantity });
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
private static StockItemDto MapToDto(StockItem s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
@@ -207,20 +125,7 @@ public class StockItemsController : ControllerBase
|
||||
LengthInches = s.LengthInches,
|
||||
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
Notes = s.Notes,
|
||||
IsActive = s.IsActive
|
||||
};
|
||||
|
||||
private static StockTransactionDto MapTransactionToDto(StockTransaction t) => new()
|
||||
{
|
||||
Id = t.Id,
|
||||
StockItemId = t.StockItemId,
|
||||
Quantity = t.Quantity,
|
||||
Type = t.Type.ToString(),
|
||||
JobId = t.JobId,
|
||||
JobNumber = t.Job?.JobNumber,
|
||||
Notes = t.Notes,
|
||||
CreatedAt = t.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ public class CatalogStockItemDto
|
||||
{
|
||||
public decimal LengthInches { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ public class StockItemDto
|
||||
public decimal LengthInches { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -18,7 +17,6 @@ public class CreateStockItemDto
|
||||
public int MaterialId { get; set; }
|
||||
public string Length { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -28,40 +26,3 @@ public class UpdateStockItemDto
|
||||
public string? Name { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class StockTransactionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int StockItemId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public int? JobId { get; set; }
|
||||
public string? JobNumber { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class AddStockDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UseStockDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public int? JobId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class AdjustStockDto
|
||||
{
|
||||
public int NewQuantity { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class ScrapStockDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ public class ApplicationDbContext : DbContext
|
||||
public DbSet<Material> Materials => Set<Material>();
|
||||
public DbSet<MaterialDimensions> MaterialDimensions => Set<MaterialDimensions>();
|
||||
public DbSet<StockItem> StockItems => Set<StockItem>();
|
||||
public DbSet<StockTransaction> StockTransactions => Set<StockTransaction>();
|
||||
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
|
||||
public DbSet<Job> Jobs => Set<Job>();
|
||||
public DbSet<JobPart> JobParts => Set<JobPart>();
|
||||
@@ -157,24 +156,6 @@ public class ApplicationDbContext : DbContext
|
||||
entity.HasIndex(e => new { e.MaterialId, e.LengthInches }).IsUnique();
|
||||
});
|
||||
|
||||
// StockTransaction
|
||||
modelBuilder.Entity<StockTransaction>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
entity.HasOne(e => e.StockItem)
|
||||
.WithMany(s => s.Transactions)
|
||||
.HasForeignKey(e => e.StockItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Job)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.JobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// CuttingTool
|
||||
modelBuilder.Entity<CuttingTool>(entity =>
|
||||
{
|
||||
|
||||
@@ -6,12 +6,10 @@ public class StockItem
|
||||
public int MaterialId { get; set; }
|
||||
public decimal LengthInches { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; } = 0;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public Material Material { get; set; } = null!;
|
||||
public ICollection<StockTransaction> Transactions { get; set; } = new List<StockTransaction>();
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace CutList.Web.Data.Entities;
|
||||
|
||||
public class StockTransaction
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int StockItemId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public StockTransactionType Type { get; set; }
|
||||
public int? JobId { 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 enum StockTransactionType
|
||||
{
|
||||
Received,
|
||||
Used,
|
||||
Adjustment,
|
||||
Scrapped,
|
||||
Returned
|
||||
}
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
// <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("20260801145943_RemoveInventoryQuantityTracking")]
|
||||
partial class RemoveInventoryQuantityTracking
|
||||
{
|
||||
/// <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<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaterialId", "LengthInches")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StockItems");
|
||||
});
|
||||
|
||||
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.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");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CutList.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveInventoryQuantityTracking : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "StockTransactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuantityOnHand",
|
||||
table: "StockItems");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "QuantityOnHand",
|
||||
table: "StockItems",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StockTransactions",
|
||||
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),
|
||||
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),
|
||||
Type = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StockTransactions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StockTransactions_Jobs_JobId",
|
||||
column: x => x.JobId,
|
||||
principalTable: "Jobs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_StockTransactions_StockItems_StockItemId",
|
||||
column: x => x.StockItemId,
|
||||
principalTable: "StockItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockTransactions_JobId",
|
||||
table: "StockTransactions",
|
||||
column: "JobId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockTransactions_StockItemId",
|
||||
table: "StockTransactions",
|
||||
column: "StockItemId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,9 +325,6 @@ namespace CutList.Web.Migrations
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<int>("QuantityOnHand")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -339,44 +336,6 @@ namespace CutList.Web.Migrations
|
||||
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");
|
||||
@@ -632,24 +591,6 @@ namespace CutList.Web.Migrations
|
||||
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");
|
||||
@@ -670,11 +611,6 @@ namespace CutList.Web.Migrations
|
||||
|
||||
b.Navigation("StockItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,6 @@ public class CatalogService
|
||||
MaterialId = material.Id,
|
||||
LengthInches = dto.LengthInches,
|
||||
Name = dto.Name,
|
||||
QuantityOnHand = dto.QuantityOnHand,
|
||||
Notes = dto.Notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -388,7 +387,6 @@ public class CatalogService
|
||||
{
|
||||
LengthInches = s.LengthInches,
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
Notes = s.Notes
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ public class CutListPackingService
|
||||
// Build stock bins
|
||||
var stockBins = new List<StockBinSource>();
|
||||
|
||||
// Check if job has specific stock configured for this material
|
||||
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock) && materialJobStock.Count > 0)
|
||||
// Use job-specific stock configuration. Jobs must have stock explicitly configured -
|
||||
// there is no fallback to "whatever's in inventory."
|
||||
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock))
|
||||
{
|
||||
// Use job-specific stock configuration
|
||||
foreach (var stock in materialJobStock.OrderBy(s => s.Priority))
|
||||
{
|
||||
stockBins.Add(new StockBinSource
|
||||
@@ -61,37 +61,6 @@ public class CutListPackingService
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No job-specific stock - use all available stock items for this material
|
||||
var stockItems = await context.StockItems
|
||||
.Where(s => s.MaterialId == materialId && s.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var stock in stockItems)
|
||||
{
|
||||
if (stock.QuantityOnHand > 0)
|
||||
{
|
||||
// In-stock with finite quantity
|
||||
stockBins.Add(new StockBinSource
|
||||
{
|
||||
LengthInches = stock.LengthInches,
|
||||
Quantity = stock.QuantityOnHand,
|
||||
Priority = 1,
|
||||
IsInStock = true
|
||||
});
|
||||
}
|
||||
|
||||
// Always add as purchasable (unlimited) - algorithm will use in-stock first due to priority
|
||||
stockBins.Add(new StockBinSource
|
||||
{
|
||||
LengthInches = stock.LengthInches,
|
||||
Quantity = -1, // unlimited
|
||||
Priority = 2,
|
||||
IsInStock = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (stockBins.Count == 0)
|
||||
{
|
||||
@@ -139,7 +108,14 @@ public class CutListPackingService
|
||||
var inStockBins = new List<Bin>();
|
||||
var toBePurchasedBins = new List<Bin>();
|
||||
|
||||
// Track remaining in-stock quantities from the stock bins we configured
|
||||
// Catalog-sourced stock rows with an unlimited (-1) quantity are always in-stock,
|
||||
// regardless of how many bins of that length get packed.
|
||||
var unlimitedInStockLengths = stockBins
|
||||
.Where(s => s.IsInStock && s.Quantity == -1)
|
||||
.Select(s => s.LengthInches)
|
||||
.ToHashSet();
|
||||
|
||||
// Track remaining in-stock quantities from the finite-quantity catalog stock bins we configured
|
||||
var remainingStock = stockBins
|
||||
.Where(s => s.IsInStock && s.Quantity > 0)
|
||||
.GroupBy(s => s.LengthInches)
|
||||
@@ -149,9 +125,14 @@ public class CutListPackingService
|
||||
{
|
||||
var binLength = (decimal)bin.Length;
|
||||
|
||||
// Check if this can come from in-stock
|
||||
if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
|
||||
if (unlimitedInStockLengths.Contains(binLength))
|
||||
{
|
||||
// Unlimited catalog-sourced stock - always in-stock
|
||||
inStockBins.Add(bin);
|
||||
}
|
||||
else if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
|
||||
{
|
||||
// Check if this can come from in-stock
|
||||
inStockBins.Add(bin);
|
||||
remainingStock[binLength] = remaining - 1;
|
||||
}
|
||||
|
||||
@@ -108,147 +108,4 @@ public class StockItemService
|
||||
|
||||
return await query.AnyAsync();
|
||||
}
|
||||
|
||||
// Stock transaction methods
|
||||
public async Task<StockTransaction> AddStockAsync(int stockItemId, int quantity, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = quantity,
|
||||
Type = StockTransactionType.Received,
|
||||
Notes = notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand += quantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<StockTransaction> UseStockAsync(int stockItemId, int quantity, int? jobId = null, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = -quantity,
|
||||
Type = StockTransactionType.Used,
|
||||
JobId = jobId,
|
||||
Notes = notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand -= quantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<StockTransaction> AdjustStockAsync(int stockItemId, int newQuantity, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var difference = newQuantity - stockItem.QuantityOnHand;
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = difference,
|
||||
Type = StockTransactionType.Adjustment,
|
||||
Notes = notes ?? "Manual adjustment",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand = newQuantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<StockTransaction> ScrapStockAsync(int stockItemId, int quantity, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = -quantity,
|
||||
Type = StockTransactionType.Scrapped,
|
||||
Notes = notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand -= quantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<List<StockTransaction>> GetTransactionHistoryAsync(int stockItemId, int? limit = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var query = context.StockTransactions
|
||||
.Include(t => t.Job)
|
||||
.Where(t => t.StockItemId == stockItemId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> RecalculateQuantityAsync(int stockItemId)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var calculatedQuantity = await context.StockTransactions
|
||||
.Where(t => t.StockItemId == stockItemId)
|
||||
.SumAsync(t => t.Quantity);
|
||||
|
||||
if (stockItem.QuantityOnHand != calculatedQuantity)
|
||||
{
|
||||
stockItem.QuantityOnHand = calculatedQuantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return calculatedQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Remove inventory quantity tracking from CutList
|
||||
|
||||
## Background
|
||||
|
||||
`StockItem.QuantityOnHand` and its `StockTransaction` ledger (Received/Used/Adjustment/Scrapped/Returned) exist today, but nothing in the job workflow reads or writes them automatically:
|
||||
|
||||
- Adding a stock item to a job (`JobStock`) never checks or reserves `QuantityOnHand` — a user can put `Quantity = 50` on a `JobStock` row pointing at a `StockItem` with 3 on hand and nothing complains.
|
||||
- Locking a job (`JobService.LockAsync`) only stamps `LockedAt` — no transaction is created, `QuantityOnHand` doesn't move.
|
||||
- The only place `QuantityOnHand` has any real effect is `CutListPackingService.PackAsync`'s auto-discovery fallback: when a job has *no* `JobStock` rows configured for a material, the packer pulls all active `StockItem`s for that material, treats the first `QuantityOnHand` bars as free ("in stock"), and always adds an *additional unlimited* bin on top for "to be purchased" — i.e. it silently assumes more can always be bought.
|
||||
- The only way `QuantityOnHand` actually changes is a fully separate, fully manual flow (`StockItemsController` receive/use/adjust/scrap, driven from the `/stock/{id}` page) that nothing in the Jobs UI ever triggers.
|
||||
|
||||
Net effect: inventory quantity is a second, disconnected bookkeeping system that the job workflow neither respects nor maintains, and the auto-discovery fallback actively assumes unlimited purchasing beyond whatever quantity happens to be tracked. Decision: remove quantity tracking entirely. A job's stock should be exactly what the user explicitly enters for that job — nothing assumed, nothing silently topped up — and the user should never need to think about a separate "inventory" system to enter parts, stock, and get results.
|
||||
|
||||
## Scope
|
||||
|
||||
### Data model
|
||||
|
||||
- `StockItem` drops `QuantityOnHand`. Keeps `MaterialId`, `LengthInches`, `Name`, `Notes`, `IsActive`, `CreatedAt`/`UpdatedAt` — it becomes a pure catalog of known lengths per material (still used to populate the "pick a standard length" dropdown when adding stock to a job).
|
||||
- `StockTransaction` entity and `StockTransactionType` enum: **deleted entirely**, along with the `ApplicationDbContext.StockTransactions` `DbSet` and its EF configuration.
|
||||
- `JobStock` is unchanged in shape. Its `Quantity` becomes the sole source of truth for how many bars are available to a job everywhere it's used: a finite number is a hard ceiling (parts beyond it land in `ItemsNotPlaced`, nothing is silently treated as purchasable), and `-1` (unlimited) only applies when the user explicitly picks it. This "unlimited" option is extended to catalog-sourced `JobStock` rows, which today only allow it for custom-length rows.
|
||||
|
||||
### Services / API
|
||||
|
||||
- `StockItemService` drops `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`, `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`. Keeps `GetAllAsync`, `GetByMaterialAsync`, `GetByIdAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `ExistsAsync`.
|
||||
- `StockItemsController` drops `POST /{id}/receive`, `POST /{id}/use`, `POST /{id}/adjust`, `POST /{id}/scrap`, `POST /{id}/recalculate`, `GET /{id}/transactions`. Keeps list/get/create/update/delete/by-material.
|
||||
- `StockItemDto` drops `QuantityOnHand`; `CreateStockItemDto` drops `QuantityOnHand`. `StockTransactionDto`, `AddStockDto`, `UseStockDto`, `AdjustStockDto`, `ScrapStockDto` are deleted.
|
||||
- `CutListPackingService.PackAsync` drops the auto-discovery fallback branch entirely (the "no job-specific stock configured" path that reads `QuantityOnHand` and adds an unlimited purchasable bin). A material with no `JobStock` rows configured packs zero stock bins, so all of its parts land in `ItemsNotPlaced` — same code path as today's "no stock available" case. The `IsInStock` bin classification (`!IsCustomLength && StockItemId.HasValue`) is untouched, since it already doesn't depend on quantity.
|
||||
- `CatalogService`: `CatalogStockItemDto` drops `QuantityOnHand`; `ImportStockItemsAsync`/`MapStockItems` stop reading/writing it. Existing seed JSON (`alro-catalog.json`, `oneals-catalog.json`) keeps the field in the file for now — it's simply ignored on import.
|
||||
- `CutList.Mcp/InventoryTools.cs`: its own `StockItemDto` drops `QuantityOnHand`. `add_stock_item` drops the `quantityOnHand` parameter. `add_stock` convenience tool drops `quantityOnHand` and the `QuantityOnHand` field on `AddStockResult` — it becomes purely "ensure this material and stock length exist." `ApiClient` methods that pass `quantityOnHand` are updated to match. Republished to `~/.claude/mcp/CutList.Mcp/` per the standard MCP publishing workflow.
|
||||
|
||||
### UI
|
||||
|
||||
- `/stock` (Index): drop the "On Hand" column/badge. Intro copy changes from "tracks how many pieces you have on hand" to describing stock items as the lengths of material available to cut from.
|
||||
- `/stock/{id}` (Edit): drop the entire right-hand "Inventory" card (quantity badge, Add/Adjust Stock transaction form, transaction history table). Left-hand details form (Material, Length, Name, Notes) is unchanged.
|
||||
- Job Edit → Stock tab: `SaveStockFromInventoryAsync`'s quantity validation changes from `Quantity < 1` to `Quantity < -1 || Quantity == 0` (matching custom-stock validation), and the form gains an "Unlimited" option for catalog-sourced rows.
|
||||
- Job Edit → Results tab: the existing "Items Not Placed" warning stays as-is structurally; its copy changes from "No stock lengths available or parts too long" to also cover insufficient configured quantity, since a finite `JobStock.Quantity` is now the only thing that can produce unplaced items for a material that does have stock configured.
|
||||
|
||||
### Migration
|
||||
|
||||
One new EF Core migration:
|
||||
- Drops the `QuantityOnHand` column from `StockItems`.
|
||||
- Drops the `StockTransactions` table.
|
||||
|
||||
This is destructive to any `QuantityOnHand`/`StockTransaction` data currently on forge (confirmed acceptable — nothing reads it automatically today, and it isn't otherwise relied upon). Applied immediately after `dotnet ef migrations add`, per the standard EF workflow, no separate confirmation gate beyond this design doc.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to the packing algorithm itself (`CutList.Core`) — untouched, since bin selection already only consumed `StockBinSource.Quantity`/`IsInStock`, not `QuantityOnHand` directly.
|
||||
- Reworking the "Purchase List" / `ToBePurchasedBins` reporting concept — it stays as a classification of catalog-sourced vs. custom-length bins for the print report, which doesn't depend on quantity tracking.
|
||||
- Cleaning up `QuantityOnHand` values already present in `alro-catalog.json`/`oneals-catalog.json` seed files — harmless once the import path ignores the field.
|
||||
Reference in New Issue
Block a user