Fix incoming powder inventory: defer creation to approval, deduplicate, fix category

Three bugs fixed:

1. Wrong timing — inventory items with IsIncoming=true were auto-created during
   quote save (in QuotePricingAssemblyService). Now deferred to quote approval so
   inventory only reflects powders the shop is actually going to process.

2. Duplicate records — same powder on multiple items in one quote created multiple
   inventory records. Now grouped by PowderCatalogItemId: one record per unique
   catalog powder, all matching coats linked to the same record.

3. Wrong category — category resolution used first IsCoating=true by DisplayOrder,
   which could land items in Cerakote or other unintended categories. Now prefers
   CategoryCode==POWDER explicitly, with DisplayOrder fallback.

Changes:
- QuoteItemCoat: add PowderCatalogItemId int? — persists catalog reference at quote
  save time so the approval path knows what to create
- QuotePricingAssemblyService.BuildQuoteItemCoatsAsync: store PowderCatalogItemId
  on coat instead of calling CreateIncomingInventoryItemAsync immediately
- QuotePricingAssemblyService.CreateIncomingInventoryItemAsync: signature changed
  from (coatDto, companyId) to (catalogItemId, companyId); category lookup prefers
  POWDER code; no longer clears PowderCostPerLb on the DTO
- QuotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync: new
  public method called at approval — loads pending coats, groups by catalog ID,
  creates one inventory item per group, links all coats in each group
- IQuotePricingAssemblyService: exposes EnsureIncomingInventoryForApprovedQuoteAsync
- QuotesController.ApproveQuote: calls EnsureIncomingInventory after save
- QuotesController.ChangeQuoteStatus: calls EnsureIncomingInventory on Approved
- QuoteApprovalController: injects IQuotePricingAssemblyService; calls
  EnsureIncomingInventory in ApproveInternal (customer-facing portal path)
- InventoryController.CreateIncomingFromCatalog: same category fix (prefers POWDER)
- Migration: AddPowderCatalogItemIdToCoat (nullable int on QuoteItemCoats)
- Tests: updated AddAsIncoming test to verify deferred behavior; new deduplication test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 10:12:24 -04:00
parent 9dd36238bb
commit 972123c7a2
11 changed files with 11230 additions and 52 deletions
@@ -326,7 +326,8 @@ public class QuoteApprovalControllerTests
Mock.Of<IStripeConnectService>(),
Mock.Of<ILogger<QuoteApprovalController>>(),
new ConfigurationBuilder().Build(),
hubContext.Object);
hubContext.Object,
Mock.Of<IQuotePricingAssemblyService>());
var httpContext = new DefaultHttpContext();
if (remoteIpAddress != null)
@@ -225,8 +225,13 @@ public class QuotePricingAssemblyServiceTests
Assert.Equal(170m, item.TotalPrice);
}
/// <summary>
/// Verifies that CreateQuoteItemsAsync does NOT create inventory during quote save.
/// The catalog item ID is stored on PowderCatalogItemId for later use at approval.
/// PowderCostPerLb is preserved (not cleared) so the Custom Powder Order item still prices correctly.
/// </summary>
[Fact]
public async Task CreateQuoteItemsAsync_AddAsIncoming_CreatesInventoryItemAndLinksCoat()
public async Task CreateQuoteItemsAsync_AddAsIncoming_StoresCatalogIdWithoutCreatingInventory()
{
await using var context = CreateContext();
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
@@ -288,11 +293,72 @@ public class QuotePricingAssemblyServiceTests
ovenRateOverride: null,
createdAtUtc: DateTime.UtcNow));
var inventoryItem = await context.InventoryItems.SingleAsync();
// No inventory created at quote-save time
Assert.Empty(await context.InventoryItems.ToListAsync());
// Coat stores the catalog item ID for deferred creation at approval
var coat = Assert.Single(item.Coats);
Assert.Equal(inventoryItem.Id, coat.InventoryItemId);
Assert.True(inventoryItem.IsIncoming);
Assert.Null(dto.Coats[0].PowderCostPerLb);
Assert.Equal(5, coat.PowderCatalogItemId);
Assert.Null(coat.InventoryItemId);
// PowderCostPerLb preserved (not cleared) so Custom Powder Order item can price correctly
Assert.Equal(22m, dto.Coats[0].PowderCostPerLb);
}
/// <summary>
/// Verifies that EnsureIncomingInventoryForApprovedQuoteAsync creates one inventory item per
/// unique catalog powder, even when multiple quote items/coats reference the same catalog ID.
/// </summary>
[Fact]
public async Task EnsureIncomingInventory_DeduplicatesSameCatalogIdAcrossCoats()
{
await using var context = CreateContext();
// Add catalog item and a "POWDER" category
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
{
Id = 5,
VendorName = "Prismatic Powders",
Sku = "P-1001",
ColorName = "Candy Red",
UnitPrice = 19.5m,
CoverageSqFtPerLb = 85m,
TransferEfficiency = 70m
});
context.InventoryCategoryLookups.Add(new InventoryCategoryLookup
{
Id = 10,
CompanyId = 1,
CategoryCode = "POWDER",
DisplayName = "Powder",
DisplayOrder = 1,
IsActive = true,
IsCoating = true,
CreatedAt = DateTime.UtcNow
});
// Two coats on different QuoteItems, both referencing catalog item 5
var qi1 = new QuoteItem { Id = 1, QuoteId = 9, CompanyId = 1, Description = "Item A", CreatedAt = DateTime.UtcNow };
var qi2 = new QuoteItem { Id = 2, QuoteId = 9, CompanyId = 1, Description = "Item B", CreatedAt = DateTime.UtcNow };
context.QuoteItems.AddRange(qi1, qi2);
await context.SaveChangesAsync();
var coat1 = new QuoteItemCoat { QuoteItemId = 1, CompanyId = 1, PowderCatalogItemId = 5, CoatName = "Base", Sequence = 1, CoverageSqFtPerLb = 30, TransferEfficiency = 65, CreatedAt = DateTime.UtcNow };
var coat2 = new QuoteItemCoat { QuoteItemId = 2, CompanyId = 1, PowderCatalogItemId = 5, CoatName = "Base", Sequence = 1, CoverageSqFtPerLb = 30, TransferEfficiency = 65, CreatedAt = DateTime.UtcNow };
context.Set<QuoteItemCoat>().AddRange(coat1, coat2);
await context.SaveChangesAsync();
var service = CreateService(context, Mock.Of<IPricingCalculationService>());
await service.EnsureIncomingInventoryForApprovedQuoteAsync(quoteId: 9, companyId: 1);
// Exactly ONE inventory item should be created (not two)
var inventoryItems = await context.InventoryItems.ToListAsync();
Assert.Single(inventoryItems);
Assert.True(inventoryItems[0].IsIncoming);
// Both coats linked to the same inventory item
var updatedCoats = await context.Set<QuoteItemCoat>().ToListAsync();
Assert.All(updatedCoats, c => Assert.Equal(inventoryItems[0].Id, c.InventoryItemId));
}
private static QuotePricingAssemblyService CreateService(ApplicationDbContext context, IPricingCalculationService pricingService)