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