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
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc);
/// <summary>
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
/// referenced by coats on the given quote, then links those coats to the new inventory records.
/// Must be called after a quote transitions to Approved status.
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
/// </summary>
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
}
@@ -181,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
/// <summary>
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
/// quote (deduplication). No inventory is created during quote save.
/// </summary>
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
@@ -195,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
// Incoming-inventory creation is intentionally deferred to quote approval.
// PowderCatalogItemId is persisted on the coat entity for later use.
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
@@ -279,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
PowderCatalogItemId = coatDto.CatalogItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
@@ -328,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
}
/// <summary>
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
/// platform catalog that doesn't yet exist in their company's inventory.
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
/// Called at quote-approval time (not during quote save) so inventory records only appear
/// when a job is actually going to be created. The caller groups coats by
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
/// duplicate records when the same powder appears on multiple items in the same quote.
///
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
/// forcing the user to manually add the powder to inventory before quoting, we create an
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
/// this record later (updating quantity + receive date) without losing the link to the original quote.
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
/// so the item always lands in the right bucket regardless of how many IsCoating categories
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
///
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
/// if it fails, the item is still created with whatever data the catalog has.
///
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
/// inventory unit cost rather than the now-stale manual price from the quote form.
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
/// from the manufacturer product page. Best-effort — item is still created from catalog data
/// if the AI call fails.
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
@@ -460,31 +464,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
coatDto.PowderCostPerLb = null;
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
item.Id, item.Name, catalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
catalogItemId);
return null;
}
}
/// <summary>
/// Scans all coat DTOs for powder that must be ordered (custom or incoming) and returns a
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
/// on the first save only — Option B means the user owns the price after creation.
///
/// Two coat types qualify:
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb &gt; 0
/// - Incoming powder: InventoryItemId set but inventoryItem.IsIncoming == true
/// (auto-created by <see cref="CreateIncomingInventoryItemAsync"/>; PowderCostPerLb cleared
/// after creation, so cost comes from inventoryItem.UnitCost instead)
/// Coat types that qualify:
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb &gt; 0 (user-entered)
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
/// pre-filled from catalog unit price (inventory creation deferred to approval)
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
/// </summary>
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
@@ -501,15 +504,16 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder: no inventory link, user entered cost per lb manually
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(coat.ColorName))
colorNames.Add(coat.ColorName);
}
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Incoming powder: catalog-selected; CreateIncomingInventoryItemAsync set InventoryItemId
// and cleared PowderCostPerLb, so cost must come from the inventory item's UnitCost
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
@@ -547,4 +551,56 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
PrepServices = []
};
}
/// <summary>
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
/// new (or existing) inventory record.
///
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
/// only reflects powders the shop is actually going to process.
///
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
/// same InventoryItemId — no duplicate records are created.
///
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
/// on an already-approved quote (e.g. retry after a transient error) is safe.
/// </summary>
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
{
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
false,
qi => qi.Coats);
var pendingCoats = quoteItems
.SelectMany(qi => qi.Coats)
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
.ToList();
if (pendingCoats.Count == 0) return;
// Group by catalog item ID so each unique powder generates exactly one inventory record.
var groups = pendingCoats
.GroupBy(c => c.PowderCatalogItemId!.Value)
.ToList();
foreach (var group in groups)
{
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
if (newInventoryId == null) continue;
// Link every coat in this group to the single newly-created inventory record.
foreach (var coat in group)
{
coat.InventoryItemId = newInventoryId;
coat.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
}
}
await _unitOfWork.SaveChangesAsync();
}
}
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
// Powder selection (same pattern as current QuoteItem)
public int? InventoryItemId { get; set; } // In-stock powder
/// <summary>
/// Platform powder catalog item that this coat was sourced from.
/// Persisted so that at quote-approval time the system can create exactly one
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
/// than creating during quote-save when the job may never be approved.
/// </summary>
public int? PowderCatalogItemId { get; set; }
public string? ColorName { get; set; } // Color name
public int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc.
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogItemIdToCoat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PowderCatalogItemId",
table: "QuoteItemCoats",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PowderCatalogItemId",
table: "QuoteItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
}
}
}
@@ -6868,7 +6868,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040),
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6879,7 +6879,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052),
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6890,7 +6890,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054),
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7582,6 +7582,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<decimal?>("PowderCostPerLb")
.HasColumnType("decimal(18,2)");
@@ -1222,12 +1222,16 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
// Find the default coating category to assign.
// Prefer the canonical "POWDER" category (CategoryCode == "POWDER") so catalog-sourced
// items always land in the right bucket regardless of how many IsCoating categories exist.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
@@ -28,6 +28,7 @@ public class QuoteApprovalController : Controller
private readonly ILogger<QuoteApprovalController> _logger;
private readonly IConfiguration _configuration;
private readonly IHubContext<NotificationHub> _hub;
private readonly IQuotePricingAssemblyService _assemblyService;
public QuoteApprovalController(
IUnitOfWork unitOfWork,
@@ -36,7 +37,8 @@ public class QuoteApprovalController : Controller
IStripeConnectService stripeConnect,
ILogger<QuoteApprovalController> logger,
IConfiguration configuration,
IHubContext<NotificationHub> hub)
IHubContext<NotificationHub> hub,
IQuotePricingAssemblyService assemblyService)
{
_unitOfWork = unitOfWork;
_notifications = notifications;
@@ -45,6 +47,7 @@ public class QuoteApprovalController : Controller
_logger = logger;
_configuration = configuration;
_hub = hub;
_assemblyService = assemblyService;
}
/// <summary>
@@ -177,6 +180,16 @@ public class QuoteApprovalController : Controller
await _unitOfWork.CompleteAsync();
// Create incoming inventory records for catalog-sourced coats deferred from quote-save time.
try
{
await _assemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(quote.Id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", quote.Id);
}
var approveEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
@@ -2317,6 +2317,17 @@ public class QuotesController : Controller
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
// Create incoming inventory records for any catalog-sourced coats that were deferred
// from quote-save time. One record per unique powder catalog item, de-duplicated.
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", id);
}
// Notify customer that quote is approved (only if user opted in)
if (sendEmail)
{
@@ -2802,6 +2813,20 @@ public class QuotesController : Controller
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.SaveChangesAsync();
// When transitioning to Approved: create incoming inventory records for catalog-sourced
// coats that were deferred from quote-save time (one record per unique powder, deduplicated).
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
{
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(request.QuoteId, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", request.QuoteId);
}
}
// Auto-create job when quote is approved — guard against double-conversion
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
@@ -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)