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
@@ -22,7 +22,9 @@ public interface IUnitOfWork : IDisposable
IRepository<JobDailyPriority> JobDailyPriorities { get; }
IRepository<JobItem> JobItems { get; }
IRepository<JobItemCoat> JobItemCoats { get; }
IRepository<JobItemPrepService> JobItemPrepServices { get; }
IRepository<JobChangeHistory> JobChangeHistories { get; }
IRepository<JobPrepService> JobPrepServices { get; }
IQuoteRepository Quotes { get; }
IRepository<QuotePhoto> QuotePhotos { get; }
IRepository<QuoteItem> QuoteItems { get; }
@@ -87,8 +89,8 @@ public interface IUnitOfWork : IDisposable
IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; }
// Notifications
IRepository<NotificationLog> NotificationLogs { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
// Subscription
@@ -20,4 +20,23 @@ public interface IBillRepository : IRepository<Bill>
/// navigations since those are read-only after the bill is opened.
/// </summary>
Task<Bill?> LoadForEditAsync(int id);
/// <summary>
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
/// Includes Vendor so the list row can display vendor name without a second round trip.
/// LineItems are included for the search-in-description condition only.
/// </summary>
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
/// <summary>
/// Returns the last bill number with the given prefix (including soft-deleted records) for
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
/// </summary>
Task<string?> GetLastBillNumberAsync(string prefix);
/// <summary>
/// Returns the last payment number with the given prefix (including soft-deleted records)
/// for sequential payment reference generation.
/// </summary>
Task<string?> GetLastPaymentNumberAsync(string prefix);
}
@@ -1,4 +1,5 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
@@ -30,4 +31,22 @@ public interface IInvoiceRepository : IRepository<Invoice>
/// Returns null if the token does not match any invoice.
/// </summary>
Task<Invoice?> GetByPaymentTokenAsync(string token);
/// <summary>
/// Returns the last invoice number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted invoices) for sequential number generation.
/// </summary>
Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// Returns all non-deleted invoices that have at least one online payment for the given company
/// and date window, with Customer navigation loaded. Used by the Online Payments reconciliation view.
/// </summary>
Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to);
/// <summary>
/// Returns all non-deleted CreditDebitCard refunds for the given company and date window,
/// with Invoice→Customer navigation loaded. Used by the Online Payments reconciliation view.
/// </summary>
Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to);
}
@@ -42,4 +42,40 @@ public interface IJobRepository : IRepository<Job>
/// loaded. Used by the Details view changelog tab.
/// </summary>
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
/// <summary>
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted jobs) for sequential number generation.
/// </summary>
Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// Looks up a job created from <paramref name="quoteId"/> that may be incomplete (items not
/// saved). Ignores query filters so it catches soft-deleted leftover rows from a previous
/// failed conversion attempt. Used for orphan cleanup before retrying conversion.
/// </summary>
Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId);
/// <summary>
/// Loads all jobs scheduled for <paramref name="date"/> that are not in a terminal status,
/// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations.
/// Optionally filtered to a single worker when <paramref name="userId"/> is supplied.
/// Used by the ShopDisplay (TV board) action.
/// </summary>
Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null);
/// <summary>
/// Loads all active (non-terminal, non-hold, non-cancelled) jobs for a company's shop mobile
/// view, with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats).
/// Optionally filtered to a single worker when <paramref name="workerId"/> is supplied.
/// </summary>
Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null);
/// <summary>
/// Loads a single job with the navigations required by the costing breakdown endpoint:
/// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker).
/// Scoped to <paramref name="companyId"/> as an extra safety check.
/// Returns null if not found.
/// </summary>
Task<Job?> LoadForCostingAsync(int jobId, int companyId);
}
@@ -0,0 +1,30 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="NotificationLog"/> that adds IgnoreQueryFilters-based lookups
/// by entity FK (InvoiceId, QuoteId, JobId) on top of the generic CRUD interface.
/// All methods bypass soft-delete and tenant filters so notification history is always visible
/// regardless of whether the linked entity has been soft-deleted.
/// </summary>
public interface INotificationLogRepository : IRepository<NotificationLog>
{
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
}
@@ -3,6 +3,9 @@ using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats.</summary>
public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount);
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
@@ -16,6 +19,12 @@ public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
/// </summary>
Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId);
/// <summary>
/// Returns KPI aggregate stats for the Index view using a server-side projection so only three
/// columns are fetched rather than full entities.
/// </summary>
Task<PurchaseOrderStats> GetStatsAsync(int companyId);
/// <summary>
/// Returns a paged, filtered, and sorted list of purchase orders for the Index view.
/// All filter parameters are optional — passing null/empty applies no restriction for
@@ -2,6 +2,9 @@ using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Aggregate counts and totals for the Quotes Index stat cards.</summary>
public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue);
/// <summary>
/// Typed repository for <see cref="Quote"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
@@ -27,4 +30,24 @@ public interface IQuoteRepository : IRepository<Quote>
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
/// </summary>
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
/// <summary>
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
/// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup)
/// to classify open vs. approved/converted quotes.
/// </summary>
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
/// <summary>
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
/// because it skips the parent-quote navigations that callers already have.
/// </summary>
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
/// <summary>
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted quotes) for sequential number generation.
/// </summary>
Task<string?> GetLastQuoteNumberByPrefixAsync(int companyId, string prefix);
}