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; /// /// 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 — 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: /// - — 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. /// - — builds QuoteItem + coats + prep services for each DTO, /// records AI prediction overrides, and auto-creates incoming inventory records when needed. /// public class QuotePricingAssemblyService : IQuotePricingAssemblyService { private readonly IUnitOfWork _unitOfWork; private readonly IPricingCalculationService _pricingService; private readonly IInventoryAiLookupService _aiLookupService; private readonly ILogger _logger; public QuotePricingAssemblyService( IUnitOfWork unitOfWork, IPricingCalculationService pricingService, IInventoryAiLookupService aiLookupService, ILogger logger) { _unitOfWork = unitOfWork; _pricingService = pricingService; _aiLookupService = aiLookupService; _logger = logger; } /// /// Writes the calculated pricing breakdown onto the 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. /// 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; } /// /// Builds and prices all 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. /// public async Task> CreateQuoteItemsAsync( IEnumerable itemDtos, int quoteId, int companyId, decimal? ovenRateOverride, DateTime createdAtUtc) { ArgumentNullException.ThrowIfNull(itemDtos); var items = new List(); foreach (var itemDto in itemDtos) { 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); } return items; } /// /// Routes a single item to the correct pricing path and stamps the result onto the entity. /// Priority order matches the routing table in : /// 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. /// 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); } /// /// Builds entities for a single item, including per-coat pricing. /// If a coat has AddAsIncoming = true and references a catalog item but not an inventory /// item, an incoming is auto-created so the shop can track the powder /// order and receive it later — see for details. /// private async Task> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) { if (itemDto.Coats == null || itemDto.Coats.Count == 0) return []; var coats = new List(); for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++) { var coatDto = itemDto.Coats[coatIndex]; if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue) coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId); 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; } /// Constructs entities from the item DTO's prep service list. private static List 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(); } /// /// Constructs a bare entity from the DTO — no pricing or coats yet. /// Pricing is applied separately by to keep the construction /// and calculation steps distinct and individually testable. /// 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 }; } /// Constructs a entity from the coat DTO. Per-coat pricing is applied by the caller. private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc) { return new QuoteItemCoat { CoatName = coatDto.CoatName, Sequence = coatDto.Sequence, InventoryItemId = coatDto.InventoryItemId, 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 }; } /// /// Stamps the pricing result onto the quote item entity. /// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync. /// 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; } /// /// Checks whether the user changed the AI's surface area or price estimates before saving, /// and sets UserOverrodeEstimate = true 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. /// 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; } /// /// Auto-creates an "incoming" when a user selects a powder from the /// platform catalog that doesn't yet exist in their company's inventory. /// /// 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. /// /// 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, coatDto.PowderCostPerLb 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. /// private async Task CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId) { try { var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value); 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(); 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(); coatDto.PowderCostPerLb = null; _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat", item.Id, item.Name, coatDto.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); return null; } } }