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:
2026-05-09 22:12:33 -04:00
parent 61866e1d1e
commit edd7389d7d
37 changed files with 11819 additions and 1211 deletions
@@ -0,0 +1,405 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
public class JobItemAssemblyService : IJobItemAssemblyService
{
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(pricing);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
},
jobItemId,
companyId,
createdAtUtc))
.ToList() ?? [];
}
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
var firstCoat = source.Coats?
.OrderBy(c => c.Sequence)
.FirstOrDefault();
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = source.SurfaceAreaSqFt,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c =>
{
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
return BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = appearance.ColorName,
VendorId = c.VendorId,
ColorCode = appearance.ColorCode,
Finish = appearance.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
},
jobItemId,
companyId,
createdAtUtc);
})
.ToList() ?? [];
}
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = source.ColorName,
ColorCode = source.ColorCode,
Finish = source.Finish,
SurfaceArea = source.SurfaceArea,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes
},
jobItemId,
companyId,
createdAtUtc))
.ToList() ?? [];
}
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
{
return new JobItem
{
JobId = jobId,
Description = seed.Description,
Quantity = seed.Quantity,
ColorName = seed.ColorName,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
SurfaceArea = seed.SurfaceArea,
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
CatalogItemId = seed.CatalogItemId,
IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem,
Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride,
UnitPrice = seed.UnitPrice,
TotalPrice = seed.TotalPrice,
LaborCost = seed.LaborCost,
RequiresSandblasting = seed.RequiresSandblasting,
RequiresMasking = seed.RequiresMasking,
EstimatedMinutes = seed.EstimatedMinutes,
Notes = seed.Notes,
IncludePrepCost = seed.IncludePrepCost,
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{
return new JobItemCoat
{
JobItemId = jobItemId,
CoatName = seed.CoatName,
Sequence = seed.Sequence,
InventoryItemId = seed.InventoryItemId,
ColorName = seed.ColorName,
VendorId = seed.VendorId,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
TransferEfficiency = seed.TransferEfficiency,
PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
{
return seeds?
.Select(seed => new JobItemPrepService
{
JobItemId = jobItemId,
PrepServiceId = seed.PrepServiceId,
EstimatedMinutes = seed.EstimatedMinutes,
BlastSetupId = seed.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.ToList() ?? [];
}
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
{
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
return storedPowderToOrder;
if (surfaceAreaSqFt <= 0)
return null;
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
}
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
string? colorName,
string? colorCode,
string? finish,
InventoryItem? inventoryItem)
{
if (inventoryItem == null)
return (colorName, colorCode, finish);
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
}
private sealed class JobItemSeed
{
public string Description { get; init; } = string.Empty;
public decimal Quantity { get; init; }
public string? ColorName { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal? SurfaceArea { get; init; }
public decimal SurfaceAreaSqFt { get; init; }
public int? CatalogItemId { get; init; }
public bool IsGenericItem { get; init; }
public bool IsLaborItem { get; init; }
public bool IsSalesItem { get; init; }
public string? Sku { get; init; }
public decimal? ManualUnitPrice { get; init; }
public decimal? PowderCostOverride { get; init; }
public decimal UnitPrice { get; init; }
public decimal TotalPrice { get; init; }
public decimal LaborCost { get; init; }
public bool RequiresSandblasting { get; init; }
public bool RequiresMasking { get; init; }
public int EstimatedMinutes { get; init; }
public string? Notes { get; init; }
public bool IncludePrepCost { get; init; }
public string? Complexity { get; init; }
public string? AiTags { get; init; }
public int? AiPredictionId { get; init; }
}
private sealed class JobItemCoatSeed
{
public string CoatName { get; init; } = string.Empty;
public int Sequence { get; init; }
public int? InventoryItemId { get; init; }
public string? ColorName { get; init; }
public int? VendorId { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal CoverageSqFtPerLb { get; init; }
public decimal TransferEfficiency { get; init; }
public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; }
}
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
public int EstimatedMinutes { get; init; }
public int? BlastSetupId { get; init; }
}
}