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
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Helpers;
/// <summary>
/// Centralizes the repeated DB queries and SelectListItem projections used by the accounting
/// controllers (Bills, Expenses). Each controller assigns only the properties it needs to ViewBag,
/// so the naming mismatch between controllers (BankAccounts vs PaymentAccounts) is harmless.
/// </summary>
internal static class AccountingDropdownHelper
{
/// <summary>
/// Loads vendors, accounts, payment methods, and active jobs in a single call.
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
/// LINQ-to-SelectListItem transform.
/// </summary>
internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
{
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
var allAccounts = await unitOfWork.Accounts.FindAsync(a => a.IsActive);
var jobs = await unitOfWork.Jobs.FindAsync(j =>
j.JobStatus.StatusCode != "COMPLETED" &&
j.JobStatus.StatusCode != "CANCELLED" &&
j.JobStatus.StatusCode != "DELIVERED");
var accountLabel = (Core.Entities.Account a) => $"{a.AccountNumber} {a.Name}";
return new AccountingDropdowns
{
Vendors = vendors
.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
.ToList(),
ExpenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(),
ExpenseAndAssetAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(),
ApAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(),
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(accountLabel(a), a.Id.ToString()))
.ToList(),
PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList(),
ActiveJobs = jobs
.OrderBy(j => j.JobNumber)
.Select(j => new SelectListItem(
$"{j.JobNumber} {j.Description ?? "No description"}",
j.Id.ToString()))
.ToList()
};
}
}
internal sealed class AccountingDropdowns
{
public IReadOnlyList<SelectListItem> Vendors { get; init; } = [];
/// <summary>Expense + Cost of Goods accounts (used by Expenses controller).</summary>
public IReadOnlyList<SelectListItem> ExpenseAccounts { get; init; } = [];
/// <summary>Expense + Cost of Goods + Asset accounts (used by Bills controller).</summary>
public IReadOnlyList<SelectListItem> ExpenseAndAssetAccounts { get; init; } = [];
/// <summary>Accounts Payable accounts (used by Bills controller).</summary>
public IReadOnlyList<SelectListItem> ApAccounts { get; init; } = [];
/// <summary>Cash, Checking, Savings, and Credit Card accounts.</summary>
public IReadOnlyList<SelectListItem> BankAccounts { get; init; } = [];
public IReadOnlyList<SelectListItem> PaymentMethods { get; init; } = [];
public IReadOnlyList<SelectListItem> ActiveJobs { get; init; } = [];
}