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
@@ -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"
};
}