972123c7a2
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>
607 lines
29 KiB
C#
607 lines
29 KiB
C#
using PowderCoating.Application.DTOs.Quote;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace PowderCoating.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
|
|
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
|
|
///
|
|
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
|
|
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
|
|
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
|
|
///
|
|
/// Key responsibilities:
|
|
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
|
|
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
|
|
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
|
|
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
|
|
/// </summary>
|
|
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IPricingCalculationService _pricingService;
|
|
private readonly IInventoryAiLookupService _aiLookupService;
|
|
private readonly ILogger<QuotePricingAssemblyService> _logger;
|
|
|
|
public QuotePricingAssemblyService(
|
|
IUnitOfWork unitOfWork,
|
|
IPricingCalculationService pricingService,
|
|
IInventoryAiLookupService aiLookupService,
|
|
ILogger<QuotePricingAssemblyService> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_pricingService = pricingService;
|
|
_aiLookupService = aiLookupService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
|
|
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
|
|
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
|
|
/// </summary>
|
|
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(quote);
|
|
ArgumentNullException.ThrowIfNull(pricingResult);
|
|
|
|
quote.MaterialCosts = pricingResult.MaterialCosts;
|
|
quote.LaborCosts = pricingResult.LaborCosts;
|
|
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
|
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
|
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
|
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
|
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
|
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
|
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
|
quote.OverheadAmount = pricingResult.OverheadCosts;
|
|
quote.OverheadPercent = pricingResult.OverheadPercent;
|
|
quote.ProfitMargin = pricingResult.ProfitMargin;
|
|
quote.ProfitPercent = pricingResult.ProfitPercent;
|
|
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
|
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
|
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
|
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
|
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
|
quote.DiscountPercent = pricingResult.DiscountPercent;
|
|
quote.DiscountAmount = pricingResult.DiscountAmount;
|
|
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
|
quote.RushFee = pricingResult.RushFee;
|
|
quote.TaxAmount = pricingResult.TaxAmount;
|
|
quote.Total = pricingResult.Total;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
|
|
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
|
|
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
|
|
/// the user selects a catalog powder not yet in their inventory) and prep services.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
|
IEnumerable<CreateQuoteItemDto> itemDtos,
|
|
int quoteId,
|
|
int companyId,
|
|
decimal? ovenRateOverride,
|
|
DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(itemDtos);
|
|
|
|
var dtoList = itemDtos.ToList();
|
|
var items = new List<QuoteItem>();
|
|
foreach (var itemDto in dtoList)
|
|
{
|
|
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
|
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
|
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
|
|
|
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
|
|
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
|
|
items.Add(item);
|
|
}
|
|
|
|
// Option B: auto-create the Custom Powder Order item only on first save.
|
|
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
|
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
|
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
|
if (!hasExistingCustomPowderOrder)
|
|
{
|
|
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
|
if (customPowderItem != null)
|
|
items.Add(customPowderItem);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
|
|
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
|
|
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
|
|
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
|
|
/// path to take, never HOW to compute the price.
|
|
/// </summary>
|
|
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
|
{
|
|
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
|
{
|
|
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
|
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
|
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
|
return;
|
|
}
|
|
|
|
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
|
{
|
|
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
|
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
|
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
|
return;
|
|
}
|
|
|
|
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
|
{
|
|
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
|
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
|
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
|
return;
|
|
}
|
|
|
|
if (itemDto.CatalogItemId.HasValue)
|
|
{
|
|
if (itemDto.Coats != null && itemDto.Coats.Any())
|
|
{
|
|
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
|
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
|
ApplyCalculatedPricing(item, itemPricing);
|
|
return;
|
|
}
|
|
|
|
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
|
if (catalogItem != null)
|
|
{
|
|
var unitPrice = itemDto.PowderCostOverride is > 0
|
|
? itemDto.PowderCostOverride.Value
|
|
: catalogItem.DefaultPrice;
|
|
item.UnitPrice = unitPrice;
|
|
item.TotalPrice = unitPrice * itemDto.Quantity;
|
|
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
|
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
|
ApplyCalculatedPricing(item, pricing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
|
/// 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)
|
|
{
|
|
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
|
return [];
|
|
|
|
var coats = new List<QuoteItemCoat>();
|
|
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
|
{
|
|
var coatDto = itemDto.Coats[coatIndex];
|
|
|
|
// 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(
|
|
coatDto,
|
|
itemDto.SurfaceAreaSqFt,
|
|
itemDto.Quantity,
|
|
coatIndex,
|
|
itemDto.EstimatedMinutes,
|
|
companyId);
|
|
|
|
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
|
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
|
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
|
coats.Add(coat);
|
|
}
|
|
|
|
return coats;
|
|
}
|
|
|
|
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
|
|
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
|
{
|
|
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
|
return [];
|
|
|
|
return itemDto.PrepServices
|
|
.Select(ps => new QuoteItemPrepService
|
|
{
|
|
PrepServiceId = ps.PrepServiceId,
|
|
EstimatedMinutes = ps.EstimatedMinutes,
|
|
BlastSetupId = ps.BlastSetupId,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
|
|
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
|
|
/// and calculation steps distinct and individually testable.
|
|
/// </summary>
|
|
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
return new QuoteItem
|
|
{
|
|
QuoteId = quoteId,
|
|
Description = itemDto.Description,
|
|
Quantity = itemDto.Quantity,
|
|
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
|
CatalogItemId = itemDto.CatalogItemId,
|
|
IsGenericItem = itemDto.IsGenericItem,
|
|
ManualUnitPrice = itemDto.ManualUnitPrice,
|
|
PowderCostOverride = itemDto.PowderCostOverride,
|
|
IsLaborItem = itemDto.IsLaborItem,
|
|
IsSalesItem = itemDto.IsSalesItem,
|
|
Sku = itemDto.Sku,
|
|
RequiresSandblasting = itemDto.RequiresSandblasting,
|
|
RequiresMasking = itemDto.RequiresMasking,
|
|
EstimatedMinutes = itemDto.EstimatedMinutes,
|
|
IncludePrepCost = itemDto.IncludePrepCost,
|
|
Notes = itemDto.Notes,
|
|
Complexity = itemDto.Complexity,
|
|
IsAiItem = itemDto.IsAiItem,
|
|
AiTags = itemDto.AiTags,
|
|
AiPredictionId = itemDto.AiPredictionId,
|
|
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
|
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
|
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc
|
|
};
|
|
}
|
|
|
|
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
|
|
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
|
{
|
|
return new QuoteItemCoat
|
|
{
|
|
CoatName = coatDto.CoatName,
|
|
Sequence = coatDto.Sequence,
|
|
InventoryItemId = coatDto.InventoryItemId,
|
|
PowderCatalogItemId = coatDto.CatalogItemId,
|
|
ColorName = coatDto.ColorName,
|
|
VendorId = coatDto.VendorId,
|
|
ColorCode = coatDto.ColorCode,
|
|
Finish = coatDto.Finish,
|
|
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
|
TransferEfficiency = coatDto.TransferEfficiency,
|
|
PowderCostPerLb = coatDto.PowderCostPerLb,
|
|
PowderToOrder = coatDto.PowderToOrder,
|
|
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
|
Notes = coatDto.Notes,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stamps the pricing result onto the quote item entity.
|
|
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
|
|
/// </summary>
|
|
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
|
{
|
|
item.UnitPrice = pricing.UnitPrice;
|
|
item.TotalPrice = pricing.TotalPrice;
|
|
item.ItemMaterialCost = pricing.MaterialCost;
|
|
item.ItemLaborCost = pricing.LaborCost;
|
|
item.ItemEquipmentCost = pricing.EquipmentCost;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether the user changed the AI's surface area or price estimates before saving,
|
|
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
|
|
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
|
|
/// and whether certain item types consistently need manual correction.
|
|
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
|
|
/// </summary>
|
|
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
|
{
|
|
if (!itemDto.AiPredictionId.HasValue) return;
|
|
|
|
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
|
if (prediction == null) return;
|
|
|
|
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
|
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
|
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
|
prediction.UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// 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(int catalogItemId, int companyId)
|
|
{
|
|
try
|
|
{
|
|
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
|
if (catalogItem == null) return null;
|
|
|
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
|
// 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();
|
|
var matchedVendor = vendors.FirstOrDefault(v =>
|
|
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
|
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
|
|
|
var code = coatingCategory != null
|
|
? (coatingCategory.CategoryCode.Length >= 4
|
|
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
|
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
|
: "POWD";
|
|
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
|
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
|
var maxSeq = allItems
|
|
.Where(i => i.SKU.StartsWith(prefix))
|
|
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
|
.DefaultIfEmpty(0)
|
|
.Max();
|
|
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
|
|
|
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
|
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
|
|
|
var description = catalogItem.Description;
|
|
var finish = catalogItem.Finish;
|
|
var colorFamilies = catalogItem.ColorFamilies;
|
|
var cureTemp = catalogItem.CureTemperatureF;
|
|
var cureTime = catalogItem.CureTimeMinutes;
|
|
var coverage = catalogItem.CoverageSqFtPerLb;
|
|
var transferEff = catalogItem.TransferEfficiency;
|
|
var specificGravity = catalogItem.SpecificGravity;
|
|
var imageUrl = catalogItem.ImageUrl;
|
|
var sdsUrl = catalogItem.SdsUrl;
|
|
var tdsUrl = catalogItem.TdsUrl;
|
|
|
|
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
|
(string.IsNullOrWhiteSpace(description) ||
|
|
string.IsNullOrWhiteSpace(colorFamilies) ||
|
|
cureTemp == null || cureTime == null);
|
|
if (needsAugment)
|
|
{
|
|
try
|
|
{
|
|
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
|
if (augmented.Success)
|
|
{
|
|
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
|
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
|
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
|
cureTemp ??= augmented.CureTemperatureF;
|
|
cureTime ??= augmented.CureTimeMinutes;
|
|
coverage ??= augmented.CoverageSqFtPerLb;
|
|
transferEff ??= augmented.TransferEfficiency;
|
|
specificGravity ??= augmented.SpecificGravity;
|
|
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
|
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
|
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
|
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
|
}
|
|
}
|
|
|
|
var item = new InventoryItem
|
|
{
|
|
SKU = sku,
|
|
Name = name,
|
|
Description = description,
|
|
ColorName = catalogItem.ColorName,
|
|
Manufacturer = catalogItem.VendorName,
|
|
ManufacturerPartNumber = catalogItem.Sku,
|
|
Finish = finish,
|
|
ColorFamilies = colorFamilies,
|
|
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
|
CoverageSqFtPerLb = coverage ?? 30m,
|
|
TransferEfficiency = transferEff ?? 65m,
|
|
CureTemperatureF = cureTemp,
|
|
CureTimeMinutes = cureTime,
|
|
SpecificGravity = specificGravity,
|
|
SpecPageUrl = catalogItem.ProductUrl,
|
|
ImageUrl = imageUrl,
|
|
SdsUrl = sdsUrl,
|
|
TdsUrl = tdsUrl,
|
|
UnitCost = catalogItem.UnitPrice,
|
|
AverageCost = catalogItem.UnitPrice,
|
|
LastPurchasePrice = catalogItem.UnitPrice,
|
|
QuantityOnHand = 0,
|
|
UnitOfMeasure = "lbs",
|
|
PrimaryVendorId = matchedVendor?.Id,
|
|
InventoryCategoryId = coatingCategory?.Id,
|
|
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
|
IsActive = true,
|
|
IsIncoming = true,
|
|
CompanyId = companyId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
await _unitOfWork.InventoryItems.AddAsync(item);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
_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",
|
|
catalogItemId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// 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)
|
|
{
|
|
var colorNames = new List<string>();
|
|
decimal totalCost = 0m;
|
|
|
|
foreach (var itemDto in itemDtos)
|
|
{
|
|
if (itemDto.Coats == null) continue;
|
|
foreach (var coat in itemDto.Coats)
|
|
{
|
|
if (!coat.InventoryItemId.HasValue &&
|
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
|
{
|
|
// 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)
|
|
{
|
|
// 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)
|
|
{
|
|
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
|
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
|
if (!string.IsNullOrWhiteSpace(colorName))
|
|
colorNames.Add(colorName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalCost <= 0) return null;
|
|
|
|
var uniqueColors = colorNames
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var description = uniqueColors.Any()
|
|
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
|
: "Custom Powder Order";
|
|
|
|
return new QuoteItem
|
|
{
|
|
QuoteId = quoteId,
|
|
Description = description,
|
|
Quantity = 1,
|
|
IsGenericItem = true,
|
|
ManualUnitPrice = totalCost,
|
|
UnitPrice = totalCost,
|
|
TotalPrice = totalCost,
|
|
ItemMaterialCost = totalCost,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc,
|
|
Coats = [],
|
|
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();
|
|
}
|
|
}
|