1eba50cf0f
Introduces per-company reusable NCalc2 pricing formula templates for complex fabricated items (roof curbs, enclosures, welded frames). Templates support two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt (formula yields sq ft fed into the standard coating engine). Includes: - CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo - IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService overloads and all existingItemsData JSON projections + pageMeta blocks - ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula generator (natural language + optional diagram image) and NCalc2 evaluator - CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete, UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi - Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js - item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields - Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details - Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action, HelpKnowledgeBase entry; 225/225 unit tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
831 lines
40 KiB
C#
831 lines
40 KiB
C#
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.
|
||
// 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
|
||
};
|
||
}
|
||
|
||
/// <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 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 result as ManualUnitPrice. Use it directly — no coating math.
|
||
// 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 total = item.ManualUnitPrice.Value * item.Quantity;
|
||
return new QuoteItemPricingResult
|
||
{
|
||
MaterialCost = 0,
|
||
LaborCost = 0,
|
||
EquipmentCost = 0,
|
||
ItemSubtotal = total,
|
||
UnitPrice = item.ManualUnitPrice.Value,
|
||
TotalPrice = total
|
||
};
|
||
}
|
||
|
||
// 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 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];
|
||
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
|
||
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);
|
||
totalMaterialCost = 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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <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;
|
||
|
||
// 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;
|
||
|
||
// 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
|
||
};
|
||
}
|
||
}
|