edd7389d7d
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item and quote pricing construction that was duplicated across create, rework copy, and quote-to-job conversion paths - BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by 6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment, Catalog) and BillsController + ExpensesController, removing 8 private copies - PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11 controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs, Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors) - AccountingDropdownHelper: single LoadAsync() call replaces duplicate vendor/account/job queries in BillsController and ExpensesController - JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate through JobTemplatesController snapshot copy and GetTemplatesJson projection, and JobsController template-application path - Test assertions updated for standardized BlobFileHelper error messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
332 lines
11 KiB
C#
332 lines
11 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using PowderCoating.Application.DTOs.Quote;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Application.Services;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Infrastructure.Repositories;
|
|
|
|
namespace PowderCoating.UnitTests;
|
|
|
|
public class QuotePricingAssemblyServiceTests
|
|
{
|
|
[Fact]
|
|
public void ApplyPricingSnapshot_CopiesAllTotalsToQuote()
|
|
{
|
|
var service = CreateService(CreateContext(), Mock.Of<IPricingCalculationService>());
|
|
var quote = new Quote();
|
|
var pricing = new QuotePricingResult
|
|
{
|
|
MaterialCosts = 10m,
|
|
LaborCosts = 20m,
|
|
EquipmentCosts = 30m,
|
|
ItemsSubtotal = 40m,
|
|
OvenBatchCost = 50m,
|
|
ShopSuppliesAmount = 60m,
|
|
ShopSuppliesPercent = 7m,
|
|
OverheadCosts = 80m,
|
|
OverheadPercent = 9m,
|
|
ProfitMargin = 100m,
|
|
ProfitPercent = 11m,
|
|
SubtotalBeforeDiscount = 120m,
|
|
DiscountPercent = 13m,
|
|
DiscountAmount = 14m,
|
|
RushFee = 15m,
|
|
TaxAmount = 16m,
|
|
Total = 17m
|
|
};
|
|
|
|
service.ApplyPricingSnapshot(quote, pricing);
|
|
|
|
Assert.Equal(10m, quote.MaterialCosts);
|
|
Assert.Equal(20m, quote.LaborCosts);
|
|
Assert.Equal(30m, quote.EquipmentCosts);
|
|
Assert.Equal(40m, quote.ItemsSubtotal);
|
|
Assert.Equal(50m, quote.OvenBatchCost);
|
|
Assert.Equal(60m, quote.ShopSuppliesAmount);
|
|
Assert.Equal(7m, quote.ShopSuppliesPercent);
|
|
Assert.Equal(80m, quote.OverheadAmount);
|
|
Assert.Equal(9m, quote.OverheadPercent);
|
|
Assert.Equal(100m, quote.ProfitMargin);
|
|
Assert.Equal(11m, quote.ProfitPercent);
|
|
Assert.Equal(120m, quote.SubTotal);
|
|
Assert.Equal(13m, quote.DiscountPercent);
|
|
Assert.Equal(14m, quote.DiscountAmount);
|
|
Assert.Equal(15m, quote.RushFee);
|
|
Assert.Equal(16m, quote.TaxAmount);
|
|
Assert.Equal(17m, quote.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateQuoteItemsAsync_PreservesManualAndCalculatedPricingPaths()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.AiItemPredictions.Add(new AiItemPrediction
|
|
{
|
|
Id = 91,
|
|
CompanyId = 1,
|
|
PredictedSurfaceAreaSqFt = 4m,
|
|
PredictedUnitPrice = 100m,
|
|
PredictedMinutes = 15,
|
|
PredictedComplexity = "Moderate",
|
|
Confidence = "High"
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var pricingService = new Mock<IPricingCalculationService>();
|
|
pricingService
|
|
.Setup(x => x.CalculateQuoteItemPriceAsync(
|
|
It.Is<CreateQuoteItemDto>(i => i.Description == "Custom frame"),
|
|
1,
|
|
null))
|
|
.ReturnsAsync(new QuoteItemPricingResult
|
|
{
|
|
UnitPrice = 77m,
|
|
TotalPrice = 154m,
|
|
MaterialCost = 22m,
|
|
LaborCost = 33m,
|
|
EquipmentCost = 11m
|
|
});
|
|
pricingService
|
|
.Setup(x => x.CalculateCoatPriceAsync(
|
|
It.IsAny<CreateQuoteItemCoatDto>(),
|
|
12m,
|
|
2m,
|
|
0,
|
|
25,
|
|
1))
|
|
.ReturnsAsync(new QuoteItemCoatPricingResult
|
|
{
|
|
CoatMaterialCost = 5m,
|
|
CoatLaborCost = 6m,
|
|
CoatTotalCost = 11m
|
|
});
|
|
|
|
var service = CreateService(context, pricingService.Object);
|
|
|
|
var items = await service.CreateQuoteItemsAsync(
|
|
[
|
|
new CreateQuoteItemDto
|
|
{
|
|
Description = "AI wheel",
|
|
Quantity = 2m,
|
|
SurfaceAreaSqFt = 5m,
|
|
EstimatedMinutes = 20,
|
|
IsAiItem = true,
|
|
ManualUnitPrice = 123m,
|
|
AiPredictionId = 91
|
|
},
|
|
new CreateQuoteItemDto
|
|
{
|
|
Description = "Shop tumbler",
|
|
Quantity = 3m,
|
|
IsSalesItem = true,
|
|
Sku = "TMB-20",
|
|
ManualUnitPrice = 18m,
|
|
IncludePrepCost = false
|
|
},
|
|
new CreateQuoteItemDto
|
|
{
|
|
Description = "Custom frame",
|
|
Quantity = 2m,
|
|
SurfaceAreaSqFt = 12m,
|
|
EstimatedMinutes = 25,
|
|
RequiresSandblasting = true,
|
|
Notes = "Calculated path",
|
|
Coats =
|
|
[
|
|
new CreateQuoteItemCoatDto
|
|
{
|
|
CoatName = "Top Coat",
|
|
Sequence = 1,
|
|
ColorName = "Black",
|
|
CoverageSqFtPerLb = 30m,
|
|
TransferEfficiency = 65m
|
|
}
|
|
],
|
|
PrepServices =
|
|
[
|
|
new CreateQuoteItemPrepServiceDto
|
|
{
|
|
PrepServiceId = 7,
|
|
EstimatedMinutes = 15,
|
|
BlastSetupId = 44
|
|
}
|
|
]
|
|
}
|
|
],
|
|
quoteId: 55,
|
|
companyId: 1,
|
|
ovenRateOverride: null,
|
|
createdAtUtc: new DateTime(2026, 5, 9, 15, 0, 0, DateTimeKind.Utc));
|
|
|
|
Assert.Equal(3, items.Count);
|
|
|
|
var aiItem = items.Single(i => i.Description == "AI wheel");
|
|
Assert.Equal(123m, aiItem.UnitPrice);
|
|
Assert.Equal(246m, aiItem.TotalPrice);
|
|
|
|
var salesItem = items.Single(i => i.Description == "Shop tumbler");
|
|
Assert.True(salesItem.IsSalesItem);
|
|
Assert.Equal("TMB-20", salesItem.Sku);
|
|
Assert.False(salesItem.IncludePrepCost);
|
|
Assert.Equal(18m, salesItem.UnitPrice);
|
|
Assert.Equal(54m, salesItem.TotalPrice);
|
|
|
|
var customItem = items.Single(i => i.Description == "Custom frame");
|
|
Assert.Equal(77m, customItem.UnitPrice);
|
|
Assert.Equal(154m, customItem.TotalPrice);
|
|
Assert.Equal(22m, customItem.ItemMaterialCost);
|
|
Assert.Equal(33m, customItem.ItemLaborCost);
|
|
Assert.Equal(11m, customItem.ItemEquipmentCost);
|
|
var customPrep = Assert.Single(customItem.PrepServices);
|
|
Assert.Equal(44, customPrep.BlastSetupId);
|
|
var customCoat = Assert.Single(customItem.Coats);
|
|
Assert.Equal(11m, customCoat.CoatTotalCost);
|
|
|
|
var prediction = await context.AiItemPredictions.SingleAsync();
|
|
Assert.True(prediction.UserOverrodeEstimate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateQuoteItemsAsync_CatalogItemWithoutCoats_UsesCatalogDefaultPrice()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.CatalogItems.Add(new CatalogItem
|
|
{
|
|
Id = 22,
|
|
CompanyId = 1,
|
|
Name = "Gate Hinge",
|
|
DefaultPrice = 42.5m
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var service = CreateService(context, Mock.Of<IPricingCalculationService>());
|
|
|
|
var item = Assert.Single(await service.CreateQuoteItemsAsync(
|
|
[
|
|
new CreateQuoteItemDto
|
|
{
|
|
Description = "Catalog hinge",
|
|
Quantity = 4m,
|
|
CatalogItemId = 22
|
|
}
|
|
],
|
|
quoteId: 1,
|
|
companyId: 1,
|
|
ovenRateOverride: null,
|
|
createdAtUtc: DateTime.UtcNow));
|
|
|
|
Assert.Equal(42.5m, item.UnitPrice);
|
|
Assert.Equal(170m, item.TotalPrice);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateQuoteItemsAsync_AddAsIncoming_CreatesInventoryItemAndLinksCoat()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.Set<PowderCatalogItem>().Add(new PowderCatalogItem
|
|
{
|
|
Id = 5,
|
|
VendorName = "Prismatic Powders",
|
|
Sku = "P-1001",
|
|
ColorName = "Candy Red",
|
|
UnitPrice = 19.5m,
|
|
CoverageSqFtPerLb = 85m,
|
|
TransferEfficiency = 70m
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var pricingService = new Mock<IPricingCalculationService>();
|
|
pricingService
|
|
.Setup(x => x.CalculateQuoteItemPriceAsync(It.IsAny<CreateQuoteItemDto>(), 1, null))
|
|
.ReturnsAsync(new QuoteItemPricingResult
|
|
{
|
|
UnitPrice = 50m,
|
|
TotalPrice = 50m,
|
|
MaterialCost = 10m,
|
|
LaborCost = 20m,
|
|
EquipmentCost = 5m
|
|
});
|
|
pricingService
|
|
.Setup(x => x.CalculateCoatPriceAsync(It.IsAny<CreateQuoteItemCoatDto>(), 6m, 1m, 0, 10, 1))
|
|
.ReturnsAsync(new QuoteItemCoatPricingResult
|
|
{
|
|
CoatMaterialCost = 3m,
|
|
CoatLaborCost = 4m,
|
|
CoatTotalCost = 7m
|
|
});
|
|
|
|
var service = CreateService(context, pricingService.Object);
|
|
var dto = new CreateQuoteItemDto
|
|
{
|
|
Description = "Incoming powder item",
|
|
Quantity = 1m,
|
|
SurfaceAreaSqFt = 6m,
|
|
EstimatedMinutes = 10,
|
|
Coats =
|
|
[
|
|
new CreateQuoteItemCoatDto
|
|
{
|
|
CoatName = "Base",
|
|
Sequence = 1,
|
|
CatalogItemId = 5,
|
|
AddAsIncoming = true,
|
|
PowderCostPerLb = 22m
|
|
}
|
|
]
|
|
};
|
|
|
|
var item = Assert.Single(await service.CreateQuoteItemsAsync(
|
|
[dto],
|
|
quoteId: 9,
|
|
companyId: 1,
|
|
ovenRateOverride: null,
|
|
createdAtUtc: DateTime.UtcNow));
|
|
|
|
var inventoryItem = await context.InventoryItems.SingleAsync();
|
|
var coat = Assert.Single(item.Coats);
|
|
Assert.Equal(inventoryItem.Id, coat.InventoryItemId);
|
|
Assert.True(inventoryItem.IsIncoming);
|
|
Assert.Null(dto.Coats[0].PowderCostPerLb);
|
|
}
|
|
|
|
private static QuotePricingAssemblyService CreateService(ApplicationDbContext context, IPricingCalculationService pricingService)
|
|
{
|
|
return new QuotePricingAssemblyService(
|
|
new UnitOfWork(context),
|
|
pricingService,
|
|
Mock.Of<IInventoryAiLookupService>(),
|
|
Mock.Of<ILogger<QuotePricingAssemblyService>>());
|
|
}
|
|
|
|
private static ApplicationDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
var identity = new ClaimsIdentity(
|
|
[new Claim(ClaimTypes.Role, "SuperAdmin")],
|
|
"Test");
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
byte[]? noBytes = null;
|
|
var sessionMock = new Mock<ISession>();
|
|
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
|
|
|
var httpContextMock = new Mock<HttpContext>();
|
|
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
|
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
|
|
|
var accessor = new Mock<IHttpContextAccessor>();
|
|
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
|
|
|
return new ApplicationDbContext(options, accessor.Object, null!);
|
|
}
|
|
}
|