using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; namespace PowderCoating.Application.Services; /// /// Core pricing engine for quotes and jobs. All dollar amounts produced here are stored as /// snapshots on the Quote/Job rows at save time — they are NOT recalculated on every page load. /// This means a quote's displayed total is always exactly what was presented to the customer, /// even if operating costs change after the quote was created. /// /// Pricing flow (high-level): /// 1. Per-coat: material cost + labor cost → /// 2. Per-item: base + additional coats + complexity → /// 3. Quote total: items + oven batch + shop supplies + discounts + rush + tax → /// /// Key design decisions documented inline: /// - Custom powder charges the full ordered quantity; in-stock charges calculated usage only. /// - Markup is baked into item prices, NOT added as a separate line at the quote level. /// - Overhead was removed as a separate charge — it is now absorbed into the markup %. /// - AI items bypass the pricing engine entirely (ManualUnitPrice used as-is). /// - Oven cost is a quote-level batch charge, not per-item; scaled for AI item fraction. /// public class PricingCalculationService : IPricingCalculationService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IMeasurementConversionService _measurementService; private readonly ITenantContext _tenantContext; // Constants for additional cost calculations private const decimal ConsumablesSurchargePercent = 0.05m; // 5% of material costs // Sandblasting and masking multipliers removed - labor is the same for all tasks // private const decimal SandblastingLaborMultiplier = 1.5m; // 1.5x base labor // private const decimal MaskingLaborMultiplier = 0.5m; // 0.5x base labor // private const decimal SandblastingTimePercent = 0.3m; // 30% of estimated time public PricingCalculationService( IUnitOfWork unitOfWork, ILogger logger, IMeasurementConversionService measurementService, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _logger = logger; _measurementService = measurementService; _tenantContext = tenantContext; } /// /// Loads the operating costs row for a company. Returns null (and logs a warning) if none /// is configured — callers fall back to zero pricing rather than throwing, so a misconfigured /// company shows $0 quotes rather than crashing the wizard. /// public async Task GetOperatingCostsAsync(int companyId) { try { var costs = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId); return costs.FirstOrDefault(); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving operating costs for company {CompanyId}", companyId); return null; } } /// /// Calculates the material and labor cost for a single coat on a single quote item. /// /// Material cost rules: /// - Custom powder (no InventoryItemId, manual PowderCostPerLb): charges for the full /// PowderToOrder quantity, not just calculated usage. The shop must purchase the whole /// bag/order for this job, so the customer pays for all of it. /// - In-stock powder (InventoryItemId set): charges calculated usage only /// (surface area × lbs/sqft ÷ transfer efficiency × unit cost). /// - No surface area, no PowderToOrder → $0 material cost. /// /// Labor cost rules: /// - First coat (coatIndex 0): 100% of EstimatedMinutes × StandardLaborRate. /// - Additional coats: AdditionalCoatLaborPercent % of base minutes. /// - NoExtraLayerCharge flag → 0% multiplier for that coat (used for clear coats, etc.). /// /// Surface area is converted from m² to sqft when the tenant uses metric, so all internal /// math stays in imperial regardless of the UI unit setting. /// public async Task CalculateCoatPriceAsync( CreateQuoteItemCoatDto coat, decimal itemSurfaceAreaSqFt, decimal quantity, int coatIndex, int estimatedMinutesBase, int companyId) { var costs = await GetOperatingCostsAsync(companyId); if (costs == null) { _logger.LogWarning("No operating costs configured for company {CompanyId}, using default values", companyId); return new QuoteItemCoatPricingResult { CoatMaterialCost = 0, CoatLaborCost = 0, CoatTotalCost = 0 }; } // Convert from metric if needed var useMetric = await _tenantContext.UseMetricSystemAsync(); var perItemSurfaceAreaSqFt = itemSurfaceAreaSqFt; if (useMetric) { perItemSurfaceAreaSqFt = _measurementService.SquareMetersToFeet(itemSurfaceAreaSqFt); _logger.LogInformation("Converted surface area from {SqM} sq m to {SqFt} sq ft", itemSurfaceAreaSqFt, perItemSurfaceAreaSqFt); } // 1. Calculate powder cost per sq ft for this coat (also track raw costPerLb for order-quantity billing) decimal powderCostPerSqFt = costs.PowderCoatingCostPerSqFt; // Default decimal costPerLb = 0m; // A coat is "custom" (must be purchased) when it has no inventory item but has a manual price. // In-stock coats reference an inventory item that already has stock on hand. // Incoming coats reference an inventory item with IsIncoming=true (ordered, not yet received). bool isCustomPowder = !coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0; bool isIncomingPowder = false; if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0) { // Custom powder with manual cost costPerLb = coat.PowderCostPerLb.Value; var poundsPerSqFt = 1m / coat.CoverageSqFtPerLb; var actualPoundsPerSqFt = poundsPerSqFt / (coat.TransferEfficiency / 100m); powderCostPerSqFt = actualPoundsPerSqFt * costPerLb; _logger.LogInformation("Coat {CoatName}: Using custom powder cost: {Cost}/sqft", coat.CoatName, powderCostPerSqFt); } else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0) { // In-stock or incoming powder - use inventory cost try { var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); if (inventoryItem != null && inventoryItem.UnitCost > 0) { costPerLb = inventoryItem.UnitCost; isIncomingPowder = inventoryItem.IsIncoming; var coverage = coat.CoverageSqFtPerLb; var transferEfficiency = coat.TransferEfficiency; var poundsPerSqFt = 1m / coverage; var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m); powderCostPerSqFt = actualPoundsPerSqFt * costPerLb; _logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft", coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt); } } catch (Exception ex) { _logger.LogWarning(ex, "Could not retrieve inventory item {InventoryItemId} for coat {CoatName}, using default powder cost", coat.InventoryItemId.Value, coat.CoatName); } } // 2. Calculate material cost for this coat var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity; decimal coatMaterialCost; // Custom or incoming powder must be purchased for this job — charge for the full ordered // quantity so the shop recovers the actual outlay, not just the calculated usage. if (batchSurfaceAreaSqFt > 0 && (isCustomPowder || isIncomingPowder) && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { coatMaterialCost = coat.PowderToOrder.Value * costPerLb; _logger.LogInformation("Coat {CoatName}: {PowderKind} powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})", coat.CoatName, isIncomingPowder ? "Incoming" : "Custom", coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt); } else if (batchSurfaceAreaSqFt > 0) { // In-stock powder: charge for calculated usage only coatMaterialCost = batchSurfaceAreaSqFt * powderCostPerSqFt; } else if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { // No surface area (generic item): use PowderToOrder × cost per lb directly coatMaterialCost = coat.PowderToOrder.Value * costPerLb; _logger.LogInformation("Coat {CoatName}: Using PowderToOrder={Lbs}lb × ${CostPerLb}/lb = ${Total}", coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost); } else { coatMaterialCost = 0; } // 3. Calculate labor cost for this coat // First coat = 100% of base minutes, each additional coat = configurable percent from operating costs // NoExtraLayerCharge flag skips the additional layer charge (0x) for that specific coat var additionalCoatPercent = costs.AdditionalCoatLaborPercent / 100m; var laborMultiplier = coatIndex == 0 ? 1.0m : (coat.NoExtraLayerCharge ? 0m : additionalCoatPercent); var coatMinutes = estimatedMinutesBase * laborMultiplier * quantity; var coatLaborHours = coatMinutes / 60m; var coatLaborCost = coatLaborHours * costs.StandardLaborRate; _logger.LogInformation("Coat {CoatName} (index {Index}): Labor={LaborMult}x, Minutes={Minutes}, LaborCost=${LaborCost}", coat.CoatName, coatIndex, laborMultiplier, coatMinutes, coatLaborCost); return new QuoteItemCoatPricingResult { CoatMaterialCost = coatMaterialCost, CoatLaborCost = coatLaborCost, CoatTotalCost = coatMaterialCost + coatLaborCost }; } /// /// Returns true when a coat requires ordering custom powder that is not in inventory. /// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity /// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync. /// private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) => !coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0; /// /// Calculates the total price for a single quote line item, routing to the correct pricing /// path based on item type: /// /// • AI item (IsAiItem = true) — ManualUnitPrice used directly; engine skipped entirely. /// • Sales item (IsSalesItem) — ManualUnitPrice × Qty; no coating math. /// • Generic item (IsGenericItem) — ManualUnitPrice as base; custom powder coat material added on top. /// • Labor item (IsLaborItem) — Qty treated as hours × StandardLaborRate; no markup. /// • Catalog, no coats — DefaultPrice × Qty (or PowderCostOverride if set). /// • Catalog with coats — DefaultPrice base + custom powder coat costs on top; /// in-stock powder assumed baked into the catalog price. /// • Calculated (surface area) — Material + labor + consumables surcharge + coating booth /// + markup + additional coat % + complexity surcharge. /// /// Markup (GeneralMarkupPercentage) is applied to materials only for calculated items — /// labor and equipment rates are already the billable customer rates, not cost rates. /// Prep service labor is added to the base for non-AI items when IncludePrepCost is true. /// public async Task CalculateQuoteItemPriceAsync(CreateQuoteItemDto item, int companyId, decimal? ovenCostOverride = null) { var costs = await GetOperatingCostsAsync(companyId); if (costs == null) { _logger.LogWarning("No operating costs configured for company {CompanyId}, using default values", companyId); return new QuoteItemPricingResult { MaterialCost = 0, LaborCost = 0, EquipmentCost = 0, ItemSubtotal = 0, UnitPrice = 0, TotalPrice = 0 }; } // AI items use ManualUnitPrice as the single-coat base price. // Apply the same additional-coat charge as the calculated-item path so that // adding a 2nd or 3rd coat in step 3 increases the price by AdditionalCoatLaborPercent% // per coat — matching what catalog/calculated items charge. if (item.IsAiItem && item.ManualUnitPrice.HasValue) { var aiUnitPrice = item.ManualUnitPrice.Value; int additionalAiCoats = 0; if (item.Coats != null) { for (int i = 1; i < item.Coats.Count; i++) { if (!item.Coats[i].NoExtraLayerCharge) additionalAiCoats++; } } if (additionalAiCoats > 0) aiUnitPrice = Math.Round( aiUnitPrice * (1m + additionalAiCoats * (costs.AdditionalCoatLaborPercent / 100m)), 2); var aiTotal = aiUnitPrice * item.Quantity; return new QuoteItemPricingResult { MaterialCost = 0, LaborCost = 0, EquipmentCost = 0, ItemSubtotal = aiTotal, UnitPrice = aiUnitPrice, TotalPrice = aiTotal }; } // Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side // and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total, // exactly like every other item type that uses ManualUnitPrice. // SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored // in SurfaceAreaSqFt, so the item falls through to the standard calculated path below. if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue) { var formulaUnitPrice = item.ManualUnitPrice.Value; var formulaTotal = formulaUnitPrice * item.Quantity; return new QuoteItemPricingResult { MaterialCost = 0, LaborCost = 0, EquipmentCost = 0, ItemSubtotal = formulaTotal, UnitPrice = formulaUnitPrice, TotalPrice = formulaTotal }; } // Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation. if (item.IsSalesItem && item.ManualUnitPrice.HasValue) { var total = item.ManualUnitPrice.Value * item.Quantity; return new QuoteItemPricingResult { MaterialCost = 0, LaborCost = 0, EquipmentCost = 0, ItemSubtotal = total, UnitPrice = item.ManualUnitPrice.Value, TotalPrice = total }; } // Generic items use a manually-entered flat price as the base; // coat material costs (powder) are calculated and added on top. if (item.IsGenericItem && item.ManualUnitPrice.HasValue) { decimal coatMaterialCost = 0; if (item.Coats != null && item.Coats.Any()) { for (int i = 0; i < item.Coats.Count; i++) { // Custom powder material moves to the "Custom Powder Order" line item if (IsCustomPowderCoat(item.Coats[i])) continue; var coatResult = await CalculateCoatPriceAsync( item.Coats[i], 0m, item.Quantity, i, 0, companyId); coatMaterialCost += coatResult.CoatMaterialCost; } } var baseTotal = item.ManualUnitPrice.Value * item.Quantity; var grandTotal = baseTotal + coatMaterialCost; return new QuoteItemPricingResult { MaterialCost = coatMaterialCost, LaborCost = 0, EquipmentCost = 0, ItemSubtotal = grandTotal, UnitPrice = grandTotal / item.Quantity, TotalPrice = grandTotal }; } // Labor items: Quantity = hours, priced at StandardLaborRate. // No markup applied — the labor rate is already the billable customer rate. if (item.IsLaborItem) { var laborHours = (decimal)item.Quantity; var laborTotal = laborHours * costs.StandardLaborRate; _logger.LogInformation("Labor item '{Description}': {Hours}h × ${Rate}/h = ${Total}", item.Description, laborHours, costs.StandardLaborRate, laborTotal); return new QuoteItemPricingResult { MaterialCost = 0, LaborCost = laborTotal, EquipmentCost = 0, ItemSubtotal = laborTotal, UnitPrice = costs.StandardLaborRate, TotalPrice = laborTotal }; } // If catalog item with no coats, use override price or catalog default price if (item.CatalogItemId.HasValue && (item.Coats == null || !item.Coats.Any())) { decimal noCoatUnitPrice = item.PowderCostOverride ?? 0m; if (noCoatUnitPrice == 0m) { var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value); noCoatUnitPrice = catalogItem?.DefaultPrice ?? 0m; } var catalogTotal = noCoatUnitPrice * item.Quantity; return new QuoteItemPricingResult { MaterialCost = 0, LaborCost = 0, EquipmentCost = 0, ItemSubtotal = catalogTotal, UnitPrice = noCoatUnitPrice, TotalPrice = catalogTotal }; } // ── Step 1: calculate base price ────────────────────────────────────────── // For catalog items: DefaultPrice × Qty is the base (covers one standard coat). // For non-catalog items: calculate first coat's material + labor + markup as the base. bool isCatalogItem = item.CatalogItemId.HasValue; _logger.LogInformation("Item {Description}: Qty={Qty}, SqFt={SqFt}, Coats={CoatCount}, IsCatalog={IsCatalog}", item.Description, item.Quantity, item.SurfaceAreaSqFt, item.Coats?.Count ?? 0, isCatalogItem); decimal baseSubtotal; decimal totalMaterialCost = 0m; decimal totalLaborCost = 0m; decimal totalEquipmentCost = 0m; if (isCatalogItem) { decimal baseUnitPrice = item.PowderCostOverride ?? 0m; if (baseUnitPrice == 0m) { var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId!.Value); baseUnitPrice = catalogItem?.DefaultPrice ?? 0m; } baseSubtotal = baseUnitPrice * item.Quantity; _logger.LogInformation("Catalog base: ${Price} × {Qty} = ${Base}", baseUnitPrice, item.Quantity, baseSubtotal); // Optionally add prep service labor for catalog items when the job requires abnormal // prep time (e.g. 120-min outgassing on a part that normally takes 30). The flag // defaults to false so the DefaultPrice is used as-is for typical jobs. if (!item.IsAiItem && item.IncludePrepCost && item.PrepServices != null && item.PrepServices.Any()) { var totalPrepMinutes = item.PrepServices.Sum(ps => ps.EstimatedMinutes); var prepLaborCost = (totalPrepMinutes / 60m) * costs.StandardLaborRate; baseSubtotal += prepLaborCost; totalLaborCost += prepLaborCost; _logger.LogInformation("Catalog item extra prep: {Min} min × ${Rate}/hr = ${Cost}", totalPrepMinutes, costs.StandardLaborRate, prepLaborCost); } // Custom (non-inventory) powder coats must be purchased separately — add their material // cost on top of the catalog base price. Inventory powder is assumed baked into DefaultPrice. if (item.Coats != null && item.Coats.Any()) { for (int ci = 0; ci < item.Coats.Count; ci++) { var coat = item.Coats[ci]; // Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0 && !IsCustomPowderCoat(coat)) { var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId); totalMaterialCost += coatResult.CoatMaterialCost; baseSubtotal += coatResult.CoatMaterialCost; _logger.LogInformation("Catalog item custom powder coat {Name}: +${Cost}", coat.CoatName, coatResult.CoatMaterialCost); } } } } else { // Non-catalog: derive base from first coat's material + labor + equipment + markup decimal coatLaborCost = 0m; // coat-only labor, used for coating booth (not prep/sandblast) if (item.Coats != null && item.Coats.Count > 0) { var firstCoatResult = await CalculateCoatPriceAsync( item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId); // Custom powder material moves to the "Custom Powder Order" line item; keep the labor totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost; coatLaborCost = firstCoatResult.CoatLaborCost; totalLaborCost = coatLaborCost; } // Prep service labor (done once per item batch) // AI items exclude prep cost — it's already baked into the AI estimate if (!item.IsAiItem && item.PrepServices != null && item.PrepServices.Any()) { var totalPrepMinutes = item.PrepServices.Sum(ps => ps.EstimatedMinutes); var prepLaborHours = totalPrepMinutes / 60m; totalLaborCost += prepLaborHours * costs.StandardLaborRate; _logger.LogInformation("PrepServices={Count}, TotalPrepMinutes={Min}", item.PrepServices.Count, totalPrepMinutes); } // Consumables surcharge (5% of material) totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent; // Equipment cost: coating booth only — use coat labor hours, not prep/sandblast hours // (sandblasting happens in a blast cabinet, not the powder coating booth) var coatLaborHours = costs.StandardLaborRate > 0 ? coatLaborCost / costs.StandardLaborRate : 0m; totalEquipmentCost = coatLaborHours * costs.CoatingBoothCostPerHour; // Apply pricing mode: markup on material only, or target margin on total cost if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost) { var totalCost = totalMaterialCost + totalLaborCost + totalEquipmentCost; var margin = Math.Min(costs.TargetMarginPercent / 100m, 0.99m); // clamp to avoid divide-by-zero baseSubtotal = margin < 0.001m ? totalCost : totalCost / (1m - margin); _logger.LogInformation("Non-catalog base (margin mode): TotalCost=${C}, Margin={M}% → Base=${Base}", totalCost, costs.TargetMarginPercent, baseSubtotal); } else { var materialWithMarkup = totalMaterialCost * (1 + costs.GeneralMarkupPercentage / 100m); baseSubtotal = materialWithMarkup + totalLaborCost + totalEquipmentCost; _logger.LogInformation("Non-catalog base (markup mode): Material=${M} (markup {Mk}% → ${MW}), Labor=${L}, Equipment=${E}, Base=${Base}", totalMaterialCost, costs.GeneralMarkupPercentage, materialWithMarkup, totalLaborCost, totalEquipmentCost, baseSubtotal); } } // ── Step 2: additional coat charges (% of base per additional coat) ────── // Each additional coat (index 1+) without NoExtraLayerCharge adds // AdditionalCoatLaborPercent % of the base subtotal. int additionalCoatCount = 0; if (item.Coats != null) { for (int i = 1; i < item.Coats.Count; i++) { if (!item.Coats[i].NoExtraLayerCharge) additionalCoatCount++; } } var additionalCoatCharge = baseSubtotal * additionalCoatCount * (costs.AdditionalCoatLaborPercent / 100m); var itemSubtotal = baseSubtotal + additionalCoatCharge; _logger.LogInformation("Additional coats: {Count} × {Pct}% of ${Base} = ${Charge}; Total=${Total}", additionalCoatCount, costs.AdditionalCoatLaborPercent, baseSubtotal, additionalCoatCharge, itemSubtotal); // ── Step 3: apply complexity multiplier (calculated items only) ──────── if (!isCatalogItem && !item.IsGenericItem && !item.IsLaborItem) { var complexityPercent = item.Complexity switch { "Moderate" => costs.ComplexityModeratePercent, "Complex" => costs.ComplexityComplexPercent, "Extreme" => costs.ComplexityExtremePercent, _ => costs.ComplexitySimplePercent // "Simple" or null }; if (complexityPercent > 0) { var complexityCharge = itemSubtotal * (complexityPercent / 100m); _logger.LogInformation("Complexity '{Complexity}': +{Pct}% = +${Charge}", item.Complexity ?? "Simple", complexityPercent, complexityCharge); itemSubtotal += complexityCharge; } } var unitPrice = item.Quantity > 0 ? itemSubtotal / item.Quantity : 0m; var totalPrice = itemSubtotal; return new QuoteItemPricingResult { MaterialCost = totalMaterialCost, LaborCost = totalLaborCost, EquipmentCost = totalEquipmentCost, ItemSubtotal = itemSubtotal, UnitPrice = unitPrice, TotalPrice = totalPrice }; } /// /// Calculates the complete quote total from all line items, applying quote-level charges, /// discounts, and tax. The calculation sequence is strictly ordered (steps 1–14) and must /// not be reordered, as each step feeds the next: /// /// 1. Sum item subtotals (via per item). /// 2. Add oven batch cost (batches × cycle hours × oven rate) — quote-level, not per-item. /// AI items are excluded from the oven charge by scaling with the non-AI surface-area fraction. /// 3. Add shop supplies charge (ShopSuppliesRate % of items + oven subtotal). /// 4. Apply customer pricing-tier discount (percentage off subtotal before any quote discount). /// 5. Apply quote-level discount (percentage or fixed amount, applied after tier discount). /// 6. Apply rush fee (percentage or fixed amount from operating costs, if IsRushJob). /// 7. Apply tax (manualTaxPercent if provided — used for tax-exempt customers — else CompanyTaxPercent). /// 8. Round all values to 2 decimal places. /// /// Overhead and profit margin are NOT added here — they were removed as separate line items. /// Markup is now baked into per-item prices (step 1). The ProfitPercent field in the result /// is the markup % from operating costs, kept for the breakdown display only. /// public async Task CalculateQuoteTotalsAsync( List items, int companyId, int? customerId = null, decimal? manualTaxPercent = null, string discountType = "None", decimal discountValue = 0, bool isRushJob = false, decimal? ovenCostOverride = null, int ovenBatches = 1, int? ovenCycleMinutes = null) { var costs = await GetOperatingCostsAsync(companyId); if (costs == null) { _logger.LogWarning("No operating costs configured for company {CompanyId}, returning zero pricing", companyId); return new QuotePricingResult { ItemsSubtotal = 0, ShopSuppliesAmount = 0, ShopSuppliesPercent = 0, FacilityOverheadCost = 0, FacilityOverheadRatePerHour = 0, OverheadCosts = 0, OverheadPercent = 0, ProfitMargin = 0, ProfitPercent = 0, SubtotalBeforeDiscount = 0, DiscountAmount = 0, DiscountPercent = 0, SubtotalAfterDiscount = 0, TaxAmount = 0, TaxPercent = 0, Total = 0, MaterialCosts = 0, LaborCosts = 0, EquipmentCosts = 0 }; } // Calculate all items var itemResults = new List(); foreach (var item in items) { QuoteItemPricingResult itemResult; // All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which // handles PowderCostOverride, prep cost inclusion, and all item type variants. itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride); itemResults.Add(itemResult); } // 1. SEPARATE CATALOG ITEMS FROM CALCULATED ITEMS // Catalog items WITHOUT coats are final prices - no breakdown // Catalog items WITH coats should include their cost breakdown var catalogItemsWithoutCoatsTotal = items .Where(i => i.CatalogItemId.HasValue && (i.Coats == null || !i.Coats.Any())) .Zip(itemResults.Where((r, idx) => items[idx].CatalogItemId.HasValue && (items[idx].Coats == null || !items[idx].Coats.Any())), (item, result) => result.ItemSubtotal) .Sum(); // Include calculated items AND catalog items with coats in the breakdown var itemsWithBreakdown = itemResults .Where((r, idx) => !items[idx].CatalogItemId.HasValue || (items[idx].Coats != null && items[idx].Coats.Any())) .ToList(); var calculatedItemsSubtotal = itemsWithBreakdown.Sum(r => r.ItemSubtotal); var totalMaterialCosts = itemsWithBreakdown.Sum(r => r.MaterialCost); var totalLaborCosts = itemsWithBreakdown.Sum(r => r.LaborCost); var totalEquipmentCosts = itemsWithBreakdown.Sum(r => r.EquipmentCost); // 2. OVERHEAD COSTS (removed - was calculated separately) var overheadCosts = 0m; // 3. PROFIT MARGIN (now baked into item prices, not added separately) // Store whichever % was active so the breakdown display shows the right label. var profitPercent = costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost ? costs.TargetMarginPercent : costs.GeneralMarkupPercentage; var profitMargin = 0m; // Already included in item prices // 4. TOTAL ITEMS SUBTOTAL var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal; // Powder-to-order costs are excluded from individual item prices and collected in a // "Custom Powder Order" line item added at save time. For live pricing previews (before // save), add them back here so the displayed total stays correct throughout the session. // Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and // incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost). bool hasCustomPowderOrderItem = items.Any(i => i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true); decimal customPowderOrderAmount = 0m; var customPowderOrderColors = new List(); if (!hasCustomPowderOrderItem) { foreach (var item in items.Where(i => i.Coats != null)) { foreach (var c in item.Coats!) { if (!c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 && c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0) { customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value; if (!string.IsNullOrWhiteSpace(c.ColorName)) customPowderOrderColors.Add(c.ColorName); } else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0) { var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value); if (invItem?.IsIncoming == true) { customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost; var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name; if (!string.IsNullOrWhiteSpace(colorName)) customPowderOrderColors.Add(colorName); } } } } if (customPowderOrderAmount > 0) { itemsSubtotal += customPowderOrderAmount; totalMaterialCosts += customPowderOrderAmount; } } // 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate) // AI items already have oven cost baked into their AI-estimated price, so we only // charge the proportion of the oven that's attributable to non-AI items. var effectiveOvenRate = ovenCostOverride ?? costs.OvenOperatingCostPerHour; var effectiveCycleMinutes = ovenCycleMinutes.HasValue && ovenCycleMinutes.Value > 0 ? ovenCycleMinutes.Value : costs.DefaultOvenCycleMinutes; var effectiveBatches = Math.Max(1, ovenBatches); var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate; // Only items with coating layers go in the oven — sandblast/prep-only items (zero coats) don't. // Of those coating items, AI items already have oven cost baked into their AI price. var coatingItems = items.Where(i => i.Coats != null && i.Coats.Any()).ToList(); var nonAiCoatItems = coatingItems.Where(i => !i.IsAiItem).ToList(); decimal nonAiFraction; if (!coatingItems.Any()) { nonAiFraction = 0m; // No coated items — no oven charge } else { var totalCoatSqFt = coatingItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity); var nonAiCoatSqFt = nonAiCoatItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity); if (totalCoatSqFt > 0) nonAiFraction = nonAiCoatSqFt / totalCoatSqFt; else nonAiFraction = coatingItems.Count > 0 ? (decimal)nonAiCoatItems.Count / coatingItems.Count : 1m; } var ovenBatchCost = fullOvenBatchCost * nonAiFraction; _logger.LogInformation( "Oven batch cost: {Batches} × {Min}min × ${Rate}/hr = ${Full}; non-AI fraction {Frac:P0} → charged ${Cost}", effectiveBatches, effectiveCycleMinutes, effectiveOvenRate, fullOvenBatchCost, nonAiFraction, ovenBatchCost); var itemsAndOvenSubtotal = itemsSubtotal + ovenBatchCost; // 5. FACILITY OVERHEAD (rent + utilities apportioned by estimated job hours) var facilityOverheadRatePerHour = 0m; var facilityOverheadCost = 0m; if (costs.MonthlyBillableHours > 0 && (costs.MonthlyRent + costs.MonthlyUtilities) > 0) { facilityOverheadRatePerHour = (costs.MonthlyRent + costs.MonthlyUtilities) / costs.MonthlyBillableHours; var totalEstimatedMinutes = items.Sum(i => (decimal)i.EstimatedMinutes * i.Quantity); facilityOverheadCost = facilityOverheadRatePerHour * (totalEstimatedMinutes / 60m); _logger.LogInformation( "Facility overhead: ${Rate:F2}/hr × {Min:F0} min = ${Cost:F2}", facilityOverheadRatePerHour, totalEstimatedMinutes, facilityOverheadCost); } // 6. SHOP SUPPLIES (percentage of items + oven subtotal — does not include facility overhead) var shopSuppliesPercent = costs.ShopSuppliesRate; var shopSuppliesAmount = itemsAndOvenSubtotal * (shopSuppliesPercent / 100m); // 7. SUBTOTAL BEFORE DISCOUNT (items + oven + facility overhead + shop supplies) var subtotalBeforeDiscount = itemsAndOvenSubtotal + facilityOverheadCost + shopSuppliesAmount; // 8. CUSTOMER PRICING TIER DISCOUNT (if applicable) var pricingTierDiscountPercent = 0m; var pricingTierDiscountAmount = 0m; if (customerId.HasValue) { try { var customers = await _unitOfWork.Customers.FindAsync(c => c.Id == customerId.Value); var customer = customers.FirstOrDefault(); if (customer != null) { // Get pricing tier if assigned if (customer.PricingTierId.HasValue) { var pricingTier = await _unitOfWork.PricingTiers.GetByIdAsync(customer.PricingTierId.Value); if (pricingTier != null) { pricingTierDiscountPercent = pricingTier.DiscountPercent; pricingTierDiscountAmount = subtotalBeforeDiscount * (pricingTierDiscountPercent / 100m); } } } } catch (Exception ex) { _logger.LogError(ex, "Error retrieving customer {CustomerId} for discount calculation", customerId.Value); } } var subtotalAfterTierDiscount = subtotalBeforeDiscount - pricingTierDiscountAmount; // 8. QUOTE-LEVEL DISCOUNT (applied after pricing tier discount) var quoteDiscountPercent = 0m; var quoteDiscountAmount = 0m; if (discountType != "None" && discountValue > 0) { if (discountType == "Percentage") { quoteDiscountPercent = discountValue; quoteDiscountAmount = subtotalAfterTierDiscount * (discountValue / 100m); } else if (discountType == "FixedAmount") { quoteDiscountAmount = discountValue; // Calculate what percentage this is for display purposes if (subtotalAfterTierDiscount > 0) { quoteDiscountPercent = (discountValue / subtotalAfterTierDiscount) * 100m; } } } // 9. COMBINED DISCOUNT (pricing tier + quote-level) var totalDiscountPercent = pricingTierDiscountPercent + quoteDiscountPercent; var totalDiscountAmount = pricingTierDiscountAmount + quoteDiscountAmount; // 10. SUBTOTAL AFTER ALL DISCOUNTS var subtotalAfterDiscount = subtotalAfterTierDiscount - quoteDiscountAmount; // 11. RUSH FEE (if applicable) var rushFee = 0m; if (isRushJob) { if (costs.RushChargeType == "Percentage") { rushFee = subtotalAfterDiscount * (costs.RushChargePercentage / 100m); } else // FixedAmount { rushFee = costs.RushChargeFixedAmount; } } // 12. SUBTOTAL INCLUDING RUSH FEE var subtotalWithRushFee = subtotalAfterDiscount + rushFee; // 13. TAX var taxPercent = manualTaxPercent ?? costs.TaxPercent; var taxAmount = subtotalWithRushFee * (taxPercent / 100m); // 14. FINAL TOTAL var total = subtotalWithRushFee + taxAmount; return new QuotePricingResult { ItemsSubtotal = Math.Round(itemsSubtotal, 2), OvenBatchCost = Math.Round(ovenBatchCost, 2), OvenBatches = effectiveBatches, OvenCycleMinutes = effectiveCycleMinutes, FacilityOverheadCost = Math.Round(facilityOverheadCost, 2), FacilityOverheadRatePerHour = Math.Round(facilityOverheadRatePerHour, 4), ShopSuppliesAmount = Math.Round(shopSuppliesAmount, 2), ShopSuppliesPercent = Math.Round(shopSuppliesPercent, 2), OverheadCosts = Math.Round(overheadCosts, 2), OverheadPercent = 0m, // Overhead removed ProfitMargin = Math.Round(profitMargin, 2), // 0 - now baked into item prices ProfitPercent = Math.Round(profitPercent, 2), // Markup % used (for reference) SubtotalBeforeDiscount = Math.Round(subtotalBeforeDiscount, 2), // Separate discounts PricingTierDiscountAmount = Math.Round(pricingTierDiscountAmount, 2), PricingTierDiscountPercent = Math.Round(pricingTierDiscountPercent, 2), QuoteDiscountAmount = Math.Round(quoteDiscountAmount, 2), QuoteDiscountPercent = Math.Round(quoteDiscountPercent, 2), // Combined discount (for backward compatibility) DiscountAmount = Math.Round(totalDiscountAmount, 2), DiscountPercent = Math.Round(totalDiscountPercent, 2), SubtotalAfterDiscount = Math.Round(subtotalAfterDiscount, 2), RushFee = Math.Round(rushFee, 2), TaxAmount = Math.Round(taxAmount, 2), TaxPercent = Math.Round(taxPercent, 2), Total = Math.Round(total, 2), MaterialCosts = Math.Round(totalMaterialCosts, 2), LaborCosts = Math.Round(totalLaborCosts, 2), EquipmentCosts = Math.Round(totalEquipmentCosts, 2), ItemResults = itemResults, CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2), CustomPowderOrderColors = customPowderOrderColors .Distinct(StringComparer.OrdinalIgnoreCase) .ToList() }; } }