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>
379 lines
14 KiB
C#
379 lines
14 KiB
C#
using PowderCoating.Application.DTOs.Quote;
|
||
using PowderCoating.Application.Interfaces;
|
||
using PowderCoating.Application.Services;
|
||
using PowderCoating.Core.Entities;
|
||
|
||
namespace PowderCoating.UnitTests;
|
||
|
||
public class JobItemAssemblyServiceTests
|
||
{
|
||
private static readonly DateTime CreatedAtUtc = new(2026, 5, 9, 14, 30, 0, DateTimeKind.Utc);
|
||
|
||
private readonly IJobItemAssemblyService _service = new JobItemAssemblyService();
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromWizardDto_PreservesSalesFieldsAndCalculatedChildren()
|
||
{
|
||
var source = new CreateQuoteItemDto
|
||
{
|
||
Description = "Powder coated tumbler",
|
||
Quantity = 2m,
|
||
SurfaceAreaSqFt = 12m,
|
||
EstimatedMinutes = 18,
|
||
CatalogItemId = 44,
|
||
IsSalesItem = true,
|
||
Sku = "TMB-RED-20",
|
||
ManualUnitPrice = 29.99m,
|
||
PowderCostOverride = 7.25m,
|
||
RequiresSandblasting = true,
|
||
RequiresMasking = true,
|
||
Notes = "Merch item",
|
||
IncludePrepCost = false,
|
||
Complexity = "Moderate",
|
||
AiTags = "merch,tumbler",
|
||
AiPredictionId = 91,
|
||
Coats =
|
||
[
|
||
new CreateQuoteItemCoatDto
|
||
{
|
||
CoatName = "Base",
|
||
Sequence = 1,
|
||
ColorName = "Signal Red",
|
||
ColorCode = "RAL3001",
|
||
Finish = "Gloss",
|
||
CoverageSqFtPerLb = 30m,
|
||
TransferEfficiency = 50m
|
||
}
|
||
],
|
||
PrepServices =
|
||
[
|
||
new CreateQuoteItemPrepServiceDto
|
||
{
|
||
PrepServiceId = 7,
|
||
EstimatedMinutes = 12,
|
||
BlastSetupId = 88
|
||
}
|
||
]
|
||
};
|
||
|
||
var pricing = new QuoteItemPricingResult
|
||
{
|
||
UnitPrice = 29.99m,
|
||
TotalPrice = 59.98m,
|
||
LaborCost = 23.992m // explicitly from pricing engine, not a 0.4× multiplier
|
||
};
|
||
|
||
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
|
||
var coats = _service.CreateJobItemCoats(source, jobItemId: 25, companyId: 3, CreatedAtUtc);
|
||
var prepServices = _service.CreateJobItemPrepServices(source, jobItemId: 25, companyId: 3, CreatedAtUtc);
|
||
|
||
Assert.Equal(10, jobItem.JobId);
|
||
Assert.Equal("Powder coated tumbler", jobItem.Description);
|
||
Assert.True(jobItem.IsSalesItem);
|
||
Assert.Equal("TMB-RED-20", jobItem.Sku);
|
||
Assert.False(jobItem.IncludePrepCost);
|
||
Assert.Equal(91, jobItem.AiPredictionId);
|
||
Assert.Equal("merch,tumbler", jobItem.AiTags);
|
||
Assert.Equal(59.98m, jobItem.TotalPrice);
|
||
Assert.Equal(23.992m, jobItem.LaborCost);
|
||
Assert.Equal(CreatedAtUtc, jobItem.CreatedAt);
|
||
|
||
var coat = Assert.Single(coats);
|
||
Assert.Equal(25, coat.JobItemId);
|
||
Assert.Equal(1.6m, coat.PowderToOrder);
|
||
Assert.Equal("Signal Red", coat.ColorName);
|
||
Assert.Equal(CreatedAtUtc, coat.CreatedAt);
|
||
|
||
var prepService = Assert.Single(prepServices);
|
||
Assert.Equal(88, prepService.BlastSetupId);
|
||
Assert.Equal(12, prepService.EstimatedMinutes);
|
||
Assert.Equal(CreatedAtUtc, prepService.CreatedAt);
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromQuoteItem_PreservesQuoteShapeAndPrepCostFlag()
|
||
{
|
||
var quoteItem = new QuoteItem
|
||
{
|
||
Description = "Bracket set",
|
||
Quantity = 3m,
|
||
SurfaceAreaSqFt = 10m,
|
||
CatalogItemId = 14,
|
||
IsGenericItem = false,
|
||
IsLaborItem = false,
|
||
IsSalesItem = true,
|
||
Sku = "BRK-SET",
|
||
ManualUnitPrice = 18m,
|
||
PowderCostOverride = 6m,
|
||
UnitPrice = 42m,
|
||
TotalPrice = 126m,
|
||
RequiresSandblasting = true,
|
||
RequiresMasking = false,
|
||
EstimatedMinutes = 25,
|
||
Notes = "Use existing hang points",
|
||
Complexity = "Complex",
|
||
IncludePrepCost = true,
|
||
AiTags = "bracket,steel",
|
||
AiPredictionId = 55,
|
||
Coats =
|
||
[
|
||
new QuoteItemCoat
|
||
{
|
||
CoatName = "Top Coat",
|
||
Sequence = 1,
|
||
InventoryItemId = 12,
|
||
ColorName = "Stale Name",
|
||
ColorCode = "STALE",
|
||
Finish = "Stale",
|
||
CoverageSqFtPerLb = 20m,
|
||
TransferEfficiency = 80m,
|
||
PowderCostPerLb = 5m,
|
||
Notes = "Resolved from inventory",
|
||
InventoryItem = new InventoryItem
|
||
{
|
||
Id = 12,
|
||
Name = "Gloss Black",
|
||
ColorCode = "RAL9005",
|
||
Finish = "Gloss"
|
||
}
|
||
}
|
||
],
|
||
PrepServices =
|
||
[
|
||
new QuoteItemPrepService
|
||
{
|
||
PrepServiceId = 4,
|
||
EstimatedMinutes = 9,
|
||
BlastSetupId = 41
|
||
}
|
||
]
|
||
};
|
||
|
||
var jobItem = _service.CreateJobItem(quoteItem, jobId: 99, companyId: 6, createdAtUtc: CreatedAtUtc);
|
||
var coats = _service.CreateJobItemCoats(quoteItem, jobItemId: 70, companyId: 6, CreatedAtUtc);
|
||
var prepServices = _service.CreateJobItemPrepServices(quoteItem, jobItemId: 70, companyId: 6, CreatedAtUtc);
|
||
|
||
Assert.Equal(99, jobItem.JobId);
|
||
Assert.True(jobItem.IsSalesItem);
|
||
Assert.Equal("BRK-SET", jobItem.Sku);
|
||
Assert.True(jobItem.IncludePrepCost);
|
||
Assert.Equal(55, jobItem.AiPredictionId);
|
||
Assert.Equal("bracket,steel", jobItem.AiTags);
|
||
|
||
var coat = Assert.Single(coats);
|
||
Assert.Equal("Gloss Black", coat.ColorName);
|
||
Assert.Equal("RAL9005", coat.ColorCode);
|
||
Assert.Equal("Gloss", coat.Finish);
|
||
Assert.Equal(1.88m, coat.PowderToOrder);
|
||
|
||
var prepService = Assert.Single(prepServices);
|
||
Assert.Equal(41, prepService.BlastSetupId);
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromQuoteItem_UsesStoredPowderToOrderWhenPresent()
|
||
{
|
||
var quoteItem = new QuoteItem
|
||
{
|
||
Description = "Wheel",
|
||
Quantity = 4m,
|
||
SurfaceAreaSqFt = 15m,
|
||
Coats =
|
||
[
|
||
new QuoteItemCoat
|
||
{
|
||
CoatName = "Primer",
|
||
Sequence = 1,
|
||
CoverageSqFtPerLb = 30m,
|
||
TransferEfficiency = 65m,
|
||
PowderToOrder = 9.5m
|
||
}
|
||
]
|
||
};
|
||
|
||
var coat = Assert.Single(_service.CreateJobItemCoats(quoteItem, jobItemId: 5, companyId: 1, CreatedAtUtc));
|
||
|
||
Assert.Equal(9.5m, coat.PowderToOrder);
|
||
}
|
||
|
||
// ─── IsAiItem propagation tests ──────────────────────────────────────────────
|
||
// AI items use ManualUnitPrice as-is and are excluded from quote-level oven cost
|
||
// (the pricing engine assumes oven is already baked into the AI estimate).
|
||
// IsAiItem MUST survive every conversion path or the job will be mispriced.
|
||
|
||
[Fact]
|
||
public void PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem()
|
||
{
|
||
// These bool flags are read by PricingCalculationService to route items to the
|
||
// correct pricing path. They MUST exist on both QuoteItem and JobItem, and MUST
|
||
// be mapped by JobItemAssemblyService in all three overloads.
|
||
//
|
||
// If this test fails: you added a pricing flag to one entity but not the other.
|
||
// Fix: add the field to both entities, add it to JobItemSeed, map it in all three
|
||
// CreateJobItem overloads, and add it to the known list below.
|
||
var requiredPricingFlags = new[]
|
||
{
|
||
nameof(QuoteItem.IsGenericItem),
|
||
nameof(QuoteItem.IsLaborItem),
|
||
nameof(QuoteItem.IsSalesItem),
|
||
nameof(QuoteItem.IsAiItem),
|
||
};
|
||
|
||
foreach (var flag in requiredPricingFlags)
|
||
{
|
||
Assert.True(typeof(QuoteItem).GetProperty(flag) != null,
|
||
$"QuoteItem is missing pricing flag '{flag}' — add it or remove it from this list.");
|
||
Assert.True(typeof(JobItem).GetProperty(flag) != null,
|
||
$"JobItem is missing pricing flag '{flag}' — add it to JobItem and map it in JobItemAssemblyService.");
|
||
Assert.True(typeof(CreateQuoteItemDto).GetProperty(flag) != null,
|
||
$"CreateQuoteItemDto is missing pricing flag '{flag}' — add it to the DTO and include it in all existingItemsData JSON blocks.");
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromDto_PreservesIsAiItemFlag()
|
||
{
|
||
var source = new CreateQuoteItemDto
|
||
{
|
||
Description = "AI Photo Item",
|
||
Quantity = 1m,
|
||
SurfaceAreaSqFt = 20m,
|
||
EstimatedMinutes = 45,
|
||
IsAiItem = true,
|
||
ManualUnitPrice = 500m,
|
||
Complexity = "Moderate",
|
||
Coats = [new CreateQuoteItemCoatDto { CoatName = "Coat 1", Sequence = 1 }]
|
||
};
|
||
var pricing = new QuoteItemPricingResult { UnitPrice = 500m, TotalPrice = 500m };
|
||
|
||
var jobItem = _service.CreateJobItem(source, jobId: 1, companyId: 1, pricing: pricing, createdAtUtc: CreatedAtUtc);
|
||
|
||
Assert.True(jobItem.IsAiItem,
|
||
"IsAiItem must survive DTO → JobItem conversion. Without it, saved AI jobs are repriced as " +
|
||
"calculated items on next edit and oven cost is double-charged.");
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromQuoteItem_PreservesIsAiItemFlag()
|
||
{
|
||
var quoteItem = new QuoteItem
|
||
{
|
||
Description = "AI Photo Item",
|
||
Quantity = 1m,
|
||
SurfaceAreaSqFt = 20m,
|
||
IsAiItem = true,
|
||
ManualUnitPrice = 500m,
|
||
UnitPrice = 500m,
|
||
TotalPrice = 500m,
|
||
Coats = [new QuoteItemCoat { CoatName = "Coat 1", Sequence = 1 }]
|
||
};
|
||
|
||
var jobItem = _service.CreateJobItem(quoteItem, jobId: 1, companyId: 1, createdAtUtc: CreatedAtUtc);
|
||
|
||
Assert.True(jobItem.IsAiItem,
|
||
"IsAiItem must survive QuoteItem → JobItem conversion (quote-approval / CreateJobFromQuote path).");
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromExistingJobItem_PreservesIsAiItemFlag()
|
||
{
|
||
var source = new JobItem
|
||
{
|
||
Description = "AI Photo Item",
|
||
Quantity = 1m,
|
||
SurfaceAreaSqFt = 20m,
|
||
IsAiItem = true,
|
||
ManualUnitPrice = 500m,
|
||
UnitPrice = 500m,
|
||
TotalPrice = 500m,
|
||
LaborCost = 200m,
|
||
Coats = [new JobItemCoat { CoatName = "Coat 1", Sequence = 1 }]
|
||
};
|
||
|
||
var jobItem = _service.CreateJobItem(source, jobId: 1, companyId: 1, createdAtUtc: CreatedAtUtc);
|
||
|
||
Assert.True(jobItem.IsAiItem,
|
||
"IsAiItem must survive JobItem → JobItem copy (rework path).");
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateJobItem_FromExistingJobItem_PreservesTransferableShapeForRework()
|
||
{
|
||
var source = new JobItem
|
||
{
|
||
Description = "Gate panel",
|
||
Quantity = 1m,
|
||
ColorName = "Bronze",
|
||
ColorCode = "BZ-22",
|
||
Finish = "Textured",
|
||
SurfaceArea = 22m,
|
||
SurfaceAreaSqFt = 22m,
|
||
CatalogItemId = 8,
|
||
IsGenericItem = false,
|
||
IsLaborItem = false,
|
||
IsSalesItem = true,
|
||
Sku = "GATE-BRZ",
|
||
ManualUnitPrice = 140m,
|
||
PowderCostOverride = 9m,
|
||
UnitPrice = 140m,
|
||
TotalPrice = 140m,
|
||
LaborCost = 56m,
|
||
RequiresSandblasting = true,
|
||
RequiresMasking = true,
|
||
EstimatedMinutes = 90,
|
||
Notes = "Rework copy",
|
||
IncludePrepCost = false,
|
||
Complexity = "Extreme",
|
||
AiTags = "gate,outdoor",
|
||
AiPredictionId = 12,
|
||
Coats =
|
||
[
|
||
new JobItemCoat
|
||
{
|
||
CoatName = "Top Coat",
|
||
Sequence = 1,
|
||
InventoryItemId = 21,
|
||
ColorName = "Bronze",
|
||
VendorId = 13,
|
||
ColorCode = "BZ-22",
|
||
Finish = "Textured",
|
||
CoverageSqFtPerLb = 26m,
|
||
TransferEfficiency = 70m,
|
||
PowderCostPerLb = 8m,
|
||
PowderToOrder = 2.75m,
|
||
Notes = "Keep order qty"
|
||
}
|
||
],
|
||
PrepServices =
|
||
[
|
||
new JobItemPrepService
|
||
{
|
||
PrepServiceId = 3,
|
||
EstimatedMinutes = 45,
|
||
BlastSetupId = 77
|
||
}
|
||
]
|
||
};
|
||
|
||
var jobItem = _service.CreateJobItem(source, jobId: 222, companyId: 9, createdAtUtc: CreatedAtUtc);
|
||
var coats = _service.CreateJobItemCoats(source, jobItemId: 333, companyId: 9, CreatedAtUtc);
|
||
var prepServices = _service.CreateJobItemPrepServices(source, jobItemId: 333, companyId: 9, CreatedAtUtc);
|
||
|
||
Assert.Equal(222, jobItem.JobId);
|
||
Assert.Equal("Bronze", jobItem.ColorName);
|
||
Assert.True(jobItem.IsSalesItem);
|
||
Assert.Equal("GATE-BRZ", jobItem.Sku);
|
||
Assert.False(jobItem.IncludePrepCost);
|
||
Assert.Equal(56m, jobItem.LaborCost);
|
||
Assert.Equal(12, jobItem.AiPredictionId);
|
||
|
||
var coat = Assert.Single(coats);
|
||
Assert.Equal(2.75m, coat.PowderToOrder);
|
||
Assert.Equal("Bronze", coat.ColorName);
|
||
|
||
var prepService = Assert.Single(prepServices);
|
||
Assert.Equal(77, prepService.BlastSetupId);
|
||
Assert.Equal(45, prepService.EstimatedMinutes);
|
||
}
|
||
}
|