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; 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; } 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; } 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; } 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.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); } 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; } 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(); } 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, CompanyId = companyId, CreatedAt = createdAtUtc }; } 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, Notes = coatDto.Notes, CompanyId = companyId, CreatedAt = createdAtUtc }; } 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; } 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; } 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; } } }