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:
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user