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:
@@ -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 > 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+10924
File diff suppressed because it is too large
Load Diff
+71
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user