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
@@ -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 &gt; 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 = []
};
}
}