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