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,
|
int companyId,
|
||||||
decimal? ovenRateOverride,
|
decimal? ovenRateOverride,
|
||||||
DateTime createdAtUtc);
|
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>
|
/// <summary>
|
||||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
/// 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
|
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
|
||||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
|
||||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
/// 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>
|
/// </summary>
|
||||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
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];
|
var coatDto = itemDto.Coats[coatIndex];
|
||||||
|
|
||||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
// Incoming-inventory creation is intentionally deferred to quote approval.
|
||||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
// PowderCatalogItemId is persisted on the coat entity for later use.
|
||||||
|
|
||||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||||
@@ -279,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
CoatName = coatDto.CoatName,
|
CoatName = coatDto.CoatName,
|
||||||
Sequence = coatDto.Sequence,
|
Sequence = coatDto.Sequence,
|
||||||
InventoryItemId = coatDto.InventoryItemId,
|
InventoryItemId = coatDto.InventoryItemId,
|
||||||
|
PowderCatalogItemId = coatDto.CatalogItemId,
|
||||||
ColorName = coatDto.ColorName,
|
ColorName = coatDto.ColorName,
|
||||||
VendorId = coatDto.VendorId,
|
VendorId = coatDto.VendorId,
|
||||||
ColorCode = coatDto.ColorCode,
|
ColorCode = coatDto.ColorCode,
|
||||||
@@ -328,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
|
||||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
/// 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
|
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
|
||||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
/// so the item always lands in the right bucket regardless of how many IsCoating categories
|
||||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
|
||||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
|
||||||
///
|
///
|
||||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
|
||||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
/// from the manufacturer product page. Best-effort — item is still created from catalog data
|
||||||
/// if it fails, the item is still created with whatever data the catalog has.
|
/// if the AI call fails.
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||||
if (catalogItem == null) return null;
|
if (catalogItem == null) return null;
|
||||||
|
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||||
var coatingCategory = categories
|
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
|
||||||
.OrderBy(c => c.DisplayOrder)
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
.FirstOrDefault();
|
&& 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 vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||||
@@ -460,31 +464,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
coatDto.PowderCostPerLb = null;
|
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
|
||||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
item.Id, item.Name, catalogItemId);
|
||||||
item.Id, item.Name, coatDto.CatalogItemId);
|
|
||||||
|
|
||||||
return item.Id;
|
return item.Id;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||||
coatDto.CatalogItemId);
|
catalogItemId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// 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"/>
|
/// 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.
|
/// on the first save only — Option B means the user owns the price after creation.
|
||||||
///
|
///
|
||||||
/// Two coat types qualify:
|
/// Coat types that qualify:
|
||||||
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0
|
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
|
||||||
/// - Incoming powder: InventoryItemId set but inventoryItem.IsIncoming == true
|
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
|
||||||
/// (auto-created by <see cref="CreateIncomingInventoryItemAsync"/>; PowderCostPerLb cleared
|
/// pre-filled from catalog unit price (inventory creation deferred to approval)
|
||||||
/// after creation, so cost comes from inventoryItem.UnitCost instead)
|
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||||
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
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.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.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;
|
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||||
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||||
colorNames.Add(coat.ColorName);
|
colorNames.Add(coat.ColorName);
|
||||||
}
|
}
|
||||||
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
{
|
{
|
||||||
// Incoming powder: catalog-selected; CreateIncomingInventoryItemAsync set InventoryItemId
|
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
|
||||||
// and cleared PowderCostPerLb, so cost must come from the inventory item's UnitCost
|
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
|
||||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||||
if (invItem?.IsIncoming == true)
|
if (invItem?.IsIncoming == true)
|
||||||
{
|
{
|
||||||
@@ -547,4 +551,56 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
PrepServices = []
|
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)
|
// Powder selection (same pattern as current QuoteItem)
|
||||||
public int? InventoryItemId { get; set; } // In-stock powder
|
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 string? ColorName { get; set; } // Color name
|
||||||
public int? VendorId { get; set; } // Vendor for custom powder
|
public int? VendorId { get; set; } // Vendor for custom powder
|
||||||
public string? ColorCode { get; set; } // RAL code, etc.
|
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,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6879,7 +6879,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6890,7 +6890,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7582,6 +7582,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("PowderCatalogItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("PowderCostPerLb")
|
b.Property<decimal?>("PowderCostPerLb")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
|||||||
@@ -1222,12 +1222,16 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
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 categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var coatingCategory = categories
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderBy(c => c.DisplayOrder)
|
?? categories
|
||||||
.FirstOrDefault();
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
|
.OrderBy(c => c.DisplayOrder)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (coatingCategory == null)
|
if (coatingCategory == null)
|
||||||
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
|
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 ILogger<QuoteApprovalController> _logger;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IHubContext<NotificationHub> _hub;
|
private readonly IHubContext<NotificationHub> _hub;
|
||||||
|
private readonly IQuotePricingAssemblyService _assemblyService;
|
||||||
|
|
||||||
public QuoteApprovalController(
|
public QuoteApprovalController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -36,7 +37,8 @@ public class QuoteApprovalController : Controller
|
|||||||
IStripeConnectService stripeConnect,
|
IStripeConnectService stripeConnect,
|
||||||
ILogger<QuoteApprovalController> logger,
|
ILogger<QuoteApprovalController> logger,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHubContext<NotificationHub> hub)
|
IHubContext<NotificationHub> hub,
|
||||||
|
IQuotePricingAssemblyService assemblyService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
@@ -45,6 +47,7 @@ public class QuoteApprovalController : Controller
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_hub = hub;
|
_hub = hub;
|
||||||
|
_assemblyService = assemblyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -177,6 +180,16 @@ public class QuoteApprovalController : Controller
|
|||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
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
|
var approveEntry = new QuoteChangeHistory
|
||||||
{
|
{
|
||||||
QuoteId = quote.Id,
|
QuoteId = quote.Id,
|
||||||
|
|||||||
@@ -2317,6 +2317,17 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
|
_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)
|
// Notify customer that quote is approved (only if user opted in)
|
||||||
if (sendEmail)
|
if (sendEmail)
|
||||||
{
|
{
|
||||||
@@ -2802,6 +2813,20 @@ public class QuotesController : Controller
|
|||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
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
|
// Auto-create job when quote is approved — guard against double-conversion
|
||||||
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
|
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
|
||||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
|
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ public class QuoteApprovalControllerTests
|
|||||||
Mock.Of<IStripeConnectService>(),
|
Mock.Of<IStripeConnectService>(),
|
||||||
Mock.Of<ILogger<QuoteApprovalController>>(),
|
Mock.Of<ILogger<QuoteApprovalController>>(),
|
||||||
new ConfigurationBuilder().Build(),
|
new ConfigurationBuilder().Build(),
|
||||||
hubContext.Object);
|
hubContext.Object,
|
||||||
|
Mock.Of<IQuotePricingAssemblyService>());
|
||||||
|
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
if (remoteIpAddress != null)
|
if (remoteIpAddress != null)
|
||||||
|
|||||||
@@ -225,8 +225,13 @@ public class QuotePricingAssemblyServiceTests
|
|||||||
Assert.Equal(170m, item.TotalPrice);
|
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]
|
[Fact]
|
||||||
public async Task CreateQuoteItemsAsync_AddAsIncoming_CreatesInventoryItemAndLinksCoat()
|
public async Task CreateQuoteItemsAsync_AddAsIncoming_StoresCatalogIdWithoutCreatingInventory()
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
|
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
|
||||||
@@ -288,11 +293,72 @@ public class QuotePricingAssemblyServiceTests
|
|||||||
ovenRateOverride: null,
|
ovenRateOverride: null,
|
||||||
createdAtUtc: DateTime.UtcNow));
|
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);
|
var coat = Assert.Single(item.Coats);
|
||||||
Assert.Equal(inventoryItem.Id, coat.InventoryItemId);
|
Assert.Equal(5, coat.PowderCatalogItemId);
|
||||||
Assert.True(inventoryItem.IsIncoming);
|
Assert.Null(coat.InventoryItemId);
|
||||||
Assert.Null(dto.Coats[0].PowderCostPerLb);
|
|
||||||
|
// 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)
|
private static QuotePricingAssemblyService CreateService(ApplicationDbContext context, IPricingCalculationService pricingService)
|
||||||
|
|||||||
Reference in New Issue
Block a user