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,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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user