Files
PowderCoatingLogix/src/PowderCoating.Core/Entities/QuoteItemCoat.cs
T
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

58 lines
2.4 KiB
C#

namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents a single coating layer applied to a quote item.
/// Supports multi-coat configurations (primer, base coat, top coat, clear coat, etc.)
/// </summary>
public class QuoteItemCoat : BaseEntity
{
// Parent relationship
public int QuoteItemId { get; set; }
// Coat identification (user-defined)
public string CoatName { get; set; } = string.Empty; // "Primer", "Base Coat", "Top Coat", etc.
public int Sequence { get; set; } // 1, 2, 3... for ordering
// 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.
public string? Finish { get; set; } // Gloss, Matte, Textured, etc.
// Coverage parameters (defaults from inventory or user-specified)
public decimal CoverageSqFtPerLb { get; set; } = 30m;
public decimal TransferEfficiency { get; set; } = 65m;
// Cost override for custom powder
public decimal? PowderCostPerLb { get; set; } // $/lb for custom orders
public decimal? PowderToOrder { get; set; } // Pounds to order (rounded up from needed)
// Calculated costs (stored for audit trail)
public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
// Navigation properties
public virtual QuoteItem QuoteItem { get; set; } = null!;
public virtual InventoryItem? InventoryItem { get; set; }
public virtual Vendor? Vendor { get; set; }
}