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:
@@ -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 > 0, PowderCostPerLb > 0
|
||||
/// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder > 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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user