6c07216c64
ManualUnitPrice holds the per-item formula result. The previous code incorrectly treated it as the batch total and divided by Quantity, causing the unit price to shrink as quantity increased. Now follows the same pattern as every other ManualUnitPrice path in this method. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
895 lines
44 KiB
C#
895 lines
44 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>
|
||
/// 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.
|
||
/// </summary>
|
||
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
|
||
!coat.InventoryItemId.HasValue &&
|
||
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
|
||
|
||
/// <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 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
|
||
};
|
||
}
|
||
|
||
/// <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;
|
||
|
||
// 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<string>();
|
||
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()
|
||
};
|
||
}
|
||
}
|