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:
@@ -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