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:
@@ -884,4 +884,9 @@ public class QuotePricingResult
|
||||
|
||||
// Per-item results (same order as input items)
|
||||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||||
|
||||
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
|
||||
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
|
||||
public decimal CustomPowderOrderAmount { get; set; }
|
||||
public List<string> CustomPowderOrderColors { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -192,7 +192,10 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
||||
|
||||
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
||||
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
|
||||
// is unreliable for ICollection<T> → List<T2> with different element types.
|
||||
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
||||
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var dtoList = itemDtos.ToList();
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
foreach (var itemDto in dtoList)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
// Option B: auto-create the Custom Powder Order item only on first save.
|
||||
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
||||
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
||||
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
||||
if (!hasExistingCustomPowderOrder)
|
||||
{
|
||||
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
||||
if (customPowderItem != null)
|
||||
items.Add(customPowderItem);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -461,4 +473,78 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans all coat DTOs for powder that must be ordered (custom or incoming) and returns a
|
||||
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
|
||||
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
|
||||
/// on the first save only — Option B means the user owns the price after creation.
|
||||
///
|
||||
/// Two coat types qualify:
|
||||
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0
|
||||
/// - Incoming powder: InventoryItemId set but inventoryItem.IsIncoming == true
|
||||
/// (auto-created by <see cref="CreateIncomingInventoryItemAsync"/>; PowderCostPerLb cleared
|
||||
/// after creation, so cost comes from inventoryItem.UnitCost instead)
|
||||
/// </summary>
|
||||
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
var colorNames = new List<string>();
|
||||
decimal totalCost = 0m;
|
||||
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
if (itemDto.Coats == null) continue;
|
||||
foreach (var coat in itemDto.Coats)
|
||||
{
|
||||
if (!coat.InventoryItemId.HasValue &&
|
||||
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
// Custom powder: no inventory link, user entered cost per lb manually
|
||||
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||
colorNames.Add(coat.ColorName);
|
||||
}
|
||||
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// Incoming powder: catalog-selected; CreateIncomingInventoryItemAsync set InventoryItemId
|
||||
// and cleared PowderCostPerLb, so cost must come from the inventory item's UnitCost
|
||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (invItem?.IsIncoming == true)
|
||||
{
|
||||
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
colorNames.Add(colorName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCost <= 0) return null;
|
||||
|
||||
var uniqueColors = colorNames
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var description = uniqueColors.Any()
|
||||
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||
: "Custom Powder Order";
|
||||
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = description,
|
||||
Quantity = 1,
|
||||
IsGenericItem = true,
|
||||
ManualUnitPrice = totalCost,
|
||||
UnitPrice = totalCost,
|
||||
TotalPrice = totalCost,
|
||||
ItemMaterialCost = totalCost,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc,
|
||||
Coats = [],
|
||||
PrepServices = []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user