Ten-task plan ordered so the build stays green at each step: strip consumers first (StockItemService/DTOs/Controller, Stock/Edit.razor, Suppliers/Orders pages, Jobs/Edit.razor, CatalogService), delete the now-unused services, then remove the entities and migrate the schema last. Closes with CutList.Mcp cleanup and a manual smoke test.
115 KiB
Remove Vendor/Supplier Data Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Remove Supplier, SupplierOffering, and PurchaseItem (the Orders workflow) from CutList entirely, strip StockTransaction down to a vendor-free quantity log, and fix the bug that started this — an existing stock item's length field is hard-locked readonly in the UI.
Architecture: This is a vertical removal across a Blazor Server + EF Core app. To keep the build green at every step, consumers are updated/deleted before the entities they depend on: UI pages and controllers that reference Supplier/SupplierOffering/PurchaseItem are stripped or deleted first (Tasks 1–6), then the entities, ApplicationDbContext config, and EF migration are removed last (Task 7), once nothing references them. CutList.Mcp and seed data cleanup follow (Tasks 8–9), and a final build + manual smoke test closes it out (Task 10).
Tech Stack: .NET 8 Blazor Server (CutList.Web), EF Core + SQL Server, .NET 10 MCP stdio server (CutList.Mcp), Python (one-off catalog transform only).
Global Constraints
- No automated test project exists in this solution (confirmed:
CutList.slnhas 4 projects, none named*Tests*). Verification isdotnet build CutList.slnafter each task, plus manual smoke testing via the running app for UI-facing tasks (per project convention — seedocs/webapp-testingskill for Playwright-driven checks if needed). - Per global
CLAUDE.md: apply EF Core migrations immediately after creating them (dotnet ef database update) — do not wait for user confirmation. - Per global
CLAUDE.md: MCP servers are published to~/.claude/mcp/<ProjectName>/viadotnet publish -c Release -o. - The migration in Task 7 drops the
Suppliers,SupplierOfferings, andPurchaseItemstables and columns outright — this is destructive to whatever's currently in the local dev DB (Server=localhost\SQLEXPRESS;Database=CutListDb). That's expected and already agreed in the design doc (docs/superpowers/specs/2026-07-31-remove-vendor-data-design.md). - Design source of truth:
docs/superpowers/specs/2026-07-31-remove-vendor-data-design.md. If any task here conflicts with it, the spec wins — stop and reconcile before proceeding.
File Structure
Modified:
CutList.Web/Services/StockItemService.cs— drop supplier/price params and cost-tracking methodsCutList.Web/DTOs/StockItemDtos.cs— dropStockPricingDto, supplier/price fieldsCutList.Web/Controllers/StockItemsController.cs— drop/offerings,/pricingendpointsCutList.Web/Components/Pages/Stock/Edit.razor— editable length, strip supplier/offerings/pricing UICutList.Web/Components/Layout/NavMenu.razor— drop Orders/Suppliers linksCutList.Web/Components/Pages/Home.razor— drop Suppliers card, update copyCutList.Web/Components/Pages/Jobs/Edit.razor— "Add to Order List" → "Lock Job"CutList.Web/Program.cs— dropSupplierService/PurchaseItemServiceregistrationsCutList.Web/DTOs/CatalogDtos.cs— drop supplier/offering DTOs and fieldsCutList.Web/Services/CatalogService.cs— drop supplier import/exportCutList.Web/Data/Entities/StockTransaction.cs— dropSupplierId/UnitPrice/SuppliernavCutList.Web/Data/Entities/StockItem.cs— dropSupplierOfferingsnavCutList.Web/Data/ApplicationDbContext.cs— dropSupplier/SupplierOffering/PurchaseItemconfig andDbSetsCutList.Web/Data/SeedData/oneals-catalog.json— regenerated without vendor wrapperCutList.Mcp/InventoryTools.cs— drop supplier tools, addadd_stockCutList.Mcp/ApiClient.cs— drop supplier/offering API methods and DTOs
Deleted:
CutList.Web/Data/Entities/Supplier.csCutList.Web/Data/Entities/SupplierOffering.csCutList.Web/Data/Entities/PurchaseItem.csCutList.Web/Services/SupplierService.csCutList.Web/Services/PurchaseItemService.csCutList.Web/Controllers/SuppliersController.csCutList.Web/DTOs/SupplierDtos.csCutList.Web/Components/Pages/Suppliers/Index.razorCutList.Web/Components/Pages/Suppliers/Edit.razorCutList.Web/Components/Pages/Orders/Index.razorCutList.Web/Components/Pages/Orders/Add.razor
Created:
- One new EF Core migration under
CutList.Web/Migrations/(name:RemoveVendorData, generated by thedotnet ef migrations addcommand, not hand-written)
Task 1: StockItemService + StockItemDtos + StockItemsController — strip supplier/pricing
Files:
- Modify:
CutList.Web/Services/StockItemService.cs - Modify:
CutList.Web/DTOs/StockItemDtos.cs - Modify:
CutList.Web/Controllers/StockItemsController.cs
Interfaces:
-
Produces:
StockItemService.AddStockAsync(int stockItemId, int quantity, string? notes = null)— Task 2 (Stock/Edit.razor) calls this. -
Produces:
StockItemService.GetTransactionHistoryAsync(int stockItemId, int? limit = null)returningStockTransactionwithJobincluded but noSupplier— Task 2 consumes this for the transaction history table. -
Removes:
StockItemService.GetAverageCostAsync,GetLastPurchasePriceAsync,StockPricingDto— nothing later in this plan uses them. -
Step 1: Replace
StockItemService.cs
Replace the entire file content with:
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class StockItemService
{
private readonly IDbContextFactory<ApplicationDbContext> _factory;
public StockItemService(IDbContextFactory<ApplicationDbContext> factory)
{
_factory = factory;
}
public async Task<List<StockItem>> GetAllAsync(bool includeInactive = false)
{
await using var context = _factory.CreateDbContext();
var query = context.StockItems
.Include(s => s.Material)
.AsQueryable();
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query
.OrderBy(s => s.Material.Shape)
.ThenBy(s => s.Material.Size)
.ThenBy(s => s.LengthInches)
.ToListAsync();
}
public async Task<List<StockItem>> GetByMaterialAsync(int materialId, bool includeInactive = false)
{
await using var context = _factory.CreateDbContext();
var query = context.StockItems
.Include(s => s.Material)
.Where(s => s.MaterialId == materialId);
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query
.OrderBy(s => s.LengthInches)
.ToListAsync();
}
public async Task<StockItem?> GetByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.StockItems
.Include(s => s.Material)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<StockItem> CreateAsync(StockItem stockItem)
{
await using var context = _factory.CreateDbContext();
stockItem.CreatedAt = DateTime.UtcNow;
context.StockItems.Add(stockItem);
await context.SaveChangesAsync();
return stockItem;
}
public async Task UpdateAsync(StockItem stockItem)
{
await using var context = _factory.CreateDbContext();
stockItem.UpdatedAt = DateTime.UtcNow;
context.StockItems.Update(stockItem);
await context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
await using var context = _factory.CreateDbContext();
var stockItem = await context.StockItems.FindAsync(id);
if (stockItem != null)
{
stockItem.IsActive = false;
stockItem.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int materialId, decimal lengthInches, int? excludeId = null)
{
await using var context = _factory.CreateDbContext();
var query = context.StockItems.Where(s =>
s.MaterialId == materialId &&
s.LengthInches == lengthInches &&
s.IsActive);
if (excludeId.HasValue)
{
query = query.Where(s => s.Id != excludeId.Value);
}
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;
}
}
- Step 2: Replace
StockItemDtos.cs
Replace the entire file content with:
namespace CutList.Web.DTOs;
public class StockItemDto
{
public int Id { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
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; }
}
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; }
}
public class UpdateStockItemDto
{
public string? Length { get; set; }
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; }
}
- Step 3: Replace
StockItemsController.cs
Replace the entire file content with:
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/stock-items")]
public class StockItemsController : ControllerBase
{
private readonly StockItemService _stockItemService;
public StockItemsController(StockItemService stockItemService)
{
_stockItemService = stockItemService;
}
[HttpGet]
public async Task<ActionResult<List<StockItemDto>>> GetAll(
[FromQuery] bool includeInactive = false,
[FromQuery] int? materialId = null)
{
List<StockItem> items;
if (materialId.HasValue)
items = await _stockItemService.GetByMaterialAsync(materialId.Value, includeInactive);
else
items = await _stockItemService.GetAllAsync(includeInactive);
return Ok(items.Select(MapToDto).ToList());
}
[HttpGet("{id}")]
public async Task<ActionResult<StockItemDto>> GetById(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
return Ok(MapToDto(item));
}
[HttpPost]
public async Task<ActionResult<StockItemDto>> Create(CreateStockItemDto dto)
{
double lengthInches;
try
{
lengthInches = ArchUnits.ParseToInches(dto.Length);
}
catch
{
return BadRequest($"Invalid length format: {dto.Length}");
}
var exists = await _stockItemService.ExistsAsync(dto.MaterialId, (decimal)lengthInches);
if (exists)
return Conflict("A stock item with this material and length already exists");
var stockItem = new StockItem
{
MaterialId = dto.MaterialId,
LengthInches = (decimal)lengthInches,
Name = dto.Name,
QuantityOnHand = dto.QuantityOnHand,
Notes = dto.Notes
};
await _stockItemService.CreateAsync(stockItem);
// Reload with includes
var created = await _stockItemService.GetByIdAsync(stockItem.Id);
return CreatedAtAction(nameof(GetById), new { id = stockItem.Id }, MapToDto(created!));
}
[HttpPut("{id}")]
public async Task<ActionResult<StockItemDto>> Update(int id, UpdateStockItemDto dto)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
if (dto.Length != null)
{
try
{
item.LengthInches = (decimal)ArchUnits.ParseToInches(dto.Length);
}
catch
{
return BadRequest($"Invalid length format: {dto.Length}");
}
}
if (dto.Name != null) item.Name = dto.Name;
if (dto.Notes != null) item.Notes = dto.Notes;
await _stockItemService.UpdateAsync(item);
return Ok(MapToDto(item));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
await _stockItemService.DeleteAsync(id);
return NoContent();
}
[HttpGet("by-material/{materialId}")]
public async Task<ActionResult<List<StockItemDto>>> GetByMaterial(int materialId)
{
var items = await _stockItemService.GetByMaterialAsync(materialId);
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,
MaterialId = s.MaterialId,
MaterialName = s.Material?.DisplayName ?? string.Empty,
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
};
}
- Step 4: Build to verify no compile errors introduced yet
Run: dotnet build CutList.sln
Expected: Build FAILS — Stock/Edit.razor, Orders/*.razor, CatalogService.cs etc. still call the old AddStockAsync/GetByIdAsync overloads and reference StockPricingDto. That's expected; Task 2 onward fixes each consumer. Confirm the only errors are in those not-yet-updated files (Stock/Edit.razor, Orders/Index.razor, Orders/Add.razor), not in StockItemsController.cs/StockItemService.cs/StockItemDtos.cs themselves.
- Step 5: Commit
git add CutList.Web/Services/StockItemService.cs CutList.Web/DTOs/StockItemDtos.cs CutList.Web/Controllers/StockItemsController.cs
git commit -m "refactor: strip supplier/pricing from StockItemService and stock-items API"
Task 2: Stock/Edit.razor — editable length + strip supplier/offerings/pricing UI
Files:
- Modify:
CutList.Web/Components/Pages/Stock/Edit.razor
Interfaces:
-
Consumes:
StockItemService.AddStockAsync(int, int, string?),AdjustStockAsync,ScrapStockAsync,GetTransactionHistoryAsyncfrom Task 1. -
No longer consumes
SupplierService(any method) — this task removes the injection entirely. -
Step 1: Replace
Stock/Edit.razor
This fixes the original reported bug (length field locked readonly outside creation) and removes the Supplier Offerings card, the Supplier/Unit Price fields on the transaction form, and the Supplier/Price columns in transaction history.
Replace the entire file content with:
@page "/stock/new"
@page "/stock/{Id:int}"
@inject StockItemService StockItemService
@inject MaterialService MaterialService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
<PageTitle>@(IsNew ? "Add Stock Item" : "Edit Stock Item")</PageTitle>
<h1>@(IsNew ? "Add Stock Item" : $"{stockItem.Material?.DisplayName} - {ArchUnits.FormatFromInches((double)stockItem.LengthInches)}")</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">Stock Item Details</h5>
</div>
<div class="card-body">
<EditForm Model="stockItem" OnValidSubmit="SaveStockItemAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Material</label>
<select class="form-select" @bind="stockItem.MaterialId" disabled="@(!IsNew)">
<option value="0">-- Select Material --</option>
@foreach (var material in materials)
{
<option value="@material.Id">@material.DisplayName</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Length</label>
<LengthInput @bind-Value="stockItem.LengthInches" />
</div>
<div class="mb-3">
<label class="form-label">Name (optional)</label>
<InputText class="form-control" @bind-Value="stockItem.Name" placeholder="Custom display name" />
</div>
<div class="mb-3">
<label class="form-label">Notes (optional)</label>
<InputText class="form-control" @bind-Value="stockItem.Notes" placeholder="Internal notes" />
</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>
}
@(IsNew ? "Create Stock Item" : "Save Changes")
</button>
<a href="stock" class="btn btn-outline-secondary">@(IsNew ? "Cancel" : "Back to List")</a>
</div>
</EditForm>
</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>
}
@code {
[Parameter]
public int? Id { get; set; }
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()
{
materials = await MaterialService.GetAllAsync();
if (Id.HasValue)
{
var existing = await StockItemService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("stock");
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;
saving = true;
try
{
if (stockItem.MaterialId == 0)
{
errorMessage = "Please select a material";
return;
}
if (stockItem.LengthInches <= 0)
{
errorMessage = "Length must be greater than zero";
return;
}
var exists = await StockItemService.ExistsAsync(
stockItem.MaterialId,
stockItem.LengthInches,
IsNew ? null : stockItem.Id);
if (exists)
{
errorMessage = "A stock item with this material and length already exists";
return;
}
if (IsNew)
{
var created = await StockItemService.CreateAsync(stockItem);
Navigation.NavigateTo($"stock/{created.Id}");
}
else
{
await StockItemService.UpdateAsync(stockItem);
}
}
finally
{
saving = false;
}
}
}
Note: Material, StockItem, StockTransaction, and StockTransactionType resolve without an explicit @using — CutList.Web.Data.Entities and CutList.Web.Services are both global usings declared in CutList.Web/Components/_Imports.razor, which applies to every .razor file under Components/.
- Step 2: Build to verify this file compiles clean
Run: dotnet build CutList.sln
Expected: No errors originating from Stock/Edit.razor. Errors may still remain in Orders/*.razor, CatalogService.cs (fixed in later tasks).
- Step 3: Commit
git add CutList.Web/Components/Pages/Stock/Edit.razor
git commit -m "fix: make stock item length editable and drop vendor UI from Stock Edit page"
Task 3: Delete Suppliers & Orders pages; update NavMenu.razor + Home.razor
Files:
- Delete:
CutList.Web/Components/Pages/Suppliers/Index.razor - Delete:
CutList.Web/Components/Pages/Suppliers/Edit.razor - Delete:
CutList.Web/Components/Pages/Orders/Index.razor - Delete:
CutList.Web/Components/Pages/Orders/Add.razor - Modify:
CutList.Web/Components/Layout/NavMenu.razor - Modify:
CutList.Web/Components/Pages/Home.razor
Interfaces:
-
None — this task only removes UI entry points.
SupplierService/PurchaseItemServiceare still registered at this point (deleted in Task 5) so the build stays green even though these two services now have zero UI consumers left inCutList.Webother thanJobs/Edit.razor(fixed in Task 4) andCatalogService/StockItemsController(already fixed in Tasks 1 and 6). -
Step 1: Delete the four Razor files
git rm CutList.Web/Components/Pages/Suppliers/Index.razor
git rm CutList.Web/Components/Pages/Suppliers/Edit.razor
git rm CutList.Web/Components/Pages/Orders/Index.razor
git rm CutList.Web/Components/Pages/Orders/Add.razor
- Step 2: Replace
NavMenu.razor
Replace the entire file content with:
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">CutList</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="jobs">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="materials">
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="stock">
<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="tools">
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
</NavLink>
</div>
</nav>
</div>
- Step 3: Replace
Home.razor
Replace the entire file content with:
@page "/"
<PageTitle>CutList - Home</PageTitle>
<h1>CutList</h1>
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
<div class="row mt-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>
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p>
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
</div>
</div>
</div>
<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>
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p>
<a href="materials" class="btn btn-outline-primary">Manage Materials</a>
</div>
</div>
</div>
<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>
<p class="card-text">Configure cutting tools with their kerf widths for accurate waste calculations.</p>
<a href="tools" class="btn btn-outline-primary">Manage Tools</a>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<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 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</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>
- Step 4: Build to verify
Run: dotnet build CutList.sln
Expected: The four deleted files no longer appear in errors. Remaining errors (if any) should only be in Jobs/Edit.razor and CatalogService.cs (fixed in Tasks 4 and 6).
- Step 5: Commit
git add -A CutList.Web/Components/Pages/Suppliers CutList.Web/Components/Pages/Orders CutList.Web/Components/Layout/NavMenu.razor CutList.Web/Components/Pages/Home.razor
git commit -m "feat: remove Suppliers and Orders pages and nav entries"
Task 4: Jobs/Edit.razor — replace "Add to Order List" with "Lock Job"
Files:
- Modify:
CutList.Web/Components/Pages/Jobs/Edit.razor
Interfaces:
-
Consumes:
JobService.LockAsync(int id)/UnlockAsync(int id)— already exist, unchanged, fromCutList.Web/Services/JobService.cs. -
No longer consumes
PurchaseItemService(any method). -
Step 1: Drop the
PurchaseItemServiceinjection
In CutList.Web/Components/Pages/Jobs/Edit.razor, find:
@inject CutListPackingService PackingService
@inject PurchaseItemService PurchaseItemService
@inject NavigationManager Navigation
Replace with:
@inject CutListPackingService PackingService
@inject NavigationManager Navigation
- Step 2: Rename the tracking fields
Find:
private bool addingToOrderList;
private bool addedToOrderList;
Replace with:
private bool lockingJob;
private bool jobLocked;
- Step 3: Update
LoadSavedResultsAsync
Find:
summary = PackingService.GetSummary(packResult);
addedToOrderList = job.IsLocked;
}
}
catch
{
// Invalid JSON — treat as no results
Replace with:
summary = PackingService.GetSummary(packResult);
jobLocked = job.IsLocked;
}
}
catch
{
// Invalid JSON — treat as no results
- Step 4: Replace
AddToOrderListwithLockJob
Find:
// Refresh job to get updated OptimizedAt
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = job.IsLocked;
}
finally
{
optimizing = false;
}
}
private async Task AddToOrderList()
{
addingToOrderList = 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;
}
}
Replace with:
// Refresh job to get updated OptimizedAt
job = (await JobService.GetByIdAsync(Id!.Value))!;
jobLocked = job.IsLocked;
}
finally
{
optimizing = false;
}
}
private async Task LockJob()
{
lockingJob = true;
try
{
await JobService.LockAsync(Id!.Value);
job = (await JobService.GetByIdAsync(Id!.Value))!;
jobLocked = true;
}
finally
{
lockingJob = false;
}
}
- Step 5: Update the Purchase List card markup
Find:
<!-- Purchase List -->
<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 (addedToOrderList)
{
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Added to orders</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus me-1"></i>Add to Order List
</button>
}
}
</div>
<div class="card-body">
@if (summary.TotalToBePurchasedBins == 0)
{
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
}
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">
Replace with:
<!-- Purchase List -->
<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 (jobLocked)
{
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</button>
}
}
</div>
<div class="card-body">
@if (summary.TotalToBePurchasedBins == 0)
{
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
}
else
{
<div class="table-responsive">
- Step 6: Build to verify
Run: dotnet build CutList.sln
Expected: No errors in Jobs/Edit.razor. Remaining errors (if any) should only be in CatalogService.cs/CatalogDtos.cs (fixed in Task 6) and files deleted in Task 5.
- Step 7: Commit
git add CutList.Web/Components/Pages/Jobs/Edit.razor
git commit -m "feat: replace Add to Order List with a direct Lock Job action"
Task 5: Delete SupplierService, SuppliersController, SupplierDtos.cs, PurchaseItemService; update Program.cs
Files:
- Delete:
CutList.Web/Services/SupplierService.cs - Delete:
CutList.Web/Controllers/SuppliersController.cs - Delete:
CutList.Web/DTOs/SupplierDtos.cs - Delete:
CutList.Web/Services/PurchaseItemService.cs - Modify:
CutList.Web/Program.cs
Interfaces:
-
Removes the
SupplierServiceandPurchaseItemServiceDI registrations that Tasks 1–4 already stopped using. -
Step 1: Delete the four files
git rm CutList.Web/Services/SupplierService.cs
git rm CutList.Web/Controllers/SuppliersController.cs
git rm CutList.Web/DTOs/SupplierDtos.cs
git rm CutList.Web/Services/PurchaseItemService.cs
- Step 2: Update
Program.cs
Find:
// 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>();
Replace with:
// Add application services
builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<JobService>();
builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<CatalogService>();
- Step 3: Build to verify
Run: dotnet build CutList.sln
Expected: No errors referencing SupplierService, PurchaseItemService, SuppliersController, or the SupplierDto/CreateSupplierDto/OfferingDto family. Remaining errors (if any) should only be in CatalogService.cs/CatalogDtos.cs (Task 6) and the entity/DbContext files (Task 7).
- Step 4: Commit
git add -A CutList.Web/Services/SupplierService.cs CutList.Web/Controllers/SuppliersController.cs CutList.Web/DTOs/SupplierDtos.cs CutList.Web/Services/PurchaseItemService.cs CutList.Web/Program.cs
git commit -m "feat: delete SupplierService, PurchaseItemService, and their API surface"
Task 6: CatalogDtos.cs + CatalogService.cs — drop supplier import/export
Files:
- Modify:
CutList.Web/DTOs/CatalogDtos.cs - Modify:
CutList.Web/Services/CatalogService.cs
Interfaces:
-
Produces:
CatalogDatawith noSuppliersproperty;CatalogStockItemDtowith noSupplierOfferingsproperty — Task 8 (regeneratingoneals-catalog.json) must match this shape. -
CatalogController.csis unaffected (it just serializes/deserializesCatalogDataand callsCatalogService.ExportAsync/ImportAsync— no direct field references), so it needs no changes. -
Step 1: Replace
CatalogDtos.cs
Replace the entire file content with:
namespace CutList.Web.DTOs;
public class CatalogData
{
public DateTime ExportedAt { get; set; }
public List<CatalogCuttingToolDto> CuttingTools { get; set; } = [];
public CatalogMaterialsDto Materials { get; set; } = new();
}
public class CatalogCuttingToolDto
{
public string Name { get; set; } = "";
public decimal KerfInches { get; set; }
public bool IsDefault { get; set; }
}
public class CatalogMaterialsDto
{
public List<CatalogAngleDto> Angles { get; set; } = [];
public List<CatalogChannelDto> Channels { get; set; } = [];
public List<CatalogFlatBarDto> FlatBars { get; set; } = [];
public List<CatalogIBeamDto> IBeams { get; set; } = [];
public List<CatalogPipeDto> Pipes { get; set; } = [];
public List<CatalogRectangularTubeDto> RectangularTubes { get; set; } = [];
public List<CatalogRoundBarDto> RoundBars { get; set; } = [];
public List<CatalogRoundTubeDto> RoundTubes { get; set; } = [];
public List<CatalogSquareBarDto> SquareBars { get; set; } = [];
public List<CatalogSquareTubeDto> SquareTubes { get; set; } = [];
}
public abstract class CatalogMaterialBaseDto
{
public string Type { get; set; } = "";
public string? Grade { get; set; }
public string Size { get; set; } = "";
public string? Description { get; set; }
public List<CatalogStockItemDto> StockItems { get; set; } = [];
}
public class CatalogAngleDto : CatalogMaterialBaseDto
{
public decimal Leg1 { get; set; }
public decimal Leg2 { get; set; }
public decimal Thickness { get; set; }
}
public class CatalogChannelDto : CatalogMaterialBaseDto
{
public decimal Height { get; set; }
public decimal Flange { get; set; }
public decimal Web { get; set; }
}
public class CatalogFlatBarDto : CatalogMaterialBaseDto
{
public decimal Width { get; set; }
public decimal Thickness { get; set; }
}
public class CatalogIBeamDto : CatalogMaterialBaseDto
{
public decimal Height { get; set; }
public decimal WeightPerFoot { get; set; }
}
public class CatalogPipeDto : CatalogMaterialBaseDto
{
public decimal NominalSize { get; set; }
public decimal Wall { get; set; }
public string? Schedule { get; set; }
}
public class CatalogRectangularTubeDto : CatalogMaterialBaseDto
{
public decimal Width { get; set; }
public decimal Height { get; set; }
public decimal Wall { get; set; }
}
public class CatalogRoundBarDto : CatalogMaterialBaseDto
{
public decimal Diameter { get; set; }
}
public class CatalogRoundTubeDto : CatalogMaterialBaseDto
{
public decimal OuterDiameter { get; set; }
public decimal Wall { get; set; }
}
public class CatalogSquareBarDto : CatalogMaterialBaseDto
{
public decimal SideLength { get; set; }
}
public class CatalogSquareTubeDto : CatalogMaterialBaseDto
{
public decimal SideLength { get; set; }
public decimal Wall { get; set; }
}
public class CatalogStockItemDto
{
public decimal LengthInches { get; set; }
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
}
public class ImportResultDto
{
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 List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
}
- Step 2: Update
CatalogService.ExportAsync— drop the suppliers query
Find:
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
Replace with:
await using var context = _factory.CreateDbContext();
var cuttingTools = await context.CuttingTools
- Step 3: Drop the
SupplierOfferingsinclude on materials
Find:
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)
Replace with:
var materials = await context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems.Where(s => s.IsActive))
.Where(m => m.IsActive)
- Step 4: Update the
MapStockItemscall site
Find:
var stockItems = MapStockItems(m, suppliers);
Replace with:
var stockItems = MapStockItems(m);
- Step 5: Drop
Suppliersfrom the returnedCatalogData
Find:
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
Replace with:
return new CatalogData
{
ExportedAt = DateTime.UtcNow,
CuttingTools = cuttingTools.Select(t => new CatalogCuttingToolDto
- Step 6: Update
ImportAsyncto drop the supplier-import step
Find:
try
{
// 1. Suppliers - upsert by name
var supplierMap = await ImportSuppliersAsync(context, data.Suppliers, result);
// 2. Cutting tools - upsert by name
await ImportCuttingToolsAsync(context, data.CuttingTools, result);
// 3. Materials + stock items + offerings
await ImportAllMaterialsAsync(context, data.Materials, supplierMap, result);
await transaction.CommitAsync();
Replace with:
try
{
// 1. Cutting tools - upsert by name
await ImportCuttingToolsAsync(context, data.CuttingTools, result);
// 2. Materials + stock items
await ImportAllMaterialsAsync(context, data.Materials, result);
await transaction.CommitAsync();
- Step 7: Delete the
ImportSuppliersAsyncmethod
Find (and delete entirely — it sits between ImportAsync and ImportCuttingToolsAsync):
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;
}
Delete this block (nothing to replace it with — remove the method and the blank line after it, leaving ImportCuttingToolsAsync directly following ImportAsync).
- Step 8: Update
ImportAllMaterialsAsync— drop thesupplierMapparameter
Find:
private async Task ImportAllMaterialsAsync(
ApplicationDbContext context, CatalogMaterialsDto materials, Dictionary<string, int> supplierMap, 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,
() => 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,
() => 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,
() => 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,
() => 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,
() => 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,
() => 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,
() => 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,
() => 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,
() => 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,
() => new SquareTubeDimensions { Size = dto.SideLength, Wall = dto.Wall },
dim => { var d = (SquareTubeDimensions)dim; d.Size = dto.SideLength; d.Wall = dto.Wall; });
}
Replace with:
private async Task ImportAllMaterialsAsync(
ApplicationDbContext context, CatalogMaterialsDto materials, ImportResultDto result)
{
var existingMaterials = await context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems)
.ToListAsync();
foreach (var dto in materials.Angles)
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, 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, 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, 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, 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, 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, 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, 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, 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, result,
() => new SquareTubeDimensions { Size = dto.SideLength, Wall = dto.Wall },
dim => { var d = (SquareTubeDimensions)dim; d.Size = dto.SideLength; d.Wall = dto.Wall; });
}
- Step 9: Update
ImportMaterialAsync— drop thesupplierMapparameter
Find:
private async Task ImportMaterialAsync(
ApplicationDbContext context, CatalogMaterialBaseDto dto, MaterialShape shape,
List<Material> existingMaterials, Dictionary<string, int> supplierMap,
ImportResultDto result,
Func<MaterialDimensions> createDimensions,
Action<MaterialDimensions> updateDimensions)
{
Replace with:
private async Task ImportMaterialAsync(
ApplicationDbContext context, CatalogMaterialBaseDto dto, MaterialShape shape,
List<Material> existingMaterials,
ImportResultDto result,
Func<MaterialDimensions> createDimensions,
Action<MaterialDimensions> updateDimensions)
{
Find:
await context.SaveChangesAsync();
await ImportStockItemsAsync(context, material, dto.StockItems, supplierMap, result);
}
catch (Exception ex)
{
result.Errors.Add($"Material '{shape} - {dto.Size}': {ex.Message}");
}
}
Replace with:
await context.SaveChangesAsync();
await ImportStockItemsAsync(context, material, dto.StockItems, result);
}
catch (Exception ex)
{
result.Errors.Add($"Material '{shape} - {dto.Size}': {ex.Message}");
}
}
- Step 10: Update
ImportStockItemsAsync— dropsupplierMapand the offerings loop
Find:
private async Task ImportStockItemsAsync(
ApplicationDbContext context, Material material, List<CatalogStockItemDto> stockItems,
Dictionary<string, int> supplierMap, ImportResultDto result)
{
var existingStockItems = await context.StockItems
.Include(s => s.SupplierOfferings)
.Where(s => s.MaterialId == material.Id)
.ToListAsync();
Replace with:
private async Task ImportStockItemsAsync(
ApplicationDbContext context, Material material, List<CatalogStockItemDto> stockItems,
ImportResultDto result)
{
var existingStockItems = await context.StockItems
.Where(s => s.MaterialId == material.Id)
.ToListAsync();
Find:
context.StockItems.Add(stockItem);
await context.SaveChangesAsync();
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();
Replace with:
context.StockItems.Add(stockItem);
await context.SaveChangesAsync();
existingStockItems.Add(stockItem);
result.StockItemsCreated++;
}
(Note: the trailing await context.SaveChangesAsync(); that was after the offerings loop is removed along with it — the stock item's own SaveChangesAsync calls above already persist it.)
- Step 11: Update
MapStockItems— drop thesuppliersparam and offerings mapping
Find:
private static List<CatalogStockItemDto> MapStockItems(Material m, List<Supplier> suppliers)
{
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()
}).ToList();
}
}
Replace with:
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
}).ToList();
}
}
- Step 12: Build to verify
Run: dotnet build CutList.sln
Expected: No errors in CatalogService.cs/CatalogDtos.cs/CatalogController.cs. Remaining errors (if any) should only be in the entity/DbContext files (fixed in Task 7).
- Step 13: Commit
git add CutList.Web/DTOs/CatalogDtos.cs CutList.Web/Services/CatalogService.cs
git commit -m "feat: drop supplier import/export from the catalog format"
Task 7: Entities + ApplicationDbContext — remove Supplier/SupplierOffering/PurchaseItem, strip StockTransaction, migrate
Files:
- Delete:
CutList.Web/Data/Entities/Supplier.cs - Delete:
CutList.Web/Data/Entities/SupplierOffering.cs - Delete:
CutList.Web/Data/Entities/PurchaseItem.cs - Modify:
CutList.Web/Data/Entities/StockTransaction.cs - Modify:
CutList.Web/Data/Entities/StockItem.cs - Modify:
CutList.Web/Data/ApplicationDbContext.cs - Create: new EF Core migration under
CutList.Web/Migrations/
Interfaces:
-
This is the final step — by this point (Tasks 1–6 complete), nothing in
CutList.WebreferencesSupplier,SupplierOffering,PurchaseItem,StockTransaction.SupplierId,StockTransaction.UnitPrice, orStockItem.SupplierOfferings. This task just removes the now-dead entities and generates the schema migration. -
Step 1: Delete the three entity files
git rm CutList.Web/Data/Entities/Supplier.cs
git rm CutList.Web/Data/Entities/SupplierOffering.cs
git rm CutList.Web/Data/Entities/PurchaseItem.cs
- Step 2: Replace
StockTransaction.cs
Replace the entire file content with:
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
}
- Step 3: Replace
StockItem.cs
Replace the entire file content with:
namespace CutList.Web.Data.Entities;
public class StockItem
{
public int Id { get; set; }
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>();
}
- Step 4: Update
ApplicationDbContext.cs— remove the threeDbSets
Find:
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>();
Replace with:
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>();
public DbSet<JobStock> JobStocks => Set<JobStock>();
- Step 5: Remove the
Supplierconfig block
Find:
// 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
Replace with:
// StockItem
- Step 6: Strip the
StockTransactionconfig block
Find:
// StockTransaction
modelBuilder.Entity<StockTransaction>(entity =>
{
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)
.WithMany(s => s.Transactions)
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Job)
.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
Replace with:
// 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
- Step 7: Remove the
PurchaseItemconfig block
Find:
// 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
Replace with:
// Seed default cutting tools
- Step 8: Build to verify the whole solution compiles clean
Run: dotnet build CutList.sln
Expected: Build SUCCEEDS with zero errors. If anything still references Supplier, SupplierOffering, or PurchaseItem, the build will name the file — stop and check that Tasks 1–6 were fully applied (a common miss is a stray SupplierOfferings reference left in CatalogService.cs or an unremoved using in a deleted-adjacent file).
- Step 9: Generate and apply the EF Core migration
Run: dotnet ef migrations add RemoveVendorData --project CutList.Web
Expected: A new migration file appears under CutList.Web/Migrations/ dropping the Suppliers, SupplierOfferings, PurchaseItems tables and the SupplierId/UnitPrice columns (plus their FKs/indexes) from StockTransactions.
Then run: dotnet ef database update --project CutList.Web
Expected: Migration applies to the local CutListDb database with no errors. Per the global constraint above, apply immediately — don't wait for separate confirmation.
- Step 10: Commit
git add -A CutList.Web/Data/Entities/Supplier.cs CutList.Web/Data/Entities/SupplierOffering.cs CutList.Web/Data/Entities/PurchaseItem.cs CutList.Web/Data/Entities/StockTransaction.cs CutList.Web/Data/Entities/StockItem.cs CutList.Web/Data/ApplicationDbContext.cs CutList.Web/Migrations
git commit -m "feat: remove Supplier/SupplierOffering/PurchaseItem entities and migrate schema"
Task 8: Regenerate oneals-catalog.json without the vendor wrapper
Files:
- Modify:
CutList.Web/Data/SeedData/oneals-catalog.json
Interfaces:
-
Consumes: the
CatalogData/CatalogStockItemDtoshape from Task 6 (supplierskey removed, stock items are{ lengthInches, name, quantityOnHand, notes }with nosupplierOfferings). -
Step 1: Run a one-off transform
This mirrors what was already done for alro-catalog.json earlier — no scraper exists for O'Neal's data, so this is a direct JSON transform of the existing file (which has real part numbers/prices in supplierOfferings that get discarded, per the design doc's explicit call).
Run:
cd "C:\Users\aisaacs\Desktop\Projects\CutList" && python -c "
import json
path = 'CutList.Web/Data/SeedData/oneals-catalog.json'
data = json.load(open(path, encoding='utf-8'))
data['suppliers'] = []
for group in data['materials'].values():
for material in group:
for stock_item in material.get('stockItems', []):
stock_item.pop('supplierOfferings', None)
json.dump(data, open(path, 'w', encoding='utf-8'), indent=2, ensure_ascii=False)
print('Rewrote', path)
"
Expected output: Rewrote CutList.Web/Data/SeedData/oneals-catalog.json
- Step 2: Verify the transform
Run:
cd "C:\Users\aisaacs\Desktop\Projects\CutList" && python -c "
import json
d = json.load(open('CutList.Web/Data/SeedData/oneals-catalog.json'))
print(d['suppliers'])
import itertools
for k, v in d['materials'].items():
if v:
print(k, json.dumps(v[0], indent=2))
break
"
Expected: suppliers prints [], and the sample material's stockItems show only lengthInches/name/quantityOnHand/notes — no supplierOfferings key, no part numbers.
- Step 3: Commit
git add CutList.Web/Data/SeedData/oneals-catalog.json
git commit -m "chore: strip vendor data from oneals-catalog.json seed file"
Task 9: CutList.Mcp — remove supplier tools, add add_stock, republish
Files:
- Modify:
CutList.Mcp/InventoryTools.cs - Modify:
CutList.Mcp/ApiClient.cs
Interfaces:
-
Produces: MCP tool
add_stock(shape, size, length, quantityOnHand, type, grade)→AddStockResult— replacesadd_stock_with_offering. -
Removes: MCP tools
list_suppliers,add_supplier,list_supplier_offerings,add_supplier_offering,add_stock_with_offering. -
No dependency on
CutList.Web's removed endpoints remains after this task —ApiClientno longer callsapi/suppliersorapi/*/offerings, which no longer exist per Task 5. -
Step 1: Update
ApiClient.cs— remove the Suppliers region
Find:
#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
Replace with:
#region Materials
- Step 2: Update
ApiClient.cs— remove the Offerings region
Find:
#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
}
Replace with:
}
- Step 3: Update
ApiClient.cs— removeApiSupplierDtoandApiOfferingDto
Find:
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
Replace with:
public class ApiMaterialDto
Find:
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
Replace with:
#endregion
- Step 4: Update
InventoryTools.cs— remove the Suppliers region
Find:
#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
Replace with:
#region Materials
- Step 5: Update
InventoryTools.cs— replace the Supplier Offerings region with aadd_stockconvenience tool
Find (this whole block, from the #region Supplier Offerings line through the #endregion that follows AddStockWithOffering):
#region Supplier Offerings
[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,
[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("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)
{
// Parse length for formatted display
double lengthInches;
try
{
lengthInches = double.TryParse(length.Trim(), out var plain)
? plain
: ArchUnits.ParseToInches(length);
}
catch
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Could not parse length: {length}"
};
}
// Step 1: Find or create material
bool materialCreated = false;
ApiMaterialDto? material = null;
// Search for existing material by shape and size
var materials = await _api.GetMaterialsAsync(shape);
material = materials.FirstOrDefault(m =>
m.Size.Equals(size, StringComparison.OrdinalIgnoreCase) &&
m.Type.Equals(type, StringComparison.OrdinalIgnoreCase) &&
string.Equals(m.Grade, grade, StringComparison.OrdinalIgnoreCase));
if (material == null)
{
// Parse dimensions from size string for the API
var dimensions = ParseSizeStringToDimensions(shape, size);
try
{
material = await _api.CreateMaterialAsync(shape, size, null, type, grade, dimensions);
materialCreated = true;
}
catch (ApiConflictException)
{
// Race condition - material was created between check and create, re-fetch
materials = await _api.GetMaterialsAsync(shape);
material = materials.FirstOrDefault(m =>
m.Size.Equals(size, StringComparison.OrdinalIgnoreCase));
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult { Success = false, Error = $"Failed to create material: {ex.Message}" };
}
}
if (material == null)
return new AddStockWithOfferingResult { Success = false, Error = "Failed to find or create material" };
// Step 2: Find or create stock item
bool stockItemCreated = false;
var stockItems = await _api.GetStockItemsAsync(material.Id);
var stockItem = stockItems.FirstOrDefault(s => Math.Abs((double)s.LengthInches - lengthInches) < 0.01);
if (stockItem == null)
{
try
{
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, 0, null);
stockItemCreated = true;
}
catch (ApiConflictException)
{
// Race condition - re-fetch
stockItems = await _api.GetStockItemsAsync(material.Id);
stockItem = stockItems.FirstOrDefault(s => Math.Abs((double)s.LengthInches - lengthInches) < 0.01);
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Failed to create stock item: {ex.Message}",
MaterialCreated = materialCreated
};
}
}
if (stockItem == null)
return new AddStockWithOfferingResult
{
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
{
Success = true,
MaterialId = material.Id,
MaterialName = $"{material.Shape} - {material.Size}",
MaterialCreated = materialCreated,
StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
OfferingId = offering?.Id ?? 0,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price
};
}
catch (ApiConflictException)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Offering for this supplier and stock item already exists",
MaterialCreated = materialCreated,
StockItemCreated = stockItemCreated
};
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Failed to create offering: {ex.Message}",
MaterialCreated = materialCreated,
StockItemCreated = stockItemCreated
};
}
}
#endregion
Replace with:
#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.")]
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)
{
// Parse length for formatted display
double lengthInches;
try
{
lengthInches = double.TryParse(length.Trim(), out var plain)
? plain
: ArchUnits.ParseToInches(length);
}
catch
{
return new AddStockResult
{
Success = false,
Error = $"Could not parse length: {length}"
};
}
// Step 1: Find or create material
bool materialCreated = false;
ApiMaterialDto? material = null;
// Search for existing material by shape and size
var materials = await _api.GetMaterialsAsync(shape);
material = materials.FirstOrDefault(m =>
m.Size.Equals(size, StringComparison.OrdinalIgnoreCase) &&
m.Type.Equals(type, StringComparison.OrdinalIgnoreCase) &&
string.Equals(m.Grade, grade, StringComparison.OrdinalIgnoreCase));
if (material == null)
{
// Parse dimensions from size string for the API
var dimensions = ParseSizeStringToDimensions(shape, size);
try
{
material = await _api.CreateMaterialAsync(shape, size, null, type, grade, dimensions);
materialCreated = true;
}
catch (ApiConflictException)
{
// Race condition - material was created between check and create, re-fetch
materials = await _api.GetMaterialsAsync(shape);
material = materials.FirstOrDefault(m =>
m.Size.Equals(size, StringComparison.OrdinalIgnoreCase));
}
catch (HttpRequestException ex)
{
return new AddStockResult { Success = false, Error = $"Failed to create material: {ex.Message}" };
}
}
if (material == null)
return new AddStockResult { Success = false, Error = "Failed to find or create material" };
// Step 2: Find or create stock item
bool stockItemCreated = false;
var stockItems = await _api.GetStockItemsAsync(material.Id);
var stockItem = stockItems.FirstOrDefault(s => Math.Abs((double)s.LengthInches - lengthInches) < 0.01);
if (stockItem == null)
{
try
{
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, quantityOnHand, null);
stockItemCreated = true;
}
catch (ApiConflictException)
{
// Race condition - re-fetch
stockItems = await _api.GetStockItemsAsync(material.Id);
stockItem = stockItems.FirstOrDefault(s => Math.Abs((double)s.LengthInches - lengthInches) < 0.01);
}
catch (HttpRequestException ex)
{
return new AddStockResult
{
Success = false,
Error = $"Failed to create stock item: {ex.Message}",
MaterialCreated = materialCreated
};
}
}
if (stockItem == null)
return new AddStockResult
{
Success = false,
Error = "Failed to find or create stock item",
MaterialCreated = materialCreated
};
return new AddStockResult
{
Success = true,
MaterialId = material.Id,
MaterialName = $"{material.Shape} - {material.Size}",
MaterialCreated = materialCreated,
StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
QuantityOnHand = stockItem.QuantityOnHand
};
}
#endregion
- Step 6: Update the module doc comment
Find:
/// <summary>
/// MCP tools for inventory management - suppliers, materials, stock items, and offerings.
/// All calls go through the CutList.Web REST API via ApiClient.
/// </summary>
Replace with:
/// <summary>
/// MCP tools for inventory management - materials and stock items.
/// All calls go through the CutList.Web REST API via ApiClient.
/// </summary>
- Step 7: Update the DTOs section — remove Supplier DTOs, replace AddStockWithOfferingResult*
Find:
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
Replace with:
public class MaterialDimensionsDto
Find:
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 bool Success { get; set; }
public string? Error { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public bool MaterialCreated { get; set; }
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; }
}
#endregion
Replace with:
public class AddStockResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public bool MaterialCreated { get; set; }
public int StockItemId { get; set; }
public bool StockItemCreated { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int QuantityOnHand { get; set; }
}
#endregion
- Step 8: Build
Run: dotnet build CutList.Mcp/CutList.Mcp.csproj
Expected: Build succeeds with zero errors.
- Step 9: Republish the MCP server
Run: dotnet publish CutList.Mcp/CutList.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/CutList.Mcp"
Expected: Publish succeeds. ~/.claude/settings.local.json already registers this server at that path (per global CLAUDE.md's MCP Server Publishing table) — no further registration needed, but if the CutListMcp server is currently running in this session, it needs a restart to pick up the new binary (not required to complete this task, just a note for whoever is using the tools next).
- Step 10: Commit
git add CutList.Mcp/InventoryTools.cs CutList.Mcp/ApiClient.cs
git commit -m "feat: remove supplier MCP tools, add plain add_stock convenience tool"
Task 10: Full solution build + manual smoke test
Files: none (verification only)
- Step 1: Full solution build
Run: dotnet build CutList.sln
Expected: Build succeeds, 0 errors, 0 new warnings beyond what existed before this work.
- Step 2: Start CutList.Web
Run: dotnet run --project CutList.Web/CutList.Web.csproj (leave running; default http://localhost:5270)
- Step 3: Verify the original bug is fixed
Navigate to http://localhost:5270/stock, open any existing stock item, confirm the Length field is now an editable LengthInput (not a greyed-out readonly box), change the length, save, and confirm the new length shows in the page title and the stock list.
-
Step 4: Verify vendor UI is gone
-
Navigate to
http://localhost:5270/suppliersandhttp://localhost:5270/orders— both should 404 (no matching route). -
Confirm the left nav has no "Suppliers" or "Orders" links.
-
Confirm
http://localhost:5270/(Home) has no Suppliers card and the "How It Works" list no longer mentions suppliers. -
On the Stock Item edit page, confirm there's no "Supplier Offerings" card and the "Add/Adjust Stock" form has no Supplier dropdown or Unit Price field.
-
Step 5: Verify Lock Job works
Open (or create) a job with parts and stock, run Optimize, confirm a "Lock Job" button appears in the Purchase List card (if there are items to purchase) instead of "Add to Order List." Click it, confirm the job locks (fieldset disables, "This job is locked" banner appears), and confirm Unlock still works.
- Step 6: Verify the stock transaction log still works, without vendor fields
On a stock item's edit page, click "Add/Adjust Stock," receive some quantity, save, and confirm the transaction history table shows Date/Type/Qty/Notes columns only (no Supplier or Price column) and the quantity on hand updated.
- Step 7: Stop the app, check Swagger for the API surface
With the app running, navigate to http://localhost:5270/swagger and confirm there is no SuppliersController group and StockItemsController no longer lists /offerings or /pricing endpoints.
- Step 8: Final check — no stray references
Run: grep -ril "Supplier\|PurchaseItem" CutList.Web CutList.Mcp --include=*.cs --include=*.razor
Expected: No output (empty). If anything prints, it's a missed spot from an earlier task — go back and fix it before considering this plan complete.
No commit for this task — it's verification only. If any step surfaces a problem, fix it as a small follow-up commit referencing which task's change was incomplete.