diff --git a/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs b/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs
index 4d3827e..0bb9c48 100644
--- a/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs
@@ -884,4 +884,9 @@ public class QuotePricingResult
// Per-item results (same order as input items)
public List 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 CustomPowderOrderColors { get; set; } = new();
}
diff --git a/src/PowderCoating.Application/Mappings/QuoteProfile.cs b/src/PowderCoating.Application/Mappings/QuoteProfile.cs
index b0afae4..49788ce 100644
--- a/src/PowderCoating.Application/Mappings/QuoteProfile.cs
+++ b/src/PowderCoating.Application/Mappings/QuoteProfile.cs
@@ -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 → List with different element types.
CreateMap()
+ .ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
// ============================================================================
diff --git a/src/PowderCoating.Application/Services/PricingCalculationService.cs b/src/PowderCoating.Application/Services/PricingCalculationService.cs
index c4a3434..5fbc1c6 100644
--- a/src/PowderCoating.Application/Services/PricingCalculationService.cs
+++ b/src/PowderCoating.Application/Services/PricingCalculationService.cs
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
};
}
+ ///
+ /// 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.
+ ///
+ private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
+ !coat.InventoryItemId.HasValue &&
+ coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
+ coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
+
///
/// 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();
+ 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()
};
}
}
diff --git a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
index 84ee1b8..e7b4449 100644
--- a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
+++ b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
ArgumentNullException.ThrowIfNull(itemDtos);
+ var dtoList = itemDtos.ToList();
var items = new List();
- 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;
}
}
+
+ ///
+ /// 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
+ /// 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 ; PowderCostPerLb cleared
+ /// after creation, so cost comes from inventoryItem.UnitCost instead)
+ ///
+ private async Task BuildCustomPowderOrderItemAsync(
+ IReadOnlyList itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
+ {
+ var colorNames = new List();
+ 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 = []
+ };
+ }
}
diff --git a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs
index 6fa6e37..7b8bde7 100644
--- a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs
+++ b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs
@@ -1268,24 +1268,22 @@ public class CsvImportService : ICsvImportService
MissingFieldFound = null
});
- var records = csv.GetRecords().ToList();
- result.TotalRows = records.Count;
+ // Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
+ // as null rather than throwing a fatal TypeConverterException.
+ csv.Context.TypeConverterCache.AddConverter(new LenientNullableDecimalConverter());
- _logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
+ // Read header row first so we know field count before iterating rows.
+ await csv.ReadAsync();
+ csv.ReadHeader();
- // Get all existing jobs for duplicate detection
+ // Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
- // Get customers for lookup — build two dictionaries so we can resolve by email
- // first and fall back to company name when the customer has no email on file.
var customers = await _unitOfWork.Customers.GetAllAsync();
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
- // Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
- // TryAdd ensures that if two customers share the same name the first one wins and the
- // lookup warning will prompt the user to resolve the ambiguity manually.
var customerByName = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var c in customers)
{
@@ -1296,19 +1294,42 @@ public class CsvImportService : ICsvImportService
customerByName.TryAdd(name, c);
}
- // Get job statuses for lookup
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
- // Get job priorities for lookup
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
- foreach (var record in records)
+ // Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
+ // in a decimal field) is caught per-row rather than aborting the entire import.
+ while (await csv.ReadAsync())
{
- rowNumber++;
+ result.TotalRows++;
+ JobImportDto record;
+ try
+ {
+ record = csv.GetRecord()
+ ?? throw new InvalidOperationException("Row returned null record.");
+ }
+ catch (Exception parseEx)
+ {
+ result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
+ result.ErrorCount++;
+ _logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
+ continue;
+ }
+
+ rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
+
+ // Warn when FinalPrice was non-numeric (lenient converter returned null).
+ var rawFinalPrice = csv.TryGetField(7, out var fpStr) ? fpStr : null;
+ if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
+ && !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
+ {
+ result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
+ }
try
{
// Validate required fields
@@ -3340,4 +3361,23 @@ public class CsvImportService : ICsvImportService
return result;
}
}
+
+ ///
+ /// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
+ /// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
+ /// artefacts like "false" in a price column are treated as $0 with a warning.
+ ///
+ private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
+ {
+ public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
+ ? (object?)v
+ : null;
+ }
+
+ public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
+ => value?.ToString();
+ }
}
diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs
index aba9548..841fd02 100644
--- a/src/PowderCoating.Web/Controllers/JobsController.cs
+++ b/src/PowderCoating.Web/Controllers/JobsController.cs
@@ -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
+
Custom Powder Order
+
+ When a coat uses a custom powder (manually entered cost per lb, no inventory item
+ selected) or an incoming powder (a color being ordered that is not yet in stock),
+ the material cost is separated from the item price and collected into a single
+ Custom Powder Order line item. This keeps per-item pricing clean and lets you
+ add shipping or freight before the customer sees the total.
+
+
+
+ While building the job, a dashed yellow Powder Order preview card appears
+ below the item cards. Edit the price to include any shipping before saving.
+
+
+ On the saved job, the Custom Powder Order appears as its own line item with the ordered
+ color name(s) in its description.
+
+
+ The Custom Powder Order is created once on first save. The price is user-owned after that
+ and will not be overwritten by the system on subsequent saves.
+
+
+
Save to Product Catalog
After completing the coatings and prep services steps, Calculated and
diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml
index 7dcfc54..d534b79 100644
--- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml
+++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml
@@ -170,6 +170,35 @@
prep services — sandblasting, masking, and/or cleaning — that will be performed before coating.
+
Custom Powder Order
+
+ When a coat uses a custom powder (you enter a cost per lb manually without selecting
+ an inventory item) or an incoming powder (a color you need to order that is not yet
+ in stock), the powder material cost is not added to the individual item price.
+ Instead, the system auto-generates a separate Custom Powder Order line item that
+ collects all ordering costs in one place — so you can include shipping and freight before
+ presenting the quote to the customer.
+
+
+
+ While building the quote, a dashed yellow Powder Order preview card appears
+ below the item cards showing the calculated material cost. The price field is editable —
+ type the total you want to charge (material + any shipping) before saving.
+
+
+ On the saved quote, the Custom Powder Order appears as its own line item with the color
+ name(s) in the description (e.g. Custom Powder Order (Gloss Black, Satin Silver)).
+
+
+ A yellow banner on the Quote Details page reminds you when a Custom Powder Order is present
+ so you don’t forget to confirm the shipping amount.
+
+
+ The Custom Powder Order is created once on first save. After that the price
+ is yours — the system will not overwrite it on subsequent saves.
+
+
+
Save to Product Catalog
After completing the prep services step, Calculated and AI Photo items display one final step:
diff --git a/src/PowderCoating.Web/Views/Jobs/Create.cshtml b/src/PowderCoating.Web/Views/Jobs/Create.cshtml
index 772d2a9..f1bcdfd 100644
--- a/src/PowderCoating.Web/Views/Jobs/Create.cshtml
+++ b/src/PowderCoating.Web/Views/Jobs/Create.cshtml
@@ -216,6 +216,28 @@