Refactor: extract shared helpers, fix field drift, add assembly services
- 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>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public class JobPhotoServiceTests
|
||||
var result = await service.SaveJobPhotoAsync(null!, 1, 2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("No file was uploaded.", result.ErrorMessage);
|
||||
Assert.Equal("No file provided.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -31,7 +31,7 @@ public class JobPhotoServiceTests
|
||||
var result = await service.SaveJobPhotoAsync(file, 1, 2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Photo must be smaller than 10 MB.", result.ErrorMessage);
|
||||
Assert.Equal("File exceeds the 10 MB limit.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,7 +43,7 @@ public class JobPhotoServiceTests
|
||||
var result = await service.SaveJobPhotoAsync(file, 1, 2);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Only JPG, PNG, GIF, and WebP images are allowed.", result.ErrorMessage);
|
||||
Assert.Equal("File type not allowed. Allowed: .jpg, .jpeg, .png, .gif, .webp.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Job;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class QuoteAndReworkControllerFlowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateQuoteStatus_ApprovedQuote_CopiesItemLevelFieldsIntoCreatedJob()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteConversionData(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var quoteStatuses = await context.QuoteStatusLookups.OrderBy(s => s.Id).ToListAsync();
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache
|
||||
.Setup(x => x.GetQuoteStatusLookupsAsync(1))
|
||||
.ReturnsAsync(quoteStatuses);
|
||||
var tenantContext = CreateTenantContext();
|
||||
|
||||
var controller = new QuotesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<QuotesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
tenantContext.Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IQuotePricingAssemblyService>(),
|
||||
new ConfigurationBuilder().Build(),
|
||||
Mock.Of<IPlatformSettingsService>(),
|
||||
Mock.Of<IQuotePhotoService>(),
|
||||
Mock.Of<IAiQuoteService>(),
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
Mock.Of<IAiUsageLogger>(),
|
||||
Mock.Of<ICompanyLogoService>(),
|
||||
Mock.Of<IInventoryAiLookupService>());
|
||||
|
||||
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
|
||||
{
|
||||
QuoteId = 1,
|
||||
StatusId = 2
|
||||
});
|
||||
|
||||
Assert.IsType<JsonResult>(result);
|
||||
|
||||
var quote = await context.Quotes.SingleAsync();
|
||||
Assert.Equal(3, quote.QuoteStatusId);
|
||||
Assert.True(quote.ConvertedToJobId.HasValue);
|
||||
|
||||
var job = await context.Jobs.SingleAsync();
|
||||
Assert.Equal(quote.Id, job.QuoteId);
|
||||
|
||||
var jobItem = await context.JobItems.SingleAsync();
|
||||
Assert.True(jobItem.IsSalesItem);
|
||||
Assert.Equal("MUG-01", jobItem.Sku);
|
||||
Assert.False(jobItem.IncludePrepCost);
|
||||
|
||||
var jobCoat = await context.JobItemCoats.SingleAsync();
|
||||
Assert.Equal(1.5m, jobCoat.PowderToOrder);
|
||||
Assert.Equal(50, jobCoat.InventoryItemId);
|
||||
|
||||
var jobItemPrep = await context.JobItemPrepServices.SingleAsync();
|
||||
Assert.Equal(77, jobItemPrep.BlastSetupId);
|
||||
|
||||
var jobPrep = await context.JobPrepServices.SingleAsync();
|
||||
Assert.Equal(5, jobPrep.PrepServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddReworkRecord_CopiesFullItemShapeToReworkJob()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedReworkData(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache
|
||||
.Setup(x => x.GetJobStatusLookupsAsync(1))
|
||||
.ReturnsAsync(await context.JobStatusLookups.ToListAsync());
|
||||
lookupCache
|
||||
.Setup(x => x.GetJobPriorityLookupsAsync(1))
|
||||
.ReturnsAsync(await context.JobPriorityLookups.ToListAsync());
|
||||
var tenantContext = CreateTenantContext();
|
||||
|
||||
var mapper = new Mock<AutoMapper.IMapper>();
|
||||
mapper
|
||||
.Setup(x => x.Map<ReworkRecordDto>(It.IsAny<object>()))
|
||||
.Returns<object>(source =>
|
||||
{
|
||||
var record = Assert.IsType<ReworkRecord>(source);
|
||||
return new ReworkRecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
JobId = record.JobId,
|
||||
JobItemId = record.JobItemId,
|
||||
ReworkJobId = record.ReworkJobId
|
||||
};
|
||||
});
|
||||
|
||||
var controller = new JobsController(
|
||||
new UnitOfWork(context),
|
||||
mapper.Object,
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<JobsController>>(),
|
||||
tenantContext.Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IHubContext<NotificationHub>>(),
|
||||
Mock.Of<IHubContext<ShopHub>>());
|
||||
|
||||
var result = await controller.AddReworkRecord(new CreateReworkRecordDto
|
||||
{
|
||||
JobId = 1,
|
||||
JobItemId = 10,
|
||||
ReworkType = ReworkType.InternalDefect,
|
||||
Reason = ReworkReason.InsufficientCoverage,
|
||||
DefectDescription = "Thin coverage on one edge",
|
||||
DiscoveredBy = ReworkDiscoveredBy.Internal,
|
||||
DiscoveredDate = new DateTime(2026, 5, 9),
|
||||
EstimatedReworkCost = 65m
|
||||
});
|
||||
|
||||
Assert.IsType<JsonResult>(result);
|
||||
|
||||
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
|
||||
Assert.Equal(1, reworkJob.OriginalJobId);
|
||||
|
||||
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
|
||||
Assert.True(reworkItem.IsSalesItem);
|
||||
Assert.Equal("GATE-BRZ", reworkItem.Sku);
|
||||
Assert.False(reworkItem.IncludePrepCost);
|
||||
Assert.Equal(140m, reworkItem.UnitPrice);
|
||||
Assert.Equal(140m, reworkItem.TotalPrice);
|
||||
|
||||
var reworkCoat = await context.JobItemCoats.SingleAsync(c => c.JobItemId == reworkItem.Id);
|
||||
Assert.Equal(2.75m, reworkCoat.PowderToOrder);
|
||||
|
||||
var reworkPrep = await context.JobItemPrepServices.SingleAsync(p => p.JobItemId == reworkItem.Id);
|
||||
Assert.Equal(88, reworkPrep.BlastSetupId);
|
||||
}
|
||||
|
||||
private static void SeedQuoteConversionData(ApplicationDbContext context)
|
||||
{
|
||||
context.Customers.Add(new Customer
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
CompanyName = "Acme Fabrication"
|
||||
});
|
||||
|
||||
context.InventoryItems.Add(new InventoryItem
|
||||
{
|
||||
Id = 50,
|
||||
CompanyId = 1,
|
||||
SKU = "POW-1",
|
||||
Name = "Gloss Black",
|
||||
ColorCode = "RAL9005",
|
||||
Finish = "Gloss",
|
||||
Category = "Powder",
|
||||
UnitOfMeasure = "lbs"
|
||||
});
|
||||
|
||||
context.QuoteStatusLookups.AddRange(
|
||||
new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" },
|
||||
new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" },
|
||||
new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{
|
||||
Id = 10,
|
||||
CompanyId = 1,
|
||||
StatusCode = "APPROVED",
|
||||
DisplayName = "Approved"
|
||||
});
|
||||
|
||||
context.JobPriorityLookups.AddRange(
|
||||
new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" },
|
||||
new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" });
|
||||
|
||||
context.PrepServices.Add(new PrepService
|
||||
{
|
||||
Id = 5,
|
||||
CompanyId = 1,
|
||||
ServiceName = "Sandblasting",
|
||||
DisplayOrder = 1,
|
||||
IsActive = true,
|
||||
RequiresBlastSetup = true
|
||||
});
|
||||
|
||||
context.Quotes.Add(new Quote
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
QuoteNumber = "Q-1001",
|
||||
CustomerId = 1,
|
||||
QuoteStatusId = 1,
|
||||
Total = 50m,
|
||||
ShopSuppliesAmount = 2m,
|
||||
ShopSuppliesPercent = 4m
|
||||
});
|
||||
|
||||
context.QuoteItems.Add(new QuoteItem
|
||||
{
|
||||
Id = 100,
|
||||
QuoteId = 1,
|
||||
CompanyId = 1,
|
||||
Description = "Merch mug",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 10m,
|
||||
IsSalesItem = true,
|
||||
Sku = "MUG-01",
|
||||
UnitPrice = 25m,
|
||||
TotalPrice = 50m,
|
||||
IncludePrepCost = false,
|
||||
EstimatedMinutes = 12
|
||||
});
|
||||
|
||||
context.QuoteItemCoats.Add(new QuoteItemCoat
|
||||
{
|
||||
Id = 101,
|
||||
QuoteItemId = 100,
|
||||
CompanyId = 1,
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
InventoryItemId = 50,
|
||||
ColorName = "Old Name",
|
||||
ColorCode = "OLD",
|
||||
Finish = "Old",
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
PowderToOrder = 1.5m
|
||||
});
|
||||
|
||||
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
|
||||
{
|
||||
Id = 102,
|
||||
QuoteItemId = 100,
|
||||
CompanyId = 1,
|
||||
PrepServiceId = 5,
|
||||
EstimatedMinutes = 9,
|
||||
BlastSetupId = 77
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedReworkData(ApplicationDbContext context)
|
||||
{
|
||||
context.Customers.Add(new Customer
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
CompanyName = "Acme Fabrication"
|
||||
});
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
StatusCode = "PENDING",
|
||||
DisplayName = "Pending"
|
||||
});
|
||||
|
||||
context.JobPriorityLookups.Add(new JobPriorityLookup
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 1,
|
||||
PriorityCode = "NORMAL",
|
||||
DisplayName = "Normal"
|
||||
});
|
||||
|
||||
context.Jobs.Add(new Job
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
JobNumber = "JOB-2605-0001",
|
||||
Description = "Original gate job",
|
||||
CustomerId = 1,
|
||||
JobStatusId = 1,
|
||||
JobPriorityId = 2
|
||||
});
|
||||
|
||||
context.JobItems.Add(new JobItem
|
||||
{
|
||||
Id = 10,
|
||||
JobId = 1,
|
||||
CompanyId = 1,
|
||||
Description = "Gate panel",
|
||||
Quantity = 1m,
|
||||
SurfaceArea = 22m,
|
||||
SurfaceAreaSqFt = 22m,
|
||||
CatalogItemId = 8,
|
||||
IsSalesItem = true,
|
||||
Sku = "GATE-BRZ",
|
||||
UnitPrice = 140m,
|
||||
TotalPrice = 140m,
|
||||
LaborCost = 56m,
|
||||
IncludePrepCost = false,
|
||||
EstimatedMinutes = 90,
|
||||
ColorName = "Bronze",
|
||||
ColorCode = "BZ-22",
|
||||
Finish = "Textured"
|
||||
});
|
||||
|
||||
context.JobItemCoats.Add(new JobItemCoat
|
||||
{
|
||||
Id = 11,
|
||||
JobItemId = 10,
|
||||
CompanyId = 1,
|
||||
CoatName = "Top Coat",
|
||||
Sequence = 1,
|
||||
CoverageSqFtPerLb = 26m,
|
||||
TransferEfficiency = 70m,
|
||||
PowderToOrder = 2.75m
|
||||
});
|
||||
|
||||
context.JobItemPrepServices.Add(new JobItemPrepService
|
||||
{
|
||||
Id = 12,
|
||||
JobItemId = 10,
|
||||
CompanyId = 1,
|
||||
PrepServiceId = 3,
|
||||
EstimatedMinutes = 45,
|
||||
BlastSetupId = 88
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private static Mock<ITenantContext> CreateTenantContext()
|
||||
{
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(x => x.GetCurrentCompanyId()).Returns(1);
|
||||
tenantContext.Setup(x => x.IsSuperAdmin()).Returns(true);
|
||||
tenantContext.Setup(x => x.IsPlatformAdmin()).Returns(true);
|
||||
return tenantContext;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.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!);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class QuotePhotoServiceTests
|
||||
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("File type '.bmp' is not allowed.", result.ErrorMessage);
|
||||
Assert.Equal("File type not allowed. Allowed: .jpg, .jpeg, .png, .gif, .webp.", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
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!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user