Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs
T
spouliot 80b0e547cc Phase 1: Introduce typed repository interfaces and report service stubs
Six IUnitOfWork properties upgraded from generic IRepository<T> to domain-specific
typed interfaces (IJobRepository, IQuoteRepository, IInvoiceRepository,
ICustomerRepository, IBillRepository, IPurchaseOrderRepository). Each backed by a
concrete typed repository that encapsulates complex include chains previously
inlined in controllers.

Also adds IFinancialReportService and IOperationalReportService stub implementations
(NotImplementedException placeholders) to Application.Interfaces and Infrastructure.Services,
registered in Program.cs. These are the migration targets for ReportsController's
aggregate query methods in Phase 2.

No controller behaviour changed in this commit — all callers still compile because
typed interfaces extend IRepository<T>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:54:10 -04:00

678 lines
24 KiB
C#

using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Repositories;
using Xunit;
namespace PowderCoating.UnitTests;
public class PricingCalculationServiceTests
{
[Fact]
public async Task CalculateCoatPriceAsync_CustomPowder_ChargesFullOrderQuantity()
{
var costs = CreateOperatingCosts();
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Custom Red",
PowderCostPerLb = 10m,
PowderToOrder = 3m,
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 5m,
quantity: 2m,
coatIndex: 0,
estimatedMinutesBase: 15,
companyId: 1);
Assert.Equal(30m, result.CoatMaterialCost);
Assert.Equal(30m, result.CoatLaborCost);
Assert.Equal(60m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_LaborItem_UsesStandardLaborRate()
{
var costs = CreateOperatingCosts();
costs.StandardLaborRate = 80m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Shop labor",
IsLaborItem = true,
Quantity = 2.5m
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(0m, result.MaterialCost);
Assert.Equal(200m, result.LaborCost);
Assert.Equal(80m, result.UnitPrice);
Assert.Equal(200m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_AiItem_UsesManualUnitPriceWithoutAdditionalCosts()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "AI wheel estimate",
IsAiItem = true,
ManualUnitPrice = 123m,
Quantity = 2m
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(0m, result.MaterialCost);
Assert.Equal(0m, result.LaborCost);
Assert.Equal(123m, result.UnitPrice);
Assert.Equal(246m, result.TotalPrice);
}
[Fact]
public async Task CalculateCoatPriceAsync_InventoryPowder_UsesCalculatedUsageOnly()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
inventoryItem: new InventoryItem
{
Id = 12,
CompanyId = 1,
Name = "Gloss Black",
UnitCost = 12m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Gloss Black",
InventoryItemId = 12,
CoverageSqFtPerLb = 24m,
TransferEfficiency = 50m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 10m,
quantity: 2m,
coatIndex: 0,
estimatedMinutesBase: 30,
companyId: 1);
Assert.Equal(20m, result.CoatMaterialCost, 2);
Assert.Equal(60m, result.CoatLaborCost);
Assert.Equal(80m, result.CoatTotalCost, 2);
}
[Fact]
public async Task CalculateCoatPriceAsync_MetricTenant_ConvertsSurfaceAreaBeforePricing()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(true);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Metric Blue",
PowderCostPerLb = 5m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 1m,
quantity: 1m,
coatIndex: 0,
estimatedMinutesBase: 0,
companyId: 1);
Assert.Equal(5.38m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(5.38m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateCoatPriceAsync_AdditionalCoatWithNoExtraLayerCharge_SkipsLabor()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Clear Coat",
PowderCostPerLb = 4m,
PowderToOrder = 1m,
CoverageSqFtPerLb = 20m,
TransferEfficiency = 100m,
NoExtraLayerCharge = true
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 0m,
quantity: 2m,
coatIndex: 1,
estimatedMinutesBase: 45,
companyId: 1);
Assert.Equal(4m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(4m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateCoatPriceAsync_WhenOperatingCostsMissing_ReturnsZeros()
{
var unitOfWork = CreateUnitOfWorkMock(costs: null);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var result = await service.CalculateCoatPriceAsync(
new CreateQuoteItemCoatDto { CoatName = "Unpriced" },
itemSurfaceAreaSqFt: 10m,
quantity: 1m,
coatIndex: 0,
estimatedMinutesBase: 30,
companyId: 1);
Assert.Equal(0m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(0m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_CatalogItem_UsesPowderCostOverrideAsBasePrice()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
catalogItem: new CatalogItem
{
Id = 50,
CompanyId = 1,
Name = "Wheel",
DefaultPrice = 10m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Override catalog item",
CatalogItemId = 50,
PowderCostOverride = 77m,
Quantity = 3m
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(0m, result.MaterialCost);
Assert.Equal(0m, result.LaborCost);
Assert.Equal(77m, result.UnitPrice);
Assert.Equal(231m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_CatalogItem_AddsPrepCostAndCustomPowder()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
catalogItem: new CatalogItem
{
Id = 51,
CompanyId = 1,
Name = "Bracket",
DefaultPrice = 50m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Bracket with prep",
CatalogItemId = 51,
Quantity = 2m,
IncludePrepCost = true,
PrepServices = new List<CreateQuoteItemPrepServiceDto>
{
new() { PrepServiceId = 1, EstimatedMinutes = 30 }
},
Coats = new List<CreateQuoteItemCoatDto>
{
new()
{
CoatName = "Custom Green",
PowderCostPerLb = 5m,
PowderToOrder = 2m
}
}
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10m, result.MaterialCost);
Assert.Equal(30m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(70m, result.UnitPrice);
Assert.Equal(140m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_MarginMode_AppliesAdditionalCoatAndComplexity()
{
var costs = CreateOperatingCosts();
costs.PricingMode = PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost;
costs.TargetMarginPercent = 50m;
costs.CoatingBoothCostPerHour = 0m;
costs.ComplexityModeratePercent = 5m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Complex fabricated part",
Quantity = 1m,
SurfaceAreaSqFt = 10m,
EstimatedMinutes = 60,
Complexity = "Moderate",
Coats = new List<CreateQuoteItemCoatDto>
{
new()
{
CoatName = "Base",
PowderCostPerLb = 10m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
},
new()
{
CoatName = "Top",
PowderCostPerLb = 10m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
}
}
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10.5m, result.MaterialCost);
Assert.Equal(60m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(222.075m, result.ItemSubtotal);
Assert.Equal(222.075m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_MixedAiAndManualItems_ScalesOvenCostBySurfaceAreaAndUsesManualTax()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 30m;
costs.DefaultOvenCycleMinutes = 60;
costs.ShopSuppliesRate = 0m;
costs.TaxPercent = 5m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "AI estimate",
IsAiItem = true,
ManualUnitPrice = 200m,
Quantity = 1m,
SurfaceAreaSqFt = 50m
},
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 1m,
SurfaceAreaSqFt = 50m,
EstimatedMinutes = 60
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
manualTaxPercent: 8m);
Assert.Equal(260m, result.ItemsSubtotal);
Assert.Equal(15m, result.OvenBatchCost);
Assert.Equal(275m, result.SubtotalBeforeDiscount);
Assert.Equal(8m, result.TaxPercent);
Assert.Equal(22m, result.TaxAmount);
Assert.Equal(297m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_ZeroSurfaceAreaFallback_UsesItemCountForOvenFraction()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 20m;
costs.DefaultOvenCycleMinutes = 60;
costs.ShopSuppliesRate = 0m;
costs.TaxPercent = 0m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "AI item",
IsAiItem = true,
ManualUnitPrice = 100m,
Quantity = 1m
},
new()
{
Description = "Shelf item",
IsSalesItem = true,
ManualUnitPrice = 40m,
Quantity = 1m
}
};
var result = await service.CalculateQuoteTotalsAsync(items, companyId: 1);
Assert.Equal(140m, result.ItemsSubtotal);
Assert.Equal(10m, result.OvenBatchCost);
Assert.Equal(150m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_FixedRushAndFacilityOverhead_AreAppliedBeforeTotal()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 0m;
costs.MonthlyRent = 1600m;
costs.MonthlyUtilities = 0m;
costs.MonthlyBillableHours = 160;
costs.ShopSuppliesRate = 10m;
costs.RushChargeType = "FixedAmount";
costs.RushChargeFixedAmount = 25m;
costs.TaxPercent = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 2m,
EstimatedMinutes = 60
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
isRushJob: true);
Assert.Equal(120m, result.ItemsSubtotal);
Assert.Equal(10m, result.FacilityOverheadRatePerHour);
Assert.Equal(20m, result.FacilityOverheadCost);
Assert.Equal(12m, result.ShopSuppliesAmount);
Assert.Equal(152m, result.SubtotalBeforeDiscount);
Assert.Equal(25m, result.RushFee);
Assert.Equal(177m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_AppliesTierDiscount_QuoteDiscount_RushFee_AndTax()
{
var costs = CreateOperatingCosts();
costs.StandardLaborRate = 100m;
costs.ShopSuppliesRate = 10m;
costs.RushChargeType = "Percentage";
costs.RushChargePercentage = 20m;
costs.TaxPercent = 5m;
costs.OvenOperatingCostPerHour = 0m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var customerRepo = new Mock<ICustomerRepository>();
customerRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
.ReturnsAsync(new[]
{
new Customer { Id = 1, CompanyId = 1, PricingTierId = 10 }
});
var pricingTierRepo = new Mock<IRepository<PricingTier>>();
pricingTierRepo
.Setup(x => x.GetByIdAsync(10, false, It.IsAny<System.Linq.Expressions.Expression<Func<PricingTier, object>>[]>()))
.ReturnsAsync(new PricingTier { Id = 10, CompanyId = 1, DiscountPercent = 10m });
var unitOfWork = CreateUnitOfWorkMock(costs);
unitOfWork.SetupGet(x => x.Customers).Returns(customerRepo.Object);
unitOfWork.SetupGet(x => x.PricingTiers).Returns(pricingTierRepo.Object);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 2m
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
customerId: 1,
discountType: "FixedAmount",
discountValue: 5m,
isRushJob: true);
Assert.Equal(200m, result.ItemsSubtotal);
Assert.Equal(20m, result.ShopSuppliesAmount);
Assert.Equal(220m, result.SubtotalBeforeDiscount);
Assert.Equal(22m, result.PricingTierDiscountAmount);
Assert.Equal(5m, result.QuoteDiscountAmount);
Assert.Equal(193m, result.SubtotalAfterDiscount);
Assert.Equal(38.6m, result.RushFee);
Assert.Equal(11.58m, result.TaxAmount);
Assert.Equal(243.18m, result.Total);
}
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(
CompanyOperatingCosts? costs,
InventoryItem? inventoryItem = null,
CatalogItem? catalogItem = null)
{
var unitOfWork = new Mock<IUnitOfWork>();
var companyOperatingCostsRepo = new Mock<IRepository<CompanyOperatingCosts>>();
companyOperatingCostsRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, object>>[]>()))
.ReturnsAsync(costs != null ? new[] { costs } : Array.Empty<CompanyOperatingCosts>());
var inventoryRepo = new Mock<IRepository<InventoryItem>>();
inventoryRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<InventoryItem, object>>[]>()))
.ReturnsAsync(inventoryItem);
var catalogRepo = new Mock<IRepository<CatalogItem>>();
catalogRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
.ReturnsAsync(catalogItem);
var customerRepo = new Mock<ICustomerRepository>();
customerRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
.ReturnsAsync(Array.Empty<Customer>());
var pricingTierRepo = new Mock<IRepository<PricingTier>>();
pricingTierRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<PricingTier, object>>[]>()))
.ReturnsAsync((PricingTier?)null);
unitOfWork.SetupGet(x => x.CompanyOperatingCosts).Returns(companyOperatingCostsRepo.Object);
unitOfWork.SetupGet(x => x.InventoryItems).Returns(inventoryRepo.Object);
unitOfWork.SetupGet(x => x.CatalogItems).Returns(catalogRepo.Object);
unitOfWork.SetupGet(x => x.Customers).Returns(customerRepo.Object);
unitOfWork.SetupGet(x => x.PricingTiers).Returns(pricingTierRepo.Object);
return unitOfWork;
}
private static CompanyOperatingCosts CreateOperatingCosts()
{
return new CompanyOperatingCosts
{
Id = 1,
CompanyId = 1,
StandardLaborRate = 60m,
AdditionalCoatLaborPercent = 50m,
OvenOperatingCostPerHour = 25m,
SandblasterCostPerHour = 20m,
CoatingBoothCostPerHour = 10m,
PowderCoatingCostPerSqFt = 1m,
PricingMode = PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial,
GeneralMarkupPercentage = 20m,
TargetMarginPercent = 40m,
TaxPercent = 5m,
ShopSuppliesRate = 10m,
DefaultOvenCycleMinutes = 60,
RushChargeType = "Percentage",
RushChargePercentage = 15m,
RushChargeFixedAmount = 50m,
ShopMinimumCharge = 0m,
ComplexitySimplePercent = 0m,
ComplexityModeratePercent = 5m,
ComplexityComplexPercent = 15m,
ComplexityExtremePercent = 25m,
MonthlyBillableHours = 160
};
}
}