Add Custom Powder Order line item and fix CSV import FinalPrice crash

Custom powder/incoming powder material cost now flows into a separate
auto-generated 'Custom Powder Order' line item instead of rolling into
individual item prices, so users can add shipping charges before the
customer sees the total. A dashed yellow preview card in the wizard
shows the material cost and lets users edit the total (including shipping)
before saving. After first save the price is user-owned.

Also fixes a fatal CSV import crash when FinalPrice contains a non-numeric
value (e.g. 'false' from a spreadsheet formula): the job CSV importer now
streams rows one at a time with a lenient decimal converter, treating bad
values as $0 with a per-row warning instead of aborting the entire import.

Updated HelpKnowledgeBase.cs and Help articles (Jobs, Quotes) with
Custom Powder Order behavior and a new Data Import / Export section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:37:46 -04:00
parent e476b4744d
commit a7ad0e1de8
19 changed files with 721 additions and 78 deletions
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
};
}
/// <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:
@@ -289,20 +299,23 @@ public class PricingCalculationService : IPricingCalculationService
}
// 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.
// and stored the result as ManualUnitPrice. The formula result IS the total price — it already
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
// 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;
var formulaTotal = item.ManualUnitPrice.Value;
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = total,
UnitPrice = item.ManualUnitPrice.Value,
TotalPrice = total
ItemSubtotal = formulaTotal,
UnitPrice = formulaUnitPrice,
TotalPrice = formulaTotal
};
}
@@ -330,6 +343,8 @@ public class PricingCalculationService : IPricingCalculationService
{
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;
@@ -431,7 +446,9 @@ public class PricingCalculationService : IPricingCalculationService
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)
// 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;
@@ -449,7 +466,8 @@ public class PricingCalculationService : IPricingCalculationService
{
var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
totalMaterialCost = firstCoatResult.CoatMaterialCost;
// 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;
}
@@ -646,6 +664,49 @@ public class PricingCalculationService : IPricingCalculationService
// 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.
@@ -824,7 +885,11 @@ public class PricingCalculationService : IPricingCalculationService
MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 2),
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
ItemResults = itemResults
ItemResults = itemResults,
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
CustomPowderOrderColors = customPowderOrderColors
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
}