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
@@ -1176,6 +1176,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allCreateItems = dto.JobItems.ToList();
if (!allCreateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allCreateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allCreateItems.Add(powderDto);
}
}
// Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null;
@@ -1186,7 +1206,7 @@ public class JobsController : Controller
createOvenRate = createOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
allCreateItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
@@ -1400,6 +1420,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allEditItems = dto.JobItems.ToList();
if (!allEditItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allEditItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allEditItems.Add(powderDto);
}
}
// Now load and update the job itself
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
if (job == null)
@@ -1653,7 +1693,7 @@ public class JobsController : Controller
}
// Recalculate FinalPrice from wizard items
if (dto.JobItems.Any())
if (allEditItems.Any())
{
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null;
@@ -1664,7 +1704,7 @@ public class JobsController : Controller
editOvenRate = editOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
allEditItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
@@ -1858,24 +1898,32 @@ public class JobsController : Controller
{
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
await PopulateDropdowns();
await PopulatePrepServicesAsync(companyId);
@@ -3127,6 +3175,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allUpdateItems = model.JobItems.ToList();
if (!allUpdateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allUpdateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, currentUser.CompanyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allUpdateItems.Add(powderDto);
}
}
// Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue)
@@ -3137,7 +3205,7 @@ public class JobsController : Controller
}
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId,
allUpdateItems, currentUser.CompanyId, job.CustomerId,
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
@@ -3341,12 +3409,20 @@ public class JobsController : Controller
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
var allowFormulas2 = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas2)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
}
/// <summary>
@@ -3382,6 +3458,66 @@ public class JobsController : Controller
return companyDefaultRate;
}
/// <summary>
/// Builds a "Custom Powder Order" DTO by aggregating all powder-to-order costs across the
/// submitted items. Returns null when no qualifying coats are present.
///
/// Two coat types qualify:
/// - Custom powder: no InventoryItemId, PowderToOrder &gt; 0, PowderCostPerLb &gt; 0
/// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder &gt; 0
/// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost)
/// </summary>
private async Task<CreateQuoteItemDto?> BuildCustomPowderOrderDto(IEnumerable<CreateQuoteItemDto> itemDtos)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var dto in itemDtos)
{
if (dto.Coats == null) continue;
foreach (var coat in dto.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; PowderCostPerLb was cleared after incoming
// inventory item was created, so cost comes from inventoryItem.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 CreateQuoteItemDto
{
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost
};
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{