Phase 2: Eliminate ApplicationDbContext from domain controllers

Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.

New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.

All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 21:20:39 -04:00
parent 80b0e547cc
commit 90bc0d965f
20 changed files with 730 additions and 878 deletions
@@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Application.DTOs.PurchaseOrder;
namespace PowderCoating.Web.Controllers;
@@ -29,7 +28,6 @@ public class BillsController : Controller
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<BillsController> _logger;
private readonly ApplicationDbContext _context;
private readonly IAccountBalanceService _accountBalanceService;
private readonly IAccountingAiService _accountingAi;
private readonly IAzureBlobStorageService _blobStorage;
@@ -41,7 +39,6 @@ public class BillsController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<BillsController> logger,
ApplicationDbContext context,
IAccountBalanceService accountBalanceService,
IAccountingAiService accountingAi,
IAzureBlobStorageService blobStorage,
@@ -52,7 +49,6 @@ public class BillsController : Controller
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
_accountBalanceService = accountBalanceService;
_accountingAi = accountingAi;
_blobStorage = blobStorage;
@@ -86,23 +82,7 @@ public class BillsController : Controller
// Bills
if (type == null || type == "Bill")
{
var bills = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted)
.Where(b => status != "Unpaid" ||
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid))
.Where(b => status != "Overdue" ||
(b.Status != BillStatus.Paid && b.Status != BillStatus.Voided &&
b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today))
.Where(b => string.IsNullOrEmpty(search) ||
b.BillNumber.Contains(search) ||
b.Vendor.CompanyName.Contains(search) ||
(b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(search)) ||
(b.Memo != null && b.Memo.Contains(search)) ||
b.LineItems.Any(li => li.Description.Contains(search)) ||
(searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value)))
.OrderByDescending(b => b.BillDate)
.ToListAsync();
var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount);
entries.AddRange(bills.Select(b => new BillExpenseListDto
{
@@ -133,17 +113,17 @@ public class BillsController : Controller
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{
var expenses = await _context.Set<Core.Entities.Expense>()
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.Where(e => string.IsNullOrEmpty(search) ||
e.ExpenseNumber.Contains(search) ||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)) ||
(e.Memo != null && e.Memo.Contains(search)) ||
(searchAmount.HasValue && e.Amount == searchAmount.Value))
.OrderByDescending(e => e.Date)
.ToListAsync();
var expSearch = search;
var expAmount = searchAmount;
var expenseList = await _unitOfWork.Expenses.FindAsync(
e => string.IsNullOrEmpty(expSearch) ||
e.ExpenseNumber.Contains(expSearch) ||
(e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) ||
(e.Memo != null && e.Memo.Contains(expSearch)) ||
(expAmount.HasValue && e.Amount == expAmount.Value),
false,
e => e.Vendor!, e => e.ExpenseAccount!);
var expenses = expenseList.OrderByDescending(e => e.Date).ToList();
entries.AddRange(expenses.Select(e => new BillExpenseListDto
{
@@ -198,11 +178,7 @@ public class BillsController : Controller
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == purchaseOrderId && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId);
if (po == null) return NotFound();
@@ -218,20 +194,16 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = po.BillId });
}
var apAccount = await _context.Accounts
.Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.FirstOrDefaultAsync();
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsPayable);
// Vendor default expense account, fall back to first expense/COGS account
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
if (!defaultExpenseAccountId.HasValue)
{
defaultExpenseAccountId = (await _context.Accounts
.Where(a => !a.IsDeleted && a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
.OrderBy(a => a.AccountNumber)
.FirstOrDefaultAsync())?.Id;
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
defaultExpenseAccountId = fallbackAccount?.Id;
}
var lineItems = po.Items
@@ -293,10 +265,8 @@ public class BillsController : Controller
};
// Pre-fill AP account
var apAccount = await _context.Accounts
.Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.FirstOrDefaultAsync();
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsPayable);
dto.APAccountId = apAccount?.Id ?? 0;
// Pre-fill default expense account for vendor
@@ -385,13 +355,12 @@ public class BillsController : Controller
// Link bill back to source PO if created from one
if (dto.PurchaseOrderId > 0)
{
var po = await _context.Set<PurchaseOrder>()
.FirstOrDefaultAsync(p => p.Id == dto.PurchaseOrderId && !p.IsDeleted);
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
if (po != null)
{
po.BillId = bill.Id;
po.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
}
}
@@ -416,8 +385,8 @@ public class BillsController : Controller
bill.AmountPaid = payment.Amount;
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
await _context.BillPayments.AddAsync(payment);
await _context.SaveChangesAsync();
await _unitOfWork.BillPayments.AddAsync(payment);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid.";
}
@@ -448,28 +417,18 @@ public class BillsController : Controller
{
if (id == null) return NotFound();
var bill = await _context.Bills
.Include(b => b.Vendor)
.Include(b => b.APAccount)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Account)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Job)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.BankAccount)
.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted);
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value);
if (bill == null) return NotFound();
var dto = _mapper.Map<BillDto>(bill);
// Payment form defaults
var bankAccounts = await _context.Accounts
.Where(a => !a.IsDeleted && (a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard))
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard))
.OrderBy(a => a.AccountNumber)
.ToListAsync();
.ToList();
ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -495,10 +454,7 @@ public class BillsController : Controller
{
if (id == null) return NotFound();
var bill = await _context.Bills
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted);
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value);
if (bill == null) return NotFound();
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
@@ -561,9 +517,7 @@ public class BillsController : Controller
try
{
var bill = await _context.Bills
.Include(b => b.LineItems)
.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted);
var bill = await _unitOfWork.Bills.LoadForEditAsync(id);
if (bill == null) return NotFound();
@@ -611,7 +565,7 @@ public class BillsController : Controller
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
bill.Total = bill.SubTotal + bill.TaxAmount;
await _context.BillLineItems.AddRangeAsync(newLineItems);
await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems);
// Handle receipt file replacement
if (receiptFile != null && receiptFile.Length > 0)
@@ -628,7 +582,7 @@ public class BillsController : Controller
}
}
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Bill {bill.BillNumber} updated.";
return RedirectToAction(nameof(Details), new { id });
@@ -1024,12 +978,7 @@ public class BillsController : Controller
private async Task<string> GenerateBillNumberAsync()
{
var prefix = $"BILL-{DateTime.Now:yyMM}-";
var last = await _context.Bills
.IgnoreQueryFilters()
.Where(b => b.BillNumber.StartsWith(prefix))
.OrderByDescending(b => b.BillNumber)
.Select(b => b.BillNumber)
.FirstOrDefaultAsync();
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
@@ -1046,12 +995,7 @@ public class BillsController : Controller
private async Task<string> GeneratePaymentNumberAsync()
{
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
var last = await _context.BillPayments
.IgnoreQueryFilters()
.Where(p => p.PaymentNumber.StartsWith(prefix))
.OrderByDescending(p => p.PaymentNumber)
.Select(p => p.PaymentNumber)
.FirstOrDefaultAsync();
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))