6721de91e4
- Store complete PricingBreakdownJson snapshot on Job at every save point so the Details page reads stored data rather than re-running the pricing engine - Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts, SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot - Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr) - Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads - Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths - Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson) - Fix Notes and CatalogItemId not copied to InvoiceItem - Add 14 unit tests in PricingStageFlowTests covering all three pricing stages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
411 lines
16 KiB
C#
411 lines
16 KiB
C#
using PowderCoating.Application.DTOs.Quote;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
|
|
namespace PowderCoating.Application.Services;
|
|
|
|
public class JobItemAssemblyService : IJobItemAssemblyService
|
|
{
|
|
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
ArgumentNullException.ThrowIfNull(pricing);
|
|
|
|
return BuildJobItem(
|
|
new JobItemSeed
|
|
{
|
|
Description = source.Description,
|
|
Quantity = source.Quantity,
|
|
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
|
CatalogItemId = source.CatalogItemId,
|
|
IsGenericItem = source.IsGenericItem,
|
|
IsLaborItem = source.IsLaborItem,
|
|
IsSalesItem = source.IsSalesItem,
|
|
IsAiItem = source.IsAiItem,
|
|
Sku = source.Sku,
|
|
ManualUnitPrice = source.ManualUnitPrice,
|
|
PowderCostOverride = source.PowderCostOverride,
|
|
UnitPrice = pricing.UnitPrice,
|
|
TotalPrice = pricing.TotalPrice,
|
|
LaborCost = pricing.LaborCost,
|
|
RequiresSandblasting = source.RequiresSandblasting,
|
|
RequiresMasking = source.RequiresMasking,
|
|
EstimatedMinutes = source.EstimatedMinutes,
|
|
Notes = source.Notes,
|
|
IncludePrepCost = source.IncludePrepCost,
|
|
Complexity = source.Complexity,
|
|
AiTags = source.AiTags,
|
|
AiPredictionId = source.AiPredictionId
|
|
},
|
|
jobId,
|
|
companyId,
|
|
createdAtUtc);
|
|
}
|
|
|
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
return source.Coats?
|
|
.OrderBy(c => c.Sequence)
|
|
.Select(c => BuildJobItemCoat(
|
|
new JobItemCoatSeed
|
|
{
|
|
CoatName = c.CoatName,
|
|
Sequence = c.Sequence,
|
|
InventoryItemId = c.InventoryItemId,
|
|
ColorName = c.ColorName,
|
|
VendorId = c.VendorId,
|
|
ColorCode = c.ColorCode,
|
|
Finish = c.Finish,
|
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
|
TransferEfficiency = c.TransferEfficiency,
|
|
PowderCostPerLb = c.PowderCostPerLb,
|
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
|
Notes = c.Notes
|
|
},
|
|
jobItemId,
|
|
companyId,
|
|
createdAtUtc))
|
|
.ToList() ?? [];
|
|
}
|
|
|
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
return BuildJobItemPrepServices(
|
|
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
|
{
|
|
PrepServiceId = p.PrepServiceId,
|
|
EstimatedMinutes = p.EstimatedMinutes,
|
|
BlastSetupId = p.BlastSetupId
|
|
}),
|
|
jobItemId,
|
|
companyId,
|
|
createdAtUtc);
|
|
}
|
|
|
|
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
var firstCoat = source.Coats?
|
|
.OrderBy(c => c.Sequence)
|
|
.FirstOrDefault();
|
|
|
|
return BuildJobItem(
|
|
new JobItemSeed
|
|
{
|
|
Description = source.Description,
|
|
Quantity = source.Quantity,
|
|
ColorName = firstCoat?.ColorName,
|
|
ColorCode = firstCoat?.ColorCode,
|
|
Finish = firstCoat?.Finish,
|
|
SurfaceArea = source.SurfaceAreaSqFt,
|
|
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
|
CatalogItemId = source.CatalogItemId,
|
|
IsGenericItem = source.IsGenericItem,
|
|
IsLaborItem = source.IsLaborItem,
|
|
IsSalesItem = source.IsSalesItem,
|
|
IsAiItem = source.IsAiItem,
|
|
Sku = source.Sku,
|
|
ManualUnitPrice = source.ManualUnitPrice,
|
|
PowderCostOverride = source.PowderCostOverride,
|
|
UnitPrice = source.UnitPrice,
|
|
TotalPrice = source.TotalPrice,
|
|
LaborCost = source.ItemLaborCost,
|
|
RequiresSandblasting = source.RequiresSandblasting,
|
|
RequiresMasking = source.RequiresMasking,
|
|
EstimatedMinutes = source.EstimatedMinutes,
|
|
Notes = source.Notes,
|
|
IncludePrepCost = source.IncludePrepCost,
|
|
Complexity = source.Complexity,
|
|
AiTags = source.AiTags,
|
|
AiPredictionId = source.AiPredictionId
|
|
},
|
|
jobId,
|
|
companyId,
|
|
createdAtUtc);
|
|
}
|
|
|
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
return source.Coats?
|
|
.OrderBy(c => c.Sequence)
|
|
.Select(c =>
|
|
{
|
|
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
|
|
return BuildJobItemCoat(
|
|
new JobItemCoatSeed
|
|
{
|
|
CoatName = c.CoatName,
|
|
Sequence = c.Sequence,
|
|
InventoryItemId = c.InventoryItemId,
|
|
ColorName = appearance.ColorName,
|
|
VendorId = c.VendorId,
|
|
ColorCode = appearance.ColorCode,
|
|
Finish = appearance.Finish,
|
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
|
TransferEfficiency = c.TransferEfficiency,
|
|
PowderCostPerLb = c.PowderCostPerLb,
|
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
|
Notes = c.Notes
|
|
},
|
|
jobItemId,
|
|
companyId,
|
|
createdAtUtc);
|
|
})
|
|
.ToList() ?? [];
|
|
}
|
|
|
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
return BuildJobItemPrepServices(
|
|
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
|
{
|
|
PrepServiceId = p.PrepServiceId,
|
|
EstimatedMinutes = p.EstimatedMinutes,
|
|
BlastSetupId = p.BlastSetupId
|
|
}),
|
|
jobItemId,
|
|
companyId,
|
|
createdAtUtc);
|
|
}
|
|
|
|
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
return BuildJobItem(
|
|
new JobItemSeed
|
|
{
|
|
Description = source.Description,
|
|
Quantity = source.Quantity,
|
|
ColorName = source.ColorName,
|
|
ColorCode = source.ColorCode,
|
|
Finish = source.Finish,
|
|
SurfaceArea = source.SurfaceArea,
|
|
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
|
CatalogItemId = source.CatalogItemId,
|
|
IsGenericItem = source.IsGenericItem,
|
|
IsLaborItem = source.IsLaborItem,
|
|
IsSalesItem = source.IsSalesItem,
|
|
IsAiItem = source.IsAiItem,
|
|
Sku = source.Sku,
|
|
ManualUnitPrice = source.ManualUnitPrice,
|
|
PowderCostOverride = source.PowderCostOverride,
|
|
UnitPrice = source.UnitPrice,
|
|
TotalPrice = source.TotalPrice,
|
|
LaborCost = source.LaborCost,
|
|
RequiresSandblasting = source.RequiresSandblasting,
|
|
RequiresMasking = source.RequiresMasking,
|
|
EstimatedMinutes = source.EstimatedMinutes,
|
|
Notes = source.Notes,
|
|
IncludePrepCost = source.IncludePrepCost,
|
|
Complexity = source.Complexity,
|
|
AiTags = source.AiTags,
|
|
AiPredictionId = source.AiPredictionId
|
|
},
|
|
jobId,
|
|
companyId,
|
|
createdAtUtc);
|
|
}
|
|
|
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
return source.Coats?
|
|
.OrderBy(c => c.Sequence)
|
|
.Select(c => BuildJobItemCoat(
|
|
new JobItemCoatSeed
|
|
{
|
|
CoatName = c.CoatName,
|
|
Sequence = c.Sequence,
|
|
InventoryItemId = c.InventoryItemId,
|
|
ColorName = c.ColorName,
|
|
VendorId = c.VendorId,
|
|
ColorCode = c.ColorCode,
|
|
Finish = c.Finish,
|
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
|
TransferEfficiency = c.TransferEfficiency,
|
|
PowderCostPerLb = c.PowderCostPerLb,
|
|
PowderToOrder = c.PowderToOrder,
|
|
Notes = c.Notes
|
|
},
|
|
jobItemId,
|
|
companyId,
|
|
createdAtUtc))
|
|
.ToList() ?? [];
|
|
}
|
|
|
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
return BuildJobItemPrepServices(
|
|
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
|
{
|
|
PrepServiceId = p.PrepServiceId,
|
|
EstimatedMinutes = p.EstimatedMinutes,
|
|
BlastSetupId = p.BlastSetupId
|
|
}),
|
|
jobItemId,
|
|
companyId,
|
|
createdAtUtc);
|
|
}
|
|
|
|
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
return new JobItem
|
|
{
|
|
JobId = jobId,
|
|
Description = seed.Description,
|
|
Quantity = seed.Quantity,
|
|
ColorName = seed.ColorName,
|
|
ColorCode = seed.ColorCode,
|
|
Finish = seed.Finish,
|
|
SurfaceArea = seed.SurfaceArea,
|
|
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
|
|
CatalogItemId = seed.CatalogItemId,
|
|
IsGenericItem = seed.IsGenericItem,
|
|
IsLaborItem = seed.IsLaborItem,
|
|
IsSalesItem = seed.IsSalesItem,
|
|
IsAiItem = seed.IsAiItem,
|
|
Sku = seed.Sku,
|
|
ManualUnitPrice = seed.ManualUnitPrice,
|
|
PowderCostOverride = seed.PowderCostOverride,
|
|
UnitPrice = seed.UnitPrice,
|
|
TotalPrice = seed.TotalPrice,
|
|
LaborCost = seed.LaborCost,
|
|
RequiresSandblasting = seed.RequiresSandblasting,
|
|
RequiresMasking = seed.RequiresMasking,
|
|
EstimatedMinutes = seed.EstimatedMinutes,
|
|
Notes = seed.Notes,
|
|
IncludePrepCost = seed.IncludePrepCost,
|
|
Complexity = seed.Complexity,
|
|
AiTags = seed.AiTags,
|
|
AiPredictionId = seed.AiPredictionId,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc
|
|
};
|
|
}
|
|
|
|
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
return new JobItemCoat
|
|
{
|
|
JobItemId = jobItemId,
|
|
CoatName = seed.CoatName,
|
|
Sequence = seed.Sequence,
|
|
InventoryItemId = seed.InventoryItemId,
|
|
ColorName = seed.ColorName,
|
|
VendorId = seed.VendorId,
|
|
ColorCode = seed.ColorCode,
|
|
Finish = seed.Finish,
|
|
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
|
|
TransferEfficiency = seed.TransferEfficiency,
|
|
PowderCostPerLb = seed.PowderCostPerLb,
|
|
PowderToOrder = seed.PowderToOrder,
|
|
Notes = seed.Notes,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
|
{
|
|
return seeds?
|
|
.Select(seed => new JobItemPrepService
|
|
{
|
|
JobItemId = jobItemId,
|
|
PrepServiceId = seed.PrepServiceId,
|
|
EstimatedMinutes = seed.EstimatedMinutes,
|
|
BlastSetupId = seed.BlastSetupId,
|
|
CompanyId = companyId,
|
|
CreatedAt = createdAtUtc
|
|
})
|
|
.ToList() ?? [];
|
|
}
|
|
|
|
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
|
{
|
|
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
|
return storedPowderToOrder;
|
|
|
|
if (surfaceAreaSqFt <= 0)
|
|
return null;
|
|
|
|
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
|
|
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
|
|
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
|
}
|
|
|
|
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
|
string? colorName,
|
|
string? colorCode,
|
|
string? finish,
|
|
InventoryItem? inventoryItem)
|
|
{
|
|
if (inventoryItem == null)
|
|
return (colorName, colorCode, finish);
|
|
|
|
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
|
}
|
|
|
|
private sealed class JobItemSeed
|
|
{
|
|
public string Description { get; init; } = string.Empty;
|
|
public decimal Quantity { get; init; }
|
|
public string? ColorName { get; init; }
|
|
public string? ColorCode { get; init; }
|
|
public string? Finish { get; init; }
|
|
public decimal? SurfaceArea { get; init; }
|
|
public decimal SurfaceAreaSqFt { get; init; }
|
|
public int? CatalogItemId { get; init; }
|
|
public bool IsGenericItem { get; init; }
|
|
public bool IsLaborItem { get; init; }
|
|
public bool IsSalesItem { get; init; }
|
|
public bool IsAiItem { get; init; }
|
|
public string? Sku { get; init; }
|
|
public decimal? ManualUnitPrice { get; init; }
|
|
public decimal? PowderCostOverride { get; init; }
|
|
public decimal UnitPrice { get; init; }
|
|
public decimal TotalPrice { get; init; }
|
|
public decimal LaborCost { get; init; }
|
|
public bool RequiresSandblasting { get; init; }
|
|
public bool RequiresMasking { get; init; }
|
|
public int EstimatedMinutes { get; init; }
|
|
public string? Notes { get; init; }
|
|
public bool IncludePrepCost { get; init; }
|
|
public string? Complexity { get; init; }
|
|
public string? AiTags { get; init; }
|
|
public int? AiPredictionId { get; init; }
|
|
}
|
|
|
|
private sealed class JobItemCoatSeed
|
|
{
|
|
public string CoatName { get; init; } = string.Empty;
|
|
public int Sequence { get; init; }
|
|
public int? InventoryItemId { get; init; }
|
|
public string? ColorName { get; init; }
|
|
public int? VendorId { get; init; }
|
|
public string? ColorCode { get; init; }
|
|
public string? Finish { get; init; }
|
|
public decimal CoverageSqFtPerLb { get; init; }
|
|
public decimal TransferEfficiency { get; init; }
|
|
public decimal? PowderCostPerLb { get; init; }
|
|
public decimal? PowderToOrder { get; init; }
|
|
public string? Notes { get; init; }
|
|
}
|
|
|
|
private sealed class JobItemPrepServiceSeed
|
|
{
|
|
public int PrepServiceId { get; init; }
|
|
public int EstimatedMinutes { get; init; }
|
|
public int? BlastSetupId { get; init; }
|
|
}
|
|
}
|