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
@@ -117,14 +117,7 @@ public class AppointmentsController : Controller
// Map to DTOs
var appointmentDtos = _mapper.Map<List<AppointmentListDto>>(items);
// Create paged result
var pagedResult = new PagedResult<AppointmentListDto>
{
Items = appointmentDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<AppointmentListDto>.From(gridRequest, appointmentDtos, totalCount);
// Set ViewBag
ViewBag.SearchTerm = searchTerm;
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Services;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
@@ -15,6 +16,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Application.DTOs.PurchaseOrder;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
@@ -345,10 +347,11 @@ public class BillsController : Controller
// Attach receipt file if provided
if (receiptFile != null && receiptFile.Length > 0)
{
if (IsValidReceiptFile(receiptFile, out var fileError))
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (receiptValid)
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
else
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
await _unitOfWork.CompleteAsync();
}
@@ -571,7 +574,8 @@ public class BillsController : Controller
// Handle receipt file replacement
if (receiptFile != null && receiptFile.Length > 0)
{
if (IsValidReceiptFile(receiptFile, out var fileError))
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (receiptValid)
{
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
@@ -579,7 +583,7 @@ public class BillsController : Controller
}
else
{
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
}
}
@@ -927,48 +931,13 @@ public class BillsController : Controller
/// </summary>
private async Task PopulateDropdownsAsync()
{
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.IsActive);
ViewBag.Vendors = vendors
.OrderBy(s => s.CompanyName)
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
.ToList();
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.APAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.ExpenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.BankAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList();
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
j.JobStatus.StatusCode != "COMPLETED" &&
j.JobStatus.StatusCode != "CANCELLED" &&
j.JobStatus.StatusCode != "DELIVERED"))
.OrderBy(j => j.JobNumber)
.Select(j => new SelectListItem($"{j.JobNumber} {j.Description ?? "No description"}", j.Id.ToString()))
.ToList();
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
ViewBag.Vendors = dd.Vendors;
ViewBag.APAccounts = dd.ApAccounts;
ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
ViewBag.BankAccounts = dd.BankAccounts;
ViewBag.PaymentMethods = dd.PaymentMethods;
ViewBag.Jobs = dd.ActiveJobs;
}
/// <summary>
@@ -1023,7 +992,7 @@ public class BillsController : Controller
if (!result.Success) return NotFound();
var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
var contentType = MimeFromExt(ext);
var contentType = BlobFileHelper.GetContentType(ext);
var fileName = $"receipt-{bill.BillNumber}{ext}";
return File(result.Content, contentType, fileName);
}
@@ -1161,41 +1130,8 @@ public class BillsController : Controller
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
var contentType = MimeFromExt(ext);
using var stream = file.OpenReadStream();
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
return result.Success ? blobName : null;
}
/// <summary>
/// Validates a receipt file upload against the allowed extension list and the 10 MB size cap.
/// Returns <c>false</c> and populates <paramref name="error"/> with a user-friendly message
/// when the file fails either check; returns <c>true</c> and sets <paramref name="error"/> to
/// an empty string when the file is acceptable.
/// </summary>
private static bool IsValidReceiptFile(IFormFile file, out string error)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedReceiptTypes.Contains(ext))
{
error = $"File type '{ext}' is not allowed. Accepted: {string.Join(", ", AllowedReceiptTypes)}";
return false;
}
if (file.Length > MaxReceiptBytes)
{
error = "Receipt file must be 10 MB or smaller.";
return false;
}
error = string.Empty;
return true;
}
private static string MimeFromExt(string ext) => ext switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -174,14 +174,7 @@ public class CompanyUsersController : Controller
LastLoginDate = u.LastLoginDate
}).ToList();
// Create paged result
var pagedResult = new PagedResult<CompanyUserListDto>
{
Items = userDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<CompanyUserListDto>.From(gridRequest, userDtos, totalCount);
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
@@ -123,14 +123,7 @@ public class CustomersController : Controller
LastContactDate = c.LastContactDate
}).ToList();
// Create paged result
var pagedResult = new PagedResult<CustomerListDto>
{
Items = customerDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<CustomerListDto>.From(gridRequest, customerDtos, totalCount);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
@@ -121,14 +121,7 @@ public class EquipmentController : Controller
// Map to DTOs
var equipmentDtos = _mapper.Map<List<EquipmentListDto>>(items);
// Create paged result
var pagedResult = new PagedResult<EquipmentListDto>
{
Items = equipmentDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<EquipmentListDto>.From(gridRequest, equipmentDtos, totalCount);
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
@@ -8,12 +8,14 @@ using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Services;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
@@ -148,11 +150,15 @@ public class ExpensesController : Controller
return View(dto);
}
if (receiptFile != null && !IsValidReceiptFile(receiptFile, out var fileError))
if (receiptFile != null)
{
ModelState.AddModelError(string.Empty, fileError);
await PopulateDropdownsAsync();
return View(dto);
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (!receiptValid)
{
ModelState.AddModelError(string.Empty, receiptError);
await PopulateDropdownsAsync();
return View(dto);
}
}
try
@@ -228,11 +234,15 @@ public class ExpensesController : Controller
return View(dto);
}
if (receiptFile != null && !IsValidReceiptFile(receiptFile, out var fileError))
if (receiptFile != null)
{
ModelState.AddModelError(string.Empty, fileError);
await PopulateDropdownsAsync();
return View(dto);
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (!receiptValid)
{
ModelState.AddModelError(string.Empty, receiptError);
await PopulateDropdownsAsync();
return View(dto);
}
}
try
@@ -345,7 +355,7 @@ public class ExpensesController : Controller
// Inline for images so the browser previews them; attachment for PDFs triggers download
var ext = Path.GetExtension(expense.ReceiptFilePath).ToLowerInvariant();
var contentType = result.ContentType.Length > 0 ? result.ContentType : MimeFromExt(ext);
var contentType = result.ContentType.Length > 0 ? result.ContentType : BlobFileHelper.GetContentType(ext);
var filename = $"Receipt-{expense.ExpenseNumber}{ext}";
Response.Headers["Content-Disposition"] = ext == ".pdf"
@@ -392,39 +402,12 @@ public class ExpensesController : Controller
/// </summary>
private async Task PopulateDropdownsAsync()
{
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.ExpenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.Vendors = (await _unitOfWork.Vendors.FindAsync(s => s.IsActive))
.OrderBy(s => s.CompanyName)
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
.ToList();
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
j.JobStatus.StatusCode != "COMPLETED" &&
j.JobStatus.StatusCode != "CANCELLED" &&
j.JobStatus.StatusCode != "DELIVERED"))
.OrderBy(j => j.JobNumber)
.Select(j => new SelectListItem($"{j.JobNumber} {j.Description ?? "No description"}", j.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList();
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
ViewBag.ExpenseAccounts = dd.ExpenseAccounts;
ViewBag.PaymentAccounts = dd.BankAccounts;
ViewBag.Vendors = dd.Vendors;
ViewBag.Jobs = dd.ActiveJobs;
ViewBag.PaymentMethods = dd.PaymentMethods;
}
/// <summary>
@@ -458,10 +441,8 @@ public class ExpensesController : Controller
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var blobName = $"{companyId}/expense-receipts/{expenseId}{ext}";
var contentType = MimeFromExt(ext);
using var stream = file.OpenReadStream();
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
if (!result.Success)
{
_logger.LogError("Receipt upload failed for expense {Id}: {Error}", expenseId, result.ErrorMessage);
@@ -470,35 +451,7 @@ public class ExpensesController : Controller
return blobName;
}
/// <summary>
/// Validates a receipt file against the allowed extension whitelist and the 10 MB size cap.
/// Returns <c>false</c> and sets <paramref name="error"/> when validation fails.
/// </summary>
private static bool IsValidReceiptFile(IFormFile file, out string error)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedReceiptTypes.Contains(ext))
{
error = $"File type '{ext}' is not allowed. Accepted types: {string.Join(", ", AllowedReceiptTypes)}";
return false;
}
if (file.Length > MaxReceiptBytes)
{
error = "Receipt file must be 10 MB or smaller.";
return false;
}
error = string.Empty;
return true;
}
private static string MimeFromExt(string ext) => ext switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
// ── AI: Account Suggestion ────────────────────────────────────────────────
@@ -154,14 +154,7 @@ public class InventoryController : Controller
// Map to DTOs using AutoMapper
var itemDtos = _mapper.Map<List<InventoryListDto>>(items);
// Create paged result
var pagedResult = new PagedResult<InventoryListDto>
{
Items = itemDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
@@ -208,16 +208,8 @@ public class InvoicesController : Controller
var dtos = _mapper.Map<List<InvoiceListDto>>(items);
var pagedResult = new PagedResult<InvoiceListDto>
{
Items = dtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount,
SortColumn = gridRequest.SortColumn,
SortDirection = gridRequest.SortDirection,
SearchTerm = searchTerm
};
var pagedResult = PagedResult<InvoiceListDto>.From(gridRequest, dtos, totalCount);
pagedResult.SearchTerm = searchTerm;
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
@@ -159,6 +159,8 @@ public class JobTemplatesController : Controller
CatalogItemId = item.CatalogItemId,
IsGenericItem = item.IsGenericItem,
IsLaborItem = item.IsLaborItem,
IsSalesItem = item.IsSalesItem,
Sku = item.Sku,
ManualUnitPrice = item.ManualUnitPrice,
RequiresSandblasting = item.RequiresSandblasting,
RequiresMasking = item.RequiresMasking,
@@ -248,6 +250,8 @@ public class JobTemplatesController : Controller
catalogItemId = i.CatalogItemId,
isGenericItem = i.IsGenericItem,
isLaborItem = i.IsLaborItem,
isSalesItem = i.IsSalesItem,
sku = i.Sku,
manualUnitPrice = i.ManualUnitPrice,
requiresSandblasting = i.RequiresSandblasting,
requiresMasking = i.RequiresMasking,
@@ -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
{
@@ -156,14 +156,7 @@ public class MaintenanceController : Controller
// Map to DTOs
var maintenanceDtos = _mapper.Map<List<MaintenanceListDto>>(items);
// Create paged result
var pagedResult = new PagedResult<MaintenanceListDto>
{
Items = maintenanceDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<MaintenanceListDto>.From(gridRequest, maintenanceDtos, totalCount);
// Get equipment name if filtering by equipment
if (equipmentId.HasValue)
@@ -170,14 +170,7 @@ public class PlatformUsersController : Controller
totalCount = userDtos.Count; // Recalculate total for SuperAdmins
}
// Create paged result
var pagedResult = new PagedResult<PlatformUserListDto>
{
Items = userDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<PlatformUserListDto>.From(gridRequest, userDtos, totalCount);
// Set ViewBag for sorting and filters
ViewBag.CurrentFilter = filter;
@@ -33,6 +33,8 @@ public class QuotesController : Controller
private readonly ILookupCacheService _lookupCache;
private readonly INotificationService _notificationService;
private readonly ISubscriptionService _subscriptionService;
private readonly IJobItemAssemblyService _jobItemAssemblyService;
private readonly IQuotePricingAssemblyService _quotePricingAssemblyService;
private readonly IConfiguration _configuration;
private readonly IPlatformSettingsService _platformSettings;
private readonly IQuotePhotoService _photoService;
@@ -55,6 +57,8 @@ public class QuotesController : Controller
ILookupCacheService lookupCache,
INotificationService notificationService,
ISubscriptionService subscriptionService,
IJobItemAssemblyService jobItemAssemblyService,
IQuotePricingAssemblyService quotePricingAssemblyService,
IConfiguration configuration,
IPlatformSettingsService platformSettings,
IQuotePhotoService photoService,
@@ -76,6 +80,8 @@ public class QuotesController : Controller
_lookupCache = lookupCache;
_notificationService = notificationService;
_subscriptionService = subscriptionService;
_jobItemAssemblyService = jobItemAssemblyService;
_quotePricingAssemblyService = quotePricingAssemblyService;
_configuration = configuration;
_platformSettings = platformSettings;
_photoService = photoService;
@@ -198,14 +204,9 @@ public class QuotesController : Controller
.Contains(tagLower)).ToList();
}
// Create paged result
var pagedResult = new PagedResult<QuoteListDto>
{
Items = quoteDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count
};
var pagedResult = PagedResult<QuoteListDto>.From(
gridRequest, quoteDtos,
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count);
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
@@ -914,140 +915,19 @@ public class QuotesController : Controller
}
// Set calculated pricing — snapshot at save time; never recalculate on load
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// Add quote
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync();
// Create quote items with calculated pricing
var itemResults = new List<QuoteItem>();
foreach (var itemDto in dto.QuoteItems)
{
var item = _mapper.Map<QuoteItem>(itemDto);
item.QuoteId = quote.Id;
item.CompanyId = currentUser.CompanyId;
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Sales/merchandise items: use the manually entered price directly — no coating calculation
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Catalog items: if they have coats, calculate with coats; otherwise use default price
else if (itemDto.CatalogItemId.HasValue)
{
// If catalog item has coats, calculate the full price with coat costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
else
{
// No coats - use catalog default price
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
item.UnitPrice = catalogItem.DefaultPrice;
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
}
}
else
{
// Calculated items use the pricing service
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Flag whether the user overrode the AI's estimates before accepting
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
// Map coats for this item with calculated costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
item.Coats = new List<QuoteItemCoat>();
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
// If "Add to inventory as Incoming" was checked on the custom tab,
// create a 0-balance inventory record so QR codes work on the work order.
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId;
// Calculate and store the coat costs
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
currentUser.CompanyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
item.Coats.Add(coat);
}
}
// Map per-item prep services
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
{
item.PrepServices = new List<QuoteItemPrepService>();
foreach (var psDto in itemDto.PrepServices)
{
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
prepService.CompanyId = currentUser.CompanyId;
item.PrepServices.Add(prepService);
}
}
itemResults.Add(item);
}
var itemResults = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
dto.QuoteItems,
quote.Id,
currentUser.CompanyId,
ovenRateOverride,
DateTime.UtcNow);
foreach (var item in itemResults)
{
@@ -1444,23 +1324,7 @@ public class QuotesController : Controller
quote.ProspectSmsConsentedAt = null;
// Set calculated pricing — snapshot at save time; never recalculate on load
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// Track changes
var changeHistories = new List<QuoteChangeHistory>();
@@ -1704,121 +1568,25 @@ public class QuotesController : Controller
// Create new quote items with calculated pricing
var newItemsForComparison = new List<(string Description, decimal Quantity, decimal UnitPrice, decimal TotalPrice, bool Sandblasting, bool Masking, decimal? SurfaceArea, string? Notes)>();
foreach (var itemDto in dto.QuoteItems)
var assembledItems = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
dto.QuoteItems,
quote.Id,
currentUser.CompanyId,
ovenRateOverride,
DateTime.UtcNow);
foreach (var item in assembledItems)
{
var item = _mapper.Map<QuoteItem>(itemDto);
item.QuoteId = quote.Id;
item.CompanyId = currentUser.CompanyId;
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask} (from DTO: Sand={DtoSand}, Mask={DtoMask})",
item.Description, item.RequiresSandblasting, item.RequiresMasking,
itemDto.RequiresSandblasting, itemDto.RequiresMasking);
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Sales/merchandise items: use the manually entered price directly — no coating calculation
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Catalog items: if they have coats, calculate with coats; otherwise use default price
else if (itemDto.CatalogItemId.HasValue)
{
// If catalog item has coats, calculate the full price with coat costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
else
{
// No coats - use catalog default price
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
item.UnitPrice = catalogItem.DefaultPrice;
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
}
}
else
{
// Calculated items use the pricing service
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Flag whether the user overrode the AI's estimates before accepting
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask}",
item.Description, item.RequiresSandblasting, item.RequiresMasking);
await _unitOfWork.QuoteItems.AddAsync(item);
// Map coats for this item with calculated costs
if (itemDto.Coats != null && itemDto.Coats.Any())
if (item.Coats?.Any() == true)
{
item.Coats = new List<QuoteItemCoat>();
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId;
// Calculate and store the coat costs
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
currentUser.CompanyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
item.Coats.Add(coat);
}
_logger.LogInformation("Added {CoatCount} coats to item {Description}", item.Coats.Count, item.Description);
}
// Map per-item prep services
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
{
item.PrepServices = new List<QuoteItemPrepService>();
foreach (var psDto in itemDto.PrepServices)
{
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
prepService.CompanyId = currentUser.CompanyId;
item.PrepServices.Add(prepService);
}
}
// Track new item for comparison
newItemsForComparison.Add((
item.Description ?? "",
item.Quantity,
@@ -3086,108 +2854,32 @@ public class QuotesController : Controller
// Create job items from quote items
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
{
// Get first coat's color information if available
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
var jobItem = new JobItem
{
JobId = job.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, // Estimated 40% labor cost
RequiresSandblasting = quoteItem.RequiresSandblasting,
RequiresMasking = quoteItem.RequiresMasking,
EstimatedMinutes = quoteItem.EstimatedMinutes,
Notes = quoteItem.Notes,
Complexity = quoteItem.Complexity,
AiTags = quoteItem.AiTags,
AiPredictionId = quoteItem.AiPredictionId, // Share the same prediction record — no duplication
// Catalog items are fixed-price — prep services must not add labor cost to them.
// Non-catalog items default to true so prep service labor is included in the calculated price.
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
var createdAtUtc = DateTime.UtcNow;
var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, job.Id, quote.CompanyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync(); // Save JobItem first to get its ID
// Create JobItemCoat records for all coats from quote
if (quoteItem.Coats != null && quoteItem.Coats.Any())
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
{
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
{
// Get color info from inventory item if available, otherwise use coat fields
string colorName = quoteCoat.ColorName;
string colorCode = quoteCoat.ColorCode;
string finish = quoteCoat.Finish;
await _unitOfWork.JobItemCoats.AddAsync(coat);
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
coat.CoatName, coat.Sequence, coat.ColorName ?? "N/A", coat.ColorCode ?? "N/A");
}
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
{
// Use inventory item information (takes precedence)
colorName = quoteCoat.InventoryItem.Name;
colorCode = quoteCoat.InventoryItem.ColorCode;
finish = quoteCoat.InventoryItem.Finish;
}
// Calculate PowderToOrder if not already stored on the quote coat
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);
var jobCoat = 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 = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobItemCoats.AddAsync(jobCoat);
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
jobCoat.CoatName, jobCoat.Sequence, colorName ?? "N/A", colorCode ?? "N/A");
}
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.SaveChangesAsync();
// Aggregate unique prep services from all quote items and copy to job
// Load from DB directly to ensure prep services are available regardless of caller's includes
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync(
ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList();
var uniquePrepServiceIds = itemPrepServices
// Aggregate unique prep services from the fully-loaded quote items and copy to job
var uniquePrepServiceIds = fullItems
.SelectMany(qi => qi.PrepServices)
.Where(ps => !ps.IsDeleted)
.Select(ps => ps.PrepServiceId)
.Distinct()
.ToList();
@@ -3795,160 +3487,6 @@ public class QuotesController : Controller
/// Returns the new inventory item ID, or null if creation fails (non-fatal — the coat
/// falls back to custom-powder pricing without an inventory link).
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
// Match catalog vendor name to a company vendor record
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
var matchedVendor = vendors.FirstOrDefault(v =>
v.CompanyName.ToLower().Contains(vendorNameLower) ||
vendorNameLower.Contains(v.CompanyName.ToLower()));
// InventoryCategoryId is nullable — degrade gracefully rather than aborting if the
// company has not yet set up inventory categories (e.g., pre-seed).
var code = coatingCategory != null
? (coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
: "POWD";
var prefix = $"{code}-{DateTime.Now:yyMM}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
var sku = $"{prefix}{(maxSeq + 1):D4}";
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
// Start with everything the catalog already has, then augment any null
// spec fields by fetching the product URL through the AI lookup service.
var description = catalogItem.Description;
var finish = catalogItem.Finish;
var colorFamilies = catalogItem.ColorFamilies;
var cureTemp = catalogItem.CureTemperatureF;
var cureTime = catalogItem.CureTimeMinutes;
var coverage = catalogItem.CoverageSqFtPerLb;
var transferEff = catalogItem.TransferEfficiency;
var specificGravity = catalogItem.SpecificGravity;
var imageUrl = catalogItem.ImageUrl;
var sdsUrl = catalogItem.SdsUrl;
var tdsUrl = catalogItem.TdsUrl;
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
(string.IsNullOrWhiteSpace(description) ||
string.IsNullOrWhiteSpace(colorFamilies) ||
cureTemp == null || cureTime == null);
if (needsAugment)
{
try
{
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
if (augmented.Success)
{
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
cureTemp ??= augmented.CureTemperatureF;
cureTime ??= augmented.CureTimeMinutes;
coverage ??= augmented.CoverageSqFtPerLb;
transferEff ??= augmented.TransferEfficiency;
specificGravity ??= augmented.SpecificGravity;
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
}
}
var item = new PowderCoating.Core.Entities.InventoryItem
{
SKU = sku,
Name = name,
Description = description,
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber = catalogItem.Sku,
Finish = finish,
ColorFamilies = colorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = coverage ?? 30m,
TransferEfficiency = transferEff ?? 65m,
CureTemperatureF = cureTemp,
CureTimeMinutes = cureTime,
SpecificGravity = specificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = imageUrl,
SdsUrl = sdsUrl,
TdsUrl = tdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
PrimaryVendorId = matchedVendor?.Id,
InventoryCategoryId = coatingCategory?.Id,
Category = coatingCategory?.DisplayName ?? "Powder Coating",
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
// Also update the coat DTO so pricing uses the inventory unit cost
coatDto.PowderCostPerLb = null; // clear manual price; pricing service reads from inventory
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
return null;
}
}
/// <summary>
/// After pricing is determined for an AI item, update the prediction record to flag whether
/// the user changed the AI's estimated surface area or unit price before accepting.
/// This data powers the "AI accuracy" reporting queries.
/// </summary>
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
if (prediction == null) return;
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
prediction.UpdatedAt = DateTime.UtcNow;
// Change is tracked by EF; will be persisted on the next CompleteAsync()
}
/// <summary>
/// Builds a benchmark summary comparing the AI's estimate to historical completed jobs of
/// similar complexity and surface area (±60% sqft range). The benchmark is displayed
@@ -111,14 +111,7 @@ public class VendorsController : Controller
InventoryItemCount = s.InventoryItems.Count(i => !i.IsDeleted)
}).ToList();
// Create paged result
var pagedResult = new PagedResult<VendorListDto>
{
Items = vendorDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
var pagedResult = PagedResult<VendorListDto>.From(gridRequest, vendorDtos, totalCount);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;