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:
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user