Files
PowderCoatingLogix/src/PowderCoating.Application/Services/PricingCalculationService.cs
T
spouliot 4153acf3aa Add facility overhead (rent + utilities) to operating costs and pricing engine
Adds MonthlyRent, MonthlyUtilities, and MonthlyBillableHours to CompanyOperatingCosts so fixed shop occupancy costs are recovered on every quote. The pricing engine converts these into a per-hour rate and applies it as a transparent "Facility Overhead" line between oven batch cost and shop supplies. UI added in Company Settings Operating Costs tab and Setup Wizard Step 3; migration AddFacilityOverheadFields applied. Help docs and AI knowledge base updated to cover the new fields and the revised quote pricing calculation order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 19:35:00 -04:00

832 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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 → <see cref="CalculateCoatPriceAsync"/>
/// 2. Per-item: base + additional coats + complexity → <see cref="CalculateQuoteItemPriceAsync"/>
/// 3. Quote total: items + oven batch + shop supplies + discounts + rush + tax → <see cref="CalculateQuoteTotalsAsync"/>
///
/// 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.
/// </summary>
public class PricingCalculationService : IPricingCalculationService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PricingCalculationService> _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<PricingCalculationService> logger,
IMeasurementConversionService measurementService,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_logger = logger;
_measurementService = measurementService;
_tenantContext = tenantContext;
}
/// <summary>
/// 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.
/// </summary>
public async Task<CompanyOperatingCosts?> 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;
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<QuoteItemCoatPricingResult> 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.
bool isCustomPowder = !coat.InventoryItemId.HasValue
&& coat.PowderCostPerLb.HasValue
&& coat.PowderCostPerLb.Value > 0;
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 powder - use inventory cost
try
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (inventoryItem != null && inventoryItem.UnitCost > 0)
{
costPerLb = inventoryItem.UnitCost;
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}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, 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;
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Custom powder that must be purchased: charge for the full ordered quantity, not just
// the calculated usage. The shop is spending money on the entire order for this job.
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
coat.CoatName, 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
};
}
/// <summary>
/// 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.
/// </summary>
public async Task<QuoteItemPricingResult> 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 directly (set to either the AI estimate or the user's price override).
// The AI already factored in all costs — skip the pricing engine entirely.
if (item.IsAiItem && item.ManualUnitPrice.HasValue)
{
var aiUnitPrice = item.ManualUnitPrice.Value;
var aiTotal = aiUnitPrice * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = aiTotal,
UnitPrice = aiUnitPrice,
TotalPrice = aiTotal
};
}
// 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++)
{
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 cost for catalog items (opt-in toggle in wizard)
// AI items exclude prep cost — it's already baked into the AI estimate
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 prep cost: {Min}min × ${Rate}/h = ${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];
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
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
if (item.Coats != null && item.Coats.Count > 0)
{
var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
totalMaterialCost = firstCoatResult.CoatMaterialCost;
totalLaborCost = firstCoatResult.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 (oven cost moved to quote-level batch calculation)
var totalLaborHours = totalLaborCost / costs.StandardLaborRate;
totalEquipmentCost = totalLaborHours * 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
};
}
/// <summary>
/// Calculates the complete quote total from all line items, applying quote-level charges,
/// discounts, and tax. The calculation sequence is strictly ordered (steps 114) and must
/// not be reordered, as each step feeds the next:
///
/// 1. Sum item subtotals (via <see cref="CalculateQuoteItemPriceAsync"/> 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.
/// </summary>
public async Task<QuotePricingResult> CalculateQuoteTotalsAsync(
List<CreateQuoteItemDto> 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<QuoteItemPricingResult>();
foreach (var item in items)
{
QuoteItemPricingResult itemResult;
// Catalog items - if they have coats, add coat costs to catalog base price
if (item.CatalogItemId.HasValue)
{
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
if (catalogItem != null)
{
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
// (which already includes the catalog base price + coat costs)
if (item.Coats != null && item.Coats.Any())
{
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
}
else
{
// No coats - use simple catalog default price
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
itemResult = new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = catalogItemTotal,
UnitPrice = catalogItem.DefaultPrice,
TotalPrice = catalogItemTotal
};
}
}
else
{
// Catalog item not found, create zero result
itemResult = new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = 0,
UnitPrice = 0,
TotalPrice = 0
};
}
}
else
{
// Calculated items use the full pricing calculation
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;
// 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;
// Scale oven cost by the fraction of total surface area coming from non-AI items.
// Use item count as a fallback when surface areas are all zero.
var totalSqFt = items.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
var aiSqFt = items.Where(i => i.IsAiItem).Sum(i => i.SurfaceAreaSqFt * i.Quantity);
var nonAiSqFt = totalSqFt - aiSqFt;
decimal nonAiFraction;
if (totalSqFt > 0)
{
nonAiFraction = nonAiSqFt / totalSqFt;
}
else
{
var totalCount = items.Count;
var aiCount = items.Count(i => i.IsAiItem);
nonAiFraction = totalCount > 0 ? (decimal)(totalCount - aiCount) / totalCount : 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
};
}
}