Files
CutList/docs/superpowers/plans/2026-07-31-remove-vendor-data.md
T
aj 9296bea0bc docs: add implementation plan for removing vendor/supplier data
Ten-task plan ordered so the build stays green at each step: strip
consumers first (StockItemService/DTOs/Controller, Stock/Edit.razor,
Suppliers/Orders pages, Jobs/Edit.razor, CatalogService), delete the
now-unused services, then remove the entities and migrate the schema
last. Closes with CutList.Mcp cleanup and a manual smoke test.
2026-07-31 22:56:41 -04:00

3254 lines
115 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 16), 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 89), 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.sln` has 4 projects, none named `*Tests*`). Verification is `dotnet build CutList.sln` after each task, plus manual smoke testing via the running app for UI-facing tasks (per project convention — see `docs/webapp-testing` skill 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>/` via `dotnet publish -c Release -o`.
- The migration in Task 7 drops the `Suppliers`, `SupplierOfferings`, and `PurchaseItems` tables 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 methods
- `CutList.Web/DTOs/StockItemDtos.cs` — drop `StockPricingDto`, supplier/price fields
- `CutList.Web/Controllers/StockItemsController.cs` — drop `/offerings`, `/pricing` endpoints
- `CutList.Web/Components/Pages/Stock/Edit.razor` — editable length, strip supplier/offerings/pricing UI
- `CutList.Web/Components/Layout/NavMenu.razor` — drop Orders/Suppliers links
- `CutList.Web/Components/Pages/Home.razor` — drop Suppliers card, update copy
- `CutList.Web/Components/Pages/Jobs/Edit.razor` — "Add to Order List" → "Lock Job"
- `CutList.Web/Program.cs` — drop `SupplierService`/`PurchaseItemService` registrations
- `CutList.Web/DTOs/CatalogDtos.cs` — drop supplier/offering DTOs and fields
- `CutList.Web/Services/CatalogService.cs` — drop supplier import/export
- `CutList.Web/Data/Entities/StockTransaction.cs` — drop `SupplierId`/`UnitPrice`/`Supplier` nav
- `CutList.Web/Data/Entities/StockItem.cs` — drop `SupplierOfferings` nav
- `CutList.Web/Data/ApplicationDbContext.cs` — drop `Supplier`/`SupplierOffering`/`PurchaseItem` config and `DbSet`s
- `CutList.Web/Data/SeedData/oneals-catalog.json` — regenerated without vendor wrapper
- `CutList.Mcp/InventoryTools.cs` — drop supplier tools, add `add_stock`
- `CutList.Mcp/ApiClient.cs` — drop supplier/offering API methods and DTOs
**Deleted:**
- `CutList.Web/Data/Entities/Supplier.cs`
- `CutList.Web/Data/Entities/SupplierOffering.cs`
- `CutList.Web/Data/Entities/PurchaseItem.cs`
- `CutList.Web/Services/SupplierService.cs`
- `CutList.Web/Services/PurchaseItemService.cs`
- `CutList.Web/Controllers/SuppliersController.cs`
- `CutList.Web/DTOs/SupplierDtos.cs`
- `CutList.Web/Components/Pages/Suppliers/Index.razor`
- `CutList.Web/Components/Pages/Suppliers/Edit.razor`
- `CutList.Web/Components/Pages/Orders/Index.razor`
- `CutList.Web/Components/Pages/Orders/Add.razor`
**Created:**
- One new EF Core migration under `CutList.Web/Migrations/` (name: `RemoveVendorData`, generated by the `dotnet ef migrations add` command, 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)` returning `StockTransaction` with `Job` included but no `Supplier` — 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:
```csharp
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:
```csharp
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:
```csharp
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**
```bash
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`, `GetTransactionHistoryAsync` from 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:
```razor
@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**
```bash
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`/`PurchaseItemService` are 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 in `CutList.Web` other than `Jobs/Edit.razor` (fixed in Task 4) and `CatalogService`/`StockItemsController` (already fixed in Tasks 1 and 6).
- [ ] **Step 1: Delete the four Razor files**
```bash
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:
```razor
<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:
```razor
@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**
```bash
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, from `CutList.Web/Services/JobService.cs`.
- No longer consumes `PurchaseItemService` (any method).
- [ ] **Step 1: Drop the `PurchaseItemService` injection**
In `CutList.Web/Components/Pages/Jobs/Edit.razor`, find:
```razor
@inject CutListPackingService PackingService
@inject PurchaseItemService PurchaseItemService
@inject NavigationManager Navigation
```
Replace with:
```razor
@inject CutListPackingService PackingService
@inject NavigationManager Navigation
```
- [ ] **Step 2: Rename the tracking fields**
Find:
```csharp
private bool addingToOrderList;
private bool addedToOrderList;
```
Replace with:
```csharp
private bool lockingJob;
private bool jobLocked;
```
- [ ] **Step 3: Update `LoadSavedResultsAsync`**
Find:
```csharp
summary = PackingService.GetSummary(packResult);
addedToOrderList = job.IsLocked;
}
}
catch
{
// Invalid JSON — treat as no results
```
Replace with:
```csharp
summary = PackingService.GetSummary(packResult);
jobLocked = job.IsLocked;
}
}
catch
{
// Invalid JSON — treat as no results
```
- [ ] **Step 4: Replace `AddToOrderList` with `LockJob`**
Find:
```csharp
// 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:
```csharp
// 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:
```razor
<!-- 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:
```razor
<!-- 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**
```bash
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 `SupplierService` and `PurchaseItemService` DI registrations that Tasks 14 already stopped using.
- [ ] **Step 1: Delete the four files**
```bash
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:
```csharp
// 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:
```csharp
// 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**
```bash
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: `CatalogData` with no `Suppliers` property; `CatalogStockItemDto` with no `SupplierOfferings` property — Task 8 (regenerating `oneals-catalog.json`) must match this shape.
- `CatalogController.cs` is unaffected (it just serializes/deserializes `CatalogData` and calls `CatalogService.ExportAsync`/`ImportAsync` — no direct field references), so it needs no changes.
- [ ] **Step 1: Replace `CatalogDtos.cs`**
Replace the entire file content with:
```csharp
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:
```csharp
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:
```csharp
await using var context = _factory.CreateDbContext();
var cuttingTools = await context.CuttingTools
```
- [ ] **Step 3: Drop the `SupplierOfferings` include on materials**
Find:
```csharp
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:
```csharp
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 `MapStockItems` call site**
Find:
```csharp
var stockItems = MapStockItems(m, suppliers);
```
Replace with:
```csharp
var stockItems = MapStockItems(m);
```
- [ ] **Step 5: Drop `Suppliers` from the returned `CatalogData`**
Find:
```csharp
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:
```csharp
return new CatalogData
{
ExportedAt = DateTime.UtcNow,
CuttingTools = cuttingTools.Select(t => new CatalogCuttingToolDto
```
- [ ] **Step 6: Update `ImportAsync` to drop the supplier-import step**
Find:
```csharp
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:
```csharp
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 `ImportSuppliersAsync` method**
Find (and delete entirely — it sits between `ImportAsync` and `ImportCuttingToolsAsync`):
```csharp
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 the `supplierMap` parameter**
Find:
```csharp
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:
```csharp
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 the `supplierMap` parameter**
Find:
```csharp
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:
```csharp
private async Task ImportMaterialAsync(
ApplicationDbContext context, CatalogMaterialBaseDto dto, MaterialShape shape,
List<Material> existingMaterials,
ImportResultDto result,
Func<MaterialDimensions> createDimensions,
Action<MaterialDimensions> updateDimensions)
{
```
Find:
```csharp
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:
```csharp
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` — drop `supplierMap` and the offerings loop**
Find:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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 the `suppliers` param and offerings mapping**
Find:
```csharp
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:
```csharp
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**
```bash
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 16 complete), nothing in `CutList.Web` references `Supplier`, `SupplierOffering`, `PurchaseItem`, `StockTransaction.SupplierId`, `StockTransaction.UnitPrice`, or `StockItem.SupplierOfferings`. This task just removes the now-dead entities and generates the schema migration.
- [ ] **Step 1: Delete the three entity files**
```bash
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:
```csharp
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:
```csharp
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 three `DbSet`s**
Find:
```csharp
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:
```csharp
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 `Supplier` config block**
Find:
```csharp
// 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:
```csharp
// StockItem
```
- [ ] **Step 6: Strip the `StockTransaction` config block**
Find:
```csharp
// 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:
```csharp
// 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 `PurchaseItem` config block**
Find:
```csharp
// 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:
```csharp
// 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 16 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**
```bash
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`/`CatalogStockItemDto` shape from Task 6 (`suppliers` key removed, stock items are `{ lengthInches, name, quantityOnHand, notes }` with no `supplierOfferings`).
- [ ] **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:
```bash
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:
```bash
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**
```bash
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` — replaces `add_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 — `ApiClient` no longer calls `api/suppliers` or `api/*/offerings`, which no longer exist per Task 5.
- [ ] **Step 1: Update `ApiClient.cs` — remove the Suppliers region**
Find:
```csharp
#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:
```csharp
#region Materials
```
- [ ] **Step 2: Update `ApiClient.cs` — remove the Offerings region**
Find:
```csharp
#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:
```csharp
}
```
- [ ] **Step 3: Update `ApiClient.cs` — remove `ApiSupplierDto` and `ApiOfferingDto`**
Find:
```csharp
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:
```csharp
public class ApiMaterialDto
```
Find:
```csharp
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:
```csharp
#endregion
```
- [ ] **Step 4: Update `InventoryTools.cs` — remove the Suppliers region**
Find:
```csharp
#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:
```csharp
#region Materials
```
- [ ] **Step 5: Update `InventoryTools.cs` — replace the Supplier Offerings region with a `add_stock` convenience tool**
Find (this whole block, from the `#region Supplier Offerings` line through the `#endregion` that follows `AddStockWithOffering`):
```csharp
#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:
```csharp
#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:
```csharp
/// <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:
```csharp
/// <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:
```csharp
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:
```csharp
public class MaterialDimensionsDto
```
Find:
```csharp
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:
```csharp
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**
```bash
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/suppliers` and `http://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.