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,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"
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user