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
@@ -34,6 +34,7 @@ public class JobsController : Controller
private readonly INotificationService _notificationService;
private readonly ISubscriptionService _subscriptionService;
private readonly IPricingCalculationService _pricingService;
private readonly IJobItemAssemblyService _jobItemAssemblyService;
private readonly IHubContext<NotificationHub> _hub;
private readonly IHubContext<ShopHub> _shopHub;
@@ -49,6 +50,7 @@ public class JobsController : Controller
INotificationService notificationService,
ISubscriptionService subscriptionService,
IPricingCalculationService pricingService,
IJobItemAssemblyService jobItemAssemblyService,
IHubContext<NotificationHub> hub,
IHubContext<ShopHub> shopHub)
{
@@ -63,6 +65,7 @@ public class JobsController : Controller
_notificationService = notificationService;
_subscriptionService = subscriptionService;
_pricingService = pricingService;
_jobItemAssemblyService = jobItemAssemblyService;
_hub = hub;
_shopHub = shopHub;
}
@@ -185,14 +188,9 @@ public class JobsController : Controller
.Contains(tagLower)).ToList();
}
// Create paged result
var pagedResult = new PagedResult<JobListDto>
{
Items = jobDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count
};
var pagedResult = PagedResult<JobListDto>.From(
gridRequest, jobDtos,
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
@@ -1026,8 +1024,8 @@ public class JobsController : Controller
catalogItemId = i.CatalogItemId,
isGenericItem = i.IsGenericItem,
isLaborItem = i.IsLaborItem,
isSalesItem = false, // JobTemplateItem doesn't have IsSalesItem — default false
sku = (string?)null,
isSalesItem = i.IsSalesItem,
sku = i.Sku,
manualUnitPrice = i.ManualUnitPrice,
requiresSandblasting = i.RequiresSandblasting,
requiresMasking = i.RequiresMasking,
@@ -1147,82 +1145,20 @@ public class JobsController : Controller
{
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(
itemDto, companyId, null);
var jobItem = new JobItem
{
JobId = job.Id,
Description = itemDto.Description,
Quantity = itemDto.Quantity,
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
EstimatedMinutes = itemDto.EstimatedMinutes,
CatalogItemId = itemDto.CatalogItemId,
IsGenericItem = itemDto.IsGenericItem,
IsLaborItem = itemDto.IsLaborItem,
IsSalesItem = itemDto.IsSalesItem,
Sku = itemDto.Sku,
ManualUnitPrice = itemDto.ManualUnitPrice,
PowderCostOverride = itemDto.PowderCostOverride,
RequiresSandblasting = itemDto.RequiresSandblasting,
RequiresMasking = itemDto.RequiresMasking,
Notes = itemDto.Notes,
IncludePrepCost = itemDto.IncludePrepCost,
Complexity = itemDto.Complexity,
UnitPrice = itemPricing.UnitPrice,
TotalPrice = itemPricing.TotalPrice,
LaborCost = itemPricing.TotalPrice * 0.4m,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
var createdAtUtc = DateTime.UtcNow;
var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, job.Id, companyId, itemPricing, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync();
if (itemDto.Coats?.Any() == true)
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, companyId, createdAtUtc))
{
foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence))
{
decimal? powderToOrder = coatDto.PowderToOrder;
if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0)
{
var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m;
var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m;
powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2);
}
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = jobItem.Id,
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
Finish = coatDto.Finish,
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = powderToOrder,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
if (itemDto.PrepServices?.Any() == true)
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, companyId, createdAtUtc))
{
foreach (var psDto in itemDto.PrepServices)
{
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
{
JobItemId = jobItem.Id,
PrepServiceId = psDto.PrepServiceId,
EstimatedMinutes = psDto.EstimatedMinutes,
BlastSetupId = psDto.BlastSetupId,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
@@ -1416,79 +1352,20 @@ public class JobsController : Controller
{
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(
itemDto, companyId, null);
var jobItem = new JobItem
{
JobId = id,
Description = itemDto.Description,
Quantity = itemDto.Quantity,
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
EstimatedMinutes = itemDto.EstimatedMinutes,
CatalogItemId = itemDto.CatalogItemId,
IsGenericItem = itemDto.IsGenericItem,
IsLaborItem = itemDto.IsLaborItem,
ManualUnitPrice = itemDto.ManualUnitPrice,
PowderCostOverride = itemDto.PowderCostOverride,
RequiresSandblasting = itemDto.RequiresSandblasting,
RequiresMasking = itemDto.RequiresMasking,
Notes = itemDto.Notes,
IncludePrepCost = itemDto.IncludePrepCost,
Complexity = itemDto.Complexity,
UnitPrice = itemPricing.UnitPrice,
TotalPrice = itemPricing.TotalPrice,
LaborCost = itemPricing.TotalPrice * 0.4m,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
var createdAtUtc = DateTime.UtcNow;
var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, id, companyId, itemPricing, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync();
if (itemDto.Coats?.Any() == true)
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, companyId, createdAtUtc))
{
foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence))
{
decimal? powderToOrder = coatDto.PowderToOrder;
if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0)
{
var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m;
var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m;
powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2);
}
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = jobItem.Id,
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
Finish = coatDto.Finish,
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = powderToOrder,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
if (itemDto.PrepServices?.Any() == true)
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, companyId, createdAtUtc))
{
foreach (var psDto in itemDto.PrepServices)
{
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
{
JobItemId = jobItem.Id,
PrepServiceId = psDto.PrepServiceId,
EstimatedMinutes = psDto.EstimatedMinutes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
@@ -3130,86 +3007,20 @@ public class JobsController : Controller
{
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(
itemDto, currentUser.CompanyId, null);
var jobItem = new JobItem
{
JobId = job.Id,
Description = itemDto.Description,
Quantity = itemDto.Quantity,
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
EstimatedMinutes = itemDto.EstimatedMinutes,
CatalogItemId = itemDto.CatalogItemId,
IsGenericItem = itemDto.IsGenericItem,
IsLaborItem = itemDto.IsLaborItem,
ManualUnitPrice = itemDto.ManualUnitPrice,
PowderCostOverride = itemDto.PowderCostOverride,
RequiresSandblasting = itemDto.RequiresSandblasting,
RequiresMasking = itemDto.RequiresMasking,
Notes = itemDto.Notes,
IncludePrepCost = itemDto.IncludePrepCost,
Complexity = itemDto.Complexity,
UnitPrice = itemPricing.UnitPrice,
TotalPrice = itemPricing.TotalPrice,
LaborCost = itemPricing.TotalPrice * 0.4m,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow
};
var createdAtUtc = DateTime.UtcNow;
var jobItem = _jobItemAssemblyService.CreateJobItem(itemDto, job.Id, currentUser.CompanyId, itemPricing, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync();
// Coats
if (itemDto.Coats?.Any() == true)
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(itemDto, jobItem.Id, currentUser.CompanyId, createdAtUtc))
{
foreach (var coatDto in itemDto.Coats.OrderBy(c => c.Sequence))
{
// Calculate PowderToOrder if not supplied by the client
decimal? powderToOrder = coatDto.PowderToOrder;
if ((powderToOrder == null || powderToOrder == 0) && itemDto.SurfaceAreaSqFt > 0)
{
var cov = coatDto.CoverageSqFtPerLb > 0 ? coatDto.CoverageSqFtPerLb : 30m;
var eff = coatDto.TransferEfficiency > 0 ? coatDto.TransferEfficiency / 100m : 0.65m;
powderToOrder = Math.Round((itemDto.SurfaceAreaSqFt * itemDto.Quantity) / (cov * eff), 2);
}
var coat = new JobItemCoat
{
JobItemId = jobItem.Id,
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
Finish = coatDto.Finish,
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = powderToOrder,
Notes = coatDto.Notes,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
// Prep services
if (itemDto.PrepServices?.Any() == true)
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(itemDto, jobItem.Id, currentUser.CompanyId, createdAtUtc))
{
foreach (var psDto in itemDto.PrepServices)
{
var ps = new JobItemPrepService
{
JobItemId = jobItem.Id,
PrepServiceId = psDto.PrepServiceId,
EstimatedMinutes = psDto.EstimatedMinutes,
BlastSetupId = psDto.BlastSetupId,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobItemPrepServices.AddAsync(ps);
}
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
@@ -3638,60 +3449,20 @@ public class JobsController : Controller
foreach (var item in itemsToCopy)
{
var newItem = new JobItem
{
JobId = reworkJob.Id,
Description = item.Description,
Quantity = item.Quantity,
SurfaceAreaSqFt = item.SurfaceAreaSqFt,
CatalogItemId = item.CatalogItemId,
IsGenericItem = item.IsGenericItem,
IsLaborItem = item.IsLaborItem,
ManualUnitPrice = item.ManualUnitPrice,
RequiresSandblasting = item.RequiresSandblasting,
RequiresMasking = item.RequiresMasking,
IncludePrepCost = item.IncludePrepCost,
EstimatedMinutes = item.EstimatedMinutes,
Complexity = item.Complexity,
Notes = item.Notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
var createdAtUtc = DateTime.UtcNow;
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = newItem.Id,
CoatName = coat.CoatName,
Sequence = coat.Sequence,
InventoryItemId = coat.InventoryItemId,
ColorName = coat.ColorName,
VendorId = coat.VendorId,
ColorCode = coat.ColorCode,
Finish = coat.Finish,
CoverageSqFtPerLb = coat.CoverageSqFtPerLb,
TransferEfficiency = coat.TransferEfficiency,
PowderCostPerLb = coat.PowderCostPerLb,
Notes = coat.Notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
foreach (var prep in item.PrepServices)
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
{
JobItemId = newItem.Id,
PrepServiceId = prep.PrepServiceId,
EstimatedMinutes = prep.EstimatedMinutes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
@@ -3910,95 +3681,31 @@ public class JobsController : Controller
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
{
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
var jobItem = new JobItem
{
JobId = id,
Description = quoteItem.Description,
Quantity = quoteItem.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = quoteItem.SurfaceAreaSqFt,
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
CatalogItemId = quoteItem.CatalogItemId,
IsGenericItem = quoteItem.IsGenericItem,
IsLaborItem = quoteItem.IsLaborItem,
IsSalesItem = quoteItem.IsSalesItem,
Sku = quoteItem.Sku,
ManualUnitPrice = quoteItem.ManualUnitPrice,
PowderCostOverride = quoteItem.PowderCostOverride,
UnitPrice = quoteItem.UnitPrice,
TotalPrice = quoteItem.TotalPrice,
LaborCost = quoteItem.TotalPrice * 0.4m,
RequiresSandblasting = quoteItem.RequiresSandblasting,
RequiresMasking = quoteItem.RequiresMasking,
EstimatedMinutes = quoteItem.EstimatedMinutes,
Notes = quoteItem.Notes,
Complexity = quoteItem.Complexity,
AiTags = quoteItem.AiTags,
AiPredictionId = quoteItem.AiPredictionId,
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
};
var createdAtUtc = DateTime.UtcNow;
var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, id, job.CompanyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync();
if (quoteItem.Coats != null)
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, job.CompanyId, createdAtUtc))
{
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
{
string colorName = quoteCoat.ColorName;
string colorCode = quoteCoat.ColorCode;
string finish = quoteCoat.Finish;
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
{
colorName = quoteCoat.InventoryItem.Name;
colorCode = quoteCoat.InventoryItem.ColorCode;
finish = quoteCoat.InventoryItem.Finish;
}
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
var powderToOrder = (quoteCoat.PowderToOrder > 0)
? quoteCoat.PowderToOrder
: (quoteItem.SurfaceAreaSqFt > 0
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
: (decimal?)null);
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = jobItem.Id,
CoatName = quoteCoat.CoatName,
Sequence = quoteCoat.Sequence,
InventoryItemId = quoteCoat.InventoryItemId,
ColorName = colorName,
VendorId = quoteCoat.VendorId,
ColorCode = colorCode,
Finish = finish,
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
TransferEfficiency = quoteCoat.TransferEfficiency,
PowderCostPerLb = quoteCoat.PowderCostPerLb,
PowderToOrder = powderToOrder,
Notes = quoteCoat.Notes,
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
}
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, job.CompanyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.SaveChangesAsync();
// Aggregate prep services from all quote items and copy to job
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
var itemPrepServices = await _unitOfWork.QuoteItemPrepServices.FindAsync(
ps => quoteItemIds.Contains(ps.QuoteItemId));
foreach (var prepServiceId in itemPrepServices.Select(ps => ps.PrepServiceId).Distinct())
// Aggregate prep services from the fully-loaded quote items and copy to job
foreach (var prepServiceId in fullItems
.SelectMany(qi => qi.PrepServices)
.Where(ps => !ps.IsDeleted)
.Select(ps => ps.PrepServiceId)
.Distinct())
{
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
{