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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user