Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/QuotePricingAssemblyServiceTests.cs
spouliot 972123c7a2 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>
2026-05-27 10:12:24 -04:00

398 lines
14 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
namespace PowderCoating.UnitTests;
public class QuotePricingAssemblyServiceTests
{
[Fact]
public void ApplyPricingSnapshot_CopiesAllTotalsToQuote()
{
var service = CreateService(CreateContext(), Mock.Of<IPricingCalculationService>());
var quote = new Quote();
var pricing = new QuotePricingResult
{
MaterialCosts = 10m,
LaborCosts = 20m,
EquipmentCosts = 30m,
ItemsSubtotal = 40m,
OvenBatchCost = 50m,
ShopSuppliesAmount = 60m,
ShopSuppliesPercent = 7m,
OverheadCosts = 80m,
OverheadPercent = 9m,
ProfitMargin = 100m,
ProfitPercent = 11m,
SubtotalBeforeDiscount = 120m,
DiscountPercent = 13m,
DiscountAmount = 14m,
RushFee = 15m,
TaxAmount = 16m,
Total = 17m
};
service.ApplyPricingSnapshot(quote, pricing);
Assert.Equal(10m, quote.MaterialCosts);
Assert.Equal(20m, quote.LaborCosts);
Assert.Equal(30m, quote.EquipmentCosts);
Assert.Equal(40m, quote.ItemsSubtotal);
Assert.Equal(50m, quote.OvenBatchCost);
Assert.Equal(60m, quote.ShopSuppliesAmount);
Assert.Equal(7m, quote.ShopSuppliesPercent);
Assert.Equal(80m, quote.OverheadAmount);
Assert.Equal(9m, quote.OverheadPercent);
Assert.Equal(100m, quote.ProfitMargin);
Assert.Equal(11m, quote.ProfitPercent);
Assert.Equal(120m, quote.SubTotal);
Assert.Equal(13m, quote.DiscountPercent);
Assert.Equal(14m, quote.DiscountAmount);
Assert.Equal(15m, quote.RushFee);
Assert.Equal(16m, quote.TaxAmount);
Assert.Equal(17m, quote.Total);
}
[Fact]
public async Task CreateQuoteItemsAsync_PreservesManualAndCalculatedPricingPaths()
{
await using var context = CreateContext();
context.AiItemPredictions.Add(new AiItemPrediction
{
Id = 91,
CompanyId = 1,
PredictedSurfaceAreaSqFt = 4m,
PredictedUnitPrice = 100m,
PredictedMinutes = 15,
PredictedComplexity = "Moderate",
Confidence = "High"
});
await context.SaveChangesAsync();
var pricingService = new Mock<IPricingCalculationService>();
pricingService
.Setup(x => x.CalculateQuoteItemPriceAsync(
It.Is<CreateQuoteItemDto>(i => i.Description == "Custom frame"),
1,
null))
.ReturnsAsync(new QuoteItemPricingResult
{
UnitPrice = 77m,
TotalPrice = 154m,
MaterialCost = 22m,
LaborCost = 33m,
EquipmentCost = 11m
});
pricingService
.Setup(x => x.CalculateCoatPriceAsync(
It.IsAny<CreateQuoteItemCoatDto>(),
12m,
2m,
0,
25,
1))
.ReturnsAsync(new QuoteItemCoatPricingResult
{
CoatMaterialCost = 5m,
CoatLaborCost = 6m,
CoatTotalCost = 11m
});
var service = CreateService(context, pricingService.Object);
var items = await service.CreateQuoteItemsAsync(
[
new CreateQuoteItemDto
{
Description = "AI wheel",
Quantity = 2m,
SurfaceAreaSqFt = 5m,
EstimatedMinutes = 20,
IsAiItem = true,
ManualUnitPrice = 123m,
AiPredictionId = 91
},
new CreateQuoteItemDto
{
Description = "Shop tumbler",
Quantity = 3m,
IsSalesItem = true,
Sku = "TMB-20",
ManualUnitPrice = 18m,
IncludePrepCost = false
},
new CreateQuoteItemDto
{
Description = "Custom frame",
Quantity = 2m,
SurfaceAreaSqFt = 12m,
EstimatedMinutes = 25,
RequiresSandblasting = true,
Notes = "Calculated path",
Coats =
[
new CreateQuoteItemCoatDto
{
CoatName = "Top Coat",
Sequence = 1,
ColorName = "Black",
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m
}
],
PrepServices =
[
new CreateQuoteItemPrepServiceDto
{
PrepServiceId = 7,
EstimatedMinutes = 15,
BlastSetupId = 44
}
]
}
],
quoteId: 55,
companyId: 1,
ovenRateOverride: null,
createdAtUtc: new DateTime(2026, 5, 9, 15, 0, 0, DateTimeKind.Utc));
Assert.Equal(3, items.Count);
var aiItem = items.Single(i => i.Description == "AI wheel");
Assert.Equal(123m, aiItem.UnitPrice);
Assert.Equal(246m, aiItem.TotalPrice);
var salesItem = items.Single(i => i.Description == "Shop tumbler");
Assert.True(salesItem.IsSalesItem);
Assert.Equal("TMB-20", salesItem.Sku);
Assert.False(salesItem.IncludePrepCost);
Assert.Equal(18m, salesItem.UnitPrice);
Assert.Equal(54m, salesItem.TotalPrice);
var customItem = items.Single(i => i.Description == "Custom frame");
Assert.Equal(77m, customItem.UnitPrice);
Assert.Equal(154m, customItem.TotalPrice);
Assert.Equal(22m, customItem.ItemMaterialCost);
Assert.Equal(33m, customItem.ItemLaborCost);
Assert.Equal(11m, customItem.ItemEquipmentCost);
var customPrep = Assert.Single(customItem.PrepServices);
Assert.Equal(44, customPrep.BlastSetupId);
var customCoat = Assert.Single(customItem.Coats);
Assert.Equal(11m, customCoat.CoatTotalCost);
var prediction = await context.AiItemPredictions.SingleAsync();
Assert.True(prediction.UserOverrodeEstimate);
}
[Fact]
public async Task CreateQuoteItemsAsync_CatalogItemWithoutCoats_UsesCatalogDefaultPrice()
{
await using var context = CreateContext();
context.CatalogItems.Add(new CatalogItem
{
Id = 22,
CompanyId = 1,
Name = "Gate Hinge",
DefaultPrice = 42.5m
});
await context.SaveChangesAsync();
var service = CreateService(context, Mock.Of<IPricingCalculationService>());
var item = Assert.Single(await service.CreateQuoteItemsAsync(
[
new CreateQuoteItemDto
{
Description = "Catalog hinge",
Quantity = 4m,
CatalogItemId = 22
}
],
quoteId: 1,
companyId: 1,
ovenRateOverride: null,
createdAtUtc: DateTime.UtcNow));
Assert.Equal(42.5m, item.UnitPrice);
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_StoresCatalogIdWithoutCreatingInventory()
{
await using var context = CreateContext();
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
{
Id = 5,
VendorName = "Prismatic Powders",
Sku = "P-1001",
ColorName = "Candy Red",
UnitPrice = 19.5m,
CoverageSqFtPerLb = 85m,
TransferEfficiency = 70m
});
await context.SaveChangesAsync();
var pricingService = new Mock<IPricingCalculationService>();
pricingService
.Setup(x => x.CalculateQuoteItemPriceAsync(It.IsAny<CreateQuoteItemDto>(), 1, null))
.ReturnsAsync(new QuoteItemPricingResult
{
UnitPrice = 50m,
TotalPrice = 50m,
MaterialCost = 10m,
LaborCost = 20m,
EquipmentCost = 5m
});
pricingService
.Setup(x => x.CalculateCoatPriceAsync(It.IsAny<CreateQuoteItemCoatDto>(), 6m, 1m, 0, 10, 1))
.ReturnsAsync(new QuoteItemCoatPricingResult
{
CoatMaterialCost = 3m,
CoatLaborCost = 4m,
CoatTotalCost = 7m
});
var service = CreateService(context, pricingService.Object);
var dto = new CreateQuoteItemDto
{
Description = "Incoming powder item",
Quantity = 1m,
SurfaceAreaSqFt = 6m,
EstimatedMinutes = 10,
Coats =
[
new CreateQuoteItemCoatDto
{
CoatName = "Base",
Sequence = 1,
CatalogItemId = 5,
AddAsIncoming = true,
PowderCostPerLb = 22m
}
]
};
var item = Assert.Single(await service.CreateQuoteItemsAsync(
[dto],
quoteId: 9,
companyId: 1,
ovenRateOverride: null,
createdAtUtc: DateTime.UtcNow));
// 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(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)
{
return new QuotePricingAssemblyService(
new UnitOfWork(context),
pricingService,
Mock.Of<IInventoryAiLookupService>(),
Mock.Of<ILogger<QuotePricingAssemblyService>>());
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Role, "SuperAdmin")],
"Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}