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); } }