Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/JobItemAssemblyServiceTests.cs
T
spouliot 6721de91e4 Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- 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>
2026-05-15 15:03:06 -04:00

379 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}