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,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 ────────────────────────────────────────────────