diff --git a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs
new file mode 100644
index 0000000..7537dc1
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs
@@ -0,0 +1,23 @@
+using PowderCoating.Application.DTOs.Accounting;
+
+namespace PowderCoating.Application.Interfaces;
+
+///
+/// Read-only service for financial aggregate reports. All methods query the database
+/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
+/// Implemented in Infrastructure; uses ApplicationDbContext directly.
+///
+public interface IFinancialReportService
+{
+ /// Returns a Profit & Loss report for the given company and date range.
+ Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
+
+ /// Returns a Balance Sheet snapshot as of the given date.
+ Task GetBalanceSheetAsync(int companyId, DateTime asOf);
+
+ /// Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.
+ Task GetArAgingAsync(int companyId, DateTime asOf);
+
+ /// Returns a Sales & Income report for the given company and date range.
+ Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
+}
diff --git a/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs
new file mode 100644
index 0000000..1a7f18c
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs
@@ -0,0 +1,25 @@
+namespace PowderCoating.Application.Interfaces;
+
+///
+/// Placeholder return types for operational reports. These will be replaced with proper
+/// Application DTOs as each report is migrated from ReportsController in Phase 2/3.
+///
+public record JobCycleTimeReport(List Rows, int Months);
+public record JobCycleTimeRow(string StatusName, double AvgDaysInStatus, int JobCount);
+
+public record PowderUsageReport(List Rows, int Months);
+public record PowderUsageRow(string ColorName, string VendorName, decimal TotalLbs, decimal TotalCost);
+
+///
+/// Read-only service for operational aggregate reports. All methods query the database
+/// with AsNoTracking and return pre-shaped objects — no tracked entities are returned.
+/// Implemented in Infrastructure; uses ApplicationDbContext directly.
+///
+public interface IOperationalReportService
+{
+ /// Returns average time jobs spend in each status over the given lookback period.
+ Task GetJobCycleTimeAsync(int companyId, int months);
+
+ /// Returns powder usage (lbs and cost) broken down by color and vendor.
+ Task GetPowderUsageAsync(int companyId, int months);
+}
diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
index d0070fb..03d8330 100644
--- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
+++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
@@ -1,4 +1,5 @@
using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
namespace PowderCoating.Core.Interfaces;
@@ -15,14 +16,14 @@ public interface IUnitOfWork : IDisposable
// Powder Insights
IRepository PowderUsageLogs { get; }
- // Core entities
- IRepository Customers { get; }
- IRepository Jobs { get; }
+ // Core entities — typed repositories for complex domains
+ ICustomerRepository Customers { get; }
+ IJobRepository Jobs { get; }
IRepository JobDailyPriorities { get; }
IRepository JobItems { get; }
IRepository JobItemCoats { get; }
IRepository JobChangeHistories { get; }
- IRepository Quotes { get; }
+ IQuoteRepository Quotes { get; }
IRepository QuotePhotos { get; }
IRepository QuoteItems { get; }
IRepository QuoteItemCoats { get; }
@@ -69,19 +70,19 @@ public interface IUnitOfWork : IDisposable
IRepository OvenBatches { get; }
IRepository OvenBatchItems { get; }
- // Invoices, Payments & Deposits
- IRepository Invoices { get; }
+ // Invoices, Payments & Deposits — typed repository for complex include chains
+ IInvoiceRepository Invoices { get; }
IRepository InvoiceItems { get; }
IRepository Payments { get; }
IRepository Deposits { get; }
- // Purchase Orders
- IRepository PurchaseOrders { get; }
+ // Purchase Orders — typed repository for paged/filtered list and detail load
+ IPurchaseOrderRepository PurchaseOrders { get; }
IRepository PurchaseOrderItems { get; }
- // Expense Tracking / Accounts Payable
+ // Expense Tracking / Accounts Payable — typed repository for Bills
IRepository Accounts { get; }
- IRepository Bills { get; }
+ IBillRepository Bills { get; }
IRepository BillLineItems { get; }
IRepository BillPayments { get; }
IRepository Expenses { get; }
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs
new file mode 100644
index 0000000..52a3881
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs
@@ -0,0 +1,23 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds domain-specific queries on top of
+/// the generic CRUD interface.
+///
+public interface IBillRepository : IRepository
+{
+ ///
+ /// Loads a single bill with the full include chain required by the Details view: Vendor,
+ /// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
+ /// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
+ ///
+ Task LoadForViewAsync(int id);
+
+ ///
+ /// Loads a single bill with only its line items for the Edit form. Excludes payment
+ /// navigations since those are read-only after the bill is opened.
+ ///
+ Task LoadForEditAsync(int id);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/ICustomerRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/ICustomerRepository.cs
new file mode 100644
index 0000000..dc5c1b2
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/ICustomerRepository.cs
@@ -0,0 +1,22 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds domain-specific queries on top of
+/// the generic CRUD interface.
+///
+public interface ICustomerRepository : IRepository
+{
+ ///
+ /// Loads a single customer with the navigations needed by the Details view: PricingTier,
+ /// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
+ ///
+ Task LoadForDetailsAsync(int id);
+
+ ///
+ /// Finds a customer by email address within the current tenant. Used for duplicate-email
+ /// validation on create and edit. Returns null if no match is found.
+ ///
+ Task FindByEmailAsync(string email);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs
new file mode 100644
index 0000000..b6caf21
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs
@@ -0,0 +1,33 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds domain-specific queries on top of
+/// the generic CRUD interface.
+///
+public interface IInvoiceRepository : IRepository
+{
+ ///
+ /// Loads a single invoice with the full eight-table include chain required by the Details
+ /// view and PDF generation: Customer, Job, PreparedBy, SalesTaxAccount, InvoiceItems with
+ /// RevenueAccount and GeneratedGiftCertificate, Payments with RecordedBy and DepositAccount,
+ /// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
+ /// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
+ ///
+ Task LoadForViewAsync(int id);
+
+ ///
+ /// Returns the invoice linked to a job, or null if none exists. Pass
+ /// = true to also surface soft-deleted invoices (used by
+ /// the 1:1 uniqueness guard that prevents duplicate invoices for the same job).
+ ///
+ Task GetForJobAsync(int jobId, bool includeDeleted = false);
+
+ ///
+ /// Looks up an invoice by its online-payment token. Ignores query filters so the payment
+ /// portal can load the invoice even when the anonymous request has no tenant context.
+ /// Returns null if the token does not match any invoice.
+ ///
+ Task GetByPaymentTokenAsync(string token);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs
new file mode 100644
index 0000000..4173b9e
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs
@@ -0,0 +1,45 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that extends the generic CRUD interface with
+/// domain-specific queries that require multi-level include chains the generic
+/// cannot express.
+///
+public interface IJobRepository : IRepository
+{
+ ///
+ /// Loads all active jobs with the minimal set of navigations needed to render the Kanban
+ /// board columns (Customer name, status, priority, assigned user, due date). Uses
+ /// AsNoTracking for read performance.
+ ///
+ Task> GetBoardJobsAsync();
+
+ ///
+ /// Loads a single job with the full include chain required by the Details view: Customer,
+ /// JobStatus, JobPriority, AssignedUser, Quote, OvenCost, OriginalJob, IntakeCheckedBy,
+ /// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
+ /// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
+ ///
+ Task LoadForDetailsAsync(int id);
+
+ ///
+ /// Loads a single job with the include chain required by the Edit form: same as
+ /// but without the read-only audit navigations, and
+ /// with tracking enabled so changes can be saved.
+ ///
+ Task LoadForEditAsync(int id);
+
+ ///
+ /// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
+ /// Includes only JobStatus. Returns null if not found or soft-deleted.
+ ///
+ Task LoadForStatusChangeAsync(int id);
+
+ ///
+ /// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
+ /// loaded. Used by the Details view changelog tab.
+ ///
+ Task> GetChangeHistoryAsync(int jobId);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs
new file mode 100644
index 0000000..d3135e2
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs
@@ -0,0 +1,35 @@
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds domain-specific queries on top of
+/// the generic CRUD interface.
+///
+public interface IPurchaseOrderRepository : IRepository
+{
+ ///
+ /// Loads a single purchase order with the full include chain required by the Details view:
+ /// Vendor, Bill, and Items (filtered to non-deleted) with InventoryItem navigation.
+ /// Returns null if not found or not owned by the current tenant.
+ ///
+ Task LoadForViewAsync(int id, int companyId);
+
+ ///
+ /// 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
+ /// that dimension.
+ ///
+ Task<(List Items, int TotalCount)> GetPagedAsync(
+ int companyId,
+ int pageNumber,
+ int pageSize,
+ PurchaseOrderStatus? statusFilter = null,
+ int? vendorId = null,
+ DateTime? dateFrom = null,
+ DateTime? dateTo = null,
+ string? searchTerm = null,
+ string? sortColumn = null,
+ string? sortDirection = null);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs
new file mode 100644
index 0000000..47527fa
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs
@@ -0,0 +1,30 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds domain-specific queries on top of
+/// the generic CRUD interface.
+///
+public interface IQuoteRepository : IRepository
+{
+ ///
+ /// Loads a single quote with the full include chain required by the Details view: Customer,
+ /// PreparedBy, QuoteStatus, OvenCost, QuoteItems with Coats (InventoryItem + Vendor),
+ /// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
+ /// Returns null if not found or soft-deleted.
+ ///
+ Task LoadForDetailsAsync(int id);
+
+ ///
+ /// Loads a single quote by its customer-facing approval token. Ignores global query filters
+ /// so the unauthenticated approval portal can resolve any tenant's quote by token alone.
+ /// Includes Customer navigation. Returns null if the token does not match any live quote.
+ ///
+ Task GetByApprovalTokenAsync(string token);
+
+ ///
+ /// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
+ ///
+ Task> GetChangeHistoryAsync(int quoteId);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Services/IFinancialReportService.cs b/src/PowderCoating.Core/Interfaces/Services/IFinancialReportService.cs
new file mode 100644
index 0000000..c358076
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Services/IFinancialReportService.cs
@@ -0,0 +1,2 @@
+// Moved to PowderCoating.Application.Interfaces.IFinancialReportService — Application layer owns DTO-returning service interfaces.
+namespace PowderCoating.Core.Interfaces.Services;
diff --git a/src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs b/src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs
new file mode 100644
index 0000000..906c5cc
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs
@@ -0,0 +1,2 @@
+// Moved to PowderCoating.Application.Interfaces.IOperationalReportService — Application layer owns DTO-returning service interfaces.
+namespace PowderCoating.Core.Interfaces.Services;
diff --git a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs
new file mode 100644
index 0000000..2930058
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides domain-specific multi-level
+/// include queries previously expressed inline in BillsController.
+///
+public class BillRepository : Repository, IBillRepository
+{
+ public BillRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForViewAsync(int id)
+ {
+ return await _context.Bills
+ .Where(b => b.Id == id && !b.IsDeleted)
+ .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();
+ }
+
+ ///
+ public async Task LoadForEditAsync(int id)
+ {
+ return await _context.Bills
+ .Where(b => b.Id == id && !b.IsDeleted)
+ .Include(b => b.LineItems.Where(li => !li.IsDeleted))
+ .FirstOrDefaultAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/CustomerRepository.cs b/src/PowderCoating.Infrastructure/Repositories/CustomerRepository.cs
new file mode 100644
index 0000000..175be52
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/CustomerRepository.cs
@@ -0,0 +1,32 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that adds domain-specific queries on top of
+/// the generic CRUD interface.
+///
+public class CustomerRepository : Repository, ICustomerRepository
+{
+ public CustomerRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForDetailsAsync(int id)
+ {
+ return await _context.Customers
+ .Where(c => c.Id == id && !c.IsDeleted)
+ .Include(c => c.PricingTier)
+ .Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task FindByEmailAsync(string email)
+ {
+ return await _context.Customers
+ .FirstOrDefaultAsync(c => c.Email == email && !c.IsDeleted);
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs b/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs
new file mode 100644
index 0000000..c12429f
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs
@@ -0,0 +1,62 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides domain-specific multi-level
+/// include queries previously expressed inline in InvoicesController.LoadInvoiceForViewAsync.
+///
+public class InvoiceRepository : Repository, IInvoiceRepository
+{
+ public InvoiceRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForViewAsync(int id)
+ {
+ return await _context.Set()
+ .Where(i => i.Id == id && !i.IsDeleted)
+ .Include(i => i.Customer)
+ .Include(i => i.Job)
+ .Include(i => i.PreparedBy)
+ .Include(i => i.SalesTaxAccount)
+ .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
+ .ThenInclude(ii => ii.RevenueAccount)
+ .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
+ .ThenInclude(ii => ii.GeneratedGiftCertificate)
+ .Include(i => i.Payments.Where(p => !p.IsDeleted))
+ .ThenInclude(p => p.RecordedBy)
+ .Include(i => i.Payments.Where(p => !p.IsDeleted))
+ .ThenInclude(p => p.DepositAccount)
+ .Include(i => i.Refunds.Where(r => !r.IsDeleted))
+ .ThenInclude(r => r.IssuedBy)
+ .Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted))
+ .ThenInclude(ca => ca.CreditMemo)
+ .Include(i => i.GiftCertificateRedemptions)
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task GetForJobAsync(int jobId, bool includeDeleted = false)
+ {
+ var query = _context.Set().Where(i => i.JobId == jobId);
+ if (!includeDeleted)
+ query = query.Where(i => !i.IsDeleted);
+ else
+ query = query.IgnoreQueryFilters().Where(i => i.JobId == jobId);
+ return await query.FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task GetByPaymentTokenAsync(string token)
+ {
+ return await _context.Set()
+ .IgnoreQueryFilters()
+ .Include(i => i.Customer)
+ .Include(i => i.Job)
+ .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
+ .FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs
new file mode 100644
index 0000000..38aed06
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs
@@ -0,0 +1,106 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides domain-specific multi-level
+/// include queries that the generic cannot express.
+/// The base class handles all standard CRUD operations; this class adds read queries
+/// that were previously scattered as inline EF expressions inside controllers.
+///
+public class JobRepository : Repository, IJobRepository
+{
+ public JobRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task> GetBoardJobsAsync()
+ {
+ return await _context.Jobs
+ .AsNoTracking()
+ .Where(j => !j.IsDeleted)
+ .Include(j => j.Customer)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobPriority)
+ .Include(j => j.AssignedUser)
+ .OrderBy(j => j.DueDate.HasValue ? 0 : 1)
+ .ThenBy(j => j.DueDate)
+ .ThenBy(j => j.JobPriority!.DisplayOrder)
+ .ToListAsync();
+ }
+
+ ///
+ public async Task LoadForDetailsAsync(int id)
+ {
+ // Single query replaces the per-item N+1 loop that was in JobsController.Details.
+ // EF Core splits the multi-level ThenIncludes across two SQL queries automatically
+ // (split query behavior), keeping result set size manageable.
+ return await _context.Jobs
+ .Where(j => j.Id == id && !j.IsDeleted)
+ .Include(j => j.Customer)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobPriority)
+ .Include(j => j.AssignedUser)
+ .Include(j => j.Quote)
+ .Include(j => j.OvenCost)
+ .Include(j => j.OriginalJob)
+ .Include(j => j.IntakeCheckedBy)
+ .Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
+ .ThenInclude(ji => ji.Coats)
+ .ThenInclude(c => c.InventoryItem)
+ .Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
+ .ThenInclude(ji => ji.Coats)
+ .ThenInclude(c => c.Vendor)
+ .Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
+ .ThenInclude(ji => ji.PrepServices)
+ .ThenInclude(ps => ps.PrepService)
+ .Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted))
+ .ThenInclude(jps => jps.PrepService)
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task LoadForEditAsync(int id)
+ {
+ return await _context.Jobs
+ .Where(j => j.Id == id && !j.IsDeleted)
+ .Include(j => j.Customer)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobPriority)
+ .Include(j => j.AssignedUser)
+ .Include(j => j.OvenCost)
+ .Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
+ .ThenInclude(ji => ji.Coats)
+ .ThenInclude(c => c.InventoryItem)
+ .Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
+ .ThenInclude(ji => ji.Coats)
+ .ThenInclude(c => c.Vendor)
+ .Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
+ .ThenInclude(ji => ji.PrepServices)
+ .ThenInclude(ps => ps.PrepService)
+ .Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted))
+ .ThenInclude(jps => jps.PrepService)
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task LoadForStatusChangeAsync(int id)
+ {
+ return await _context.Jobs
+ .Include(j => j.JobStatus)
+ .FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
+ }
+
+ ///
+ public async Task> GetChangeHistoryAsync(int jobId)
+ {
+ return await _context.JobChangeHistories
+ .Where(h => h.JobId == jobId && !h.IsDeleted)
+ .Include(h => h.ChangedBy)
+ .OrderByDescending(h => h.ChangedAt)
+ .AsNoTracking()
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs
new file mode 100644
index 0000000..1d7b842
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs
@@ -0,0 +1,94 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides domain-specific queries
+/// previously expressed inline in PurchaseOrdersController.
+///
+public class PurchaseOrderRepository : Repository, IPurchaseOrderRepository
+{
+ public PurchaseOrderRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForViewAsync(int id, int companyId)
+ {
+ return await _context.Set()
+ .Where(p => p.Id == id && !p.IsDeleted && p.CompanyId == companyId)
+ .Include(p => p.Vendor)
+ .Include(p => p.Bill)
+ .Include(p => p.Items.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.InventoryItem)
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task<(List Items, int TotalCount)> GetPagedAsync(
+ int companyId,
+ int pageNumber,
+ int pageSize,
+ PurchaseOrderStatus? statusFilter = null,
+ int? vendorId = null,
+ DateTime? dateFrom = null,
+ DateTime? dateTo = null,
+ string? searchTerm = null,
+ string? sortColumn = null,
+ string? sortDirection = null)
+ {
+ var query = _context.Set()
+ .Include(po => po.Vendor)
+ .Include(po => po.Items.Where(i => !i.IsDeleted))
+ .Where(po => !po.IsDeleted && po.CompanyId == companyId)
+ .AsQueryable();
+
+ if (statusFilter.HasValue)
+ query = query.Where(po => po.Status == statusFilter.Value);
+
+ if (vendorId.HasValue)
+ query = query.Where(po => po.VendorId == vendorId.Value);
+
+ if (dateFrom.HasValue)
+ query = query.Where(po => po.OrderDate >= dateFrom.Value);
+
+ if (dateTo.HasValue)
+ query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1));
+
+ if (!string.IsNullOrWhiteSpace(searchTerm))
+ {
+ var term = searchTerm.Trim().ToLower();
+ query = query.Where(po =>
+ po.PoNumber.ToLower().Contains(term) ||
+ po.Vendor.CompanyName.ToLower().Contains(term) ||
+ (po.Notes != null && po.Notes.ToLower().Contains(term)));
+ }
+
+ query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch
+ {
+ ("ponumber", "asc") => query.OrderBy(po => po.PoNumber),
+ ("ponumber", _) => query.OrderByDescending(po => po.PoNumber),
+ ("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName),
+ ("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName),
+ ("status", "asc") => query.OrderBy(po => po.Status),
+ ("status", _) => query.OrderByDescending(po => po.Status),
+ ("orderdate", "asc") => query.OrderBy(po => po.OrderDate),
+ ("orderdate", _) => query.OrderByDescending(po => po.OrderDate),
+ ("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate),
+ ("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate),
+ ("total", "asc") => query.OrderBy(po => po.TotalAmount),
+ ("total", _) => query.OrderByDescending(po => po.TotalAmount),
+ _ => query.OrderByDescending(po => po.OrderDate)
+ };
+
+ var totalCount = await query.CountAsync();
+ var items = await query
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync();
+
+ return (items, totalCount);
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs b/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs
new file mode 100644
index 0000000..e707607
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs
@@ -0,0 +1,70 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides domain-specific queries previously
+/// scattered as inline EF expressions across QuotesController and
+/// QuoteApprovalController.
+///
+public class QuoteRepository : Repository, IQuoteRepository
+{
+ public QuoteRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForDetailsAsync(int id)
+ {
+ var quote = await _context.Quotes
+ .Where(q => q.Id == id && !q.IsDeleted)
+ .Include(q => q.Customer)
+ .Include(q => q.PreparedBy)
+ .Include(q => q.QuoteStatus)
+ .Include(q => q.OvenCost)
+ .Include(q => q.QuotePrepServices.Where(qps => !qps.IsDeleted))
+ .ThenInclude(qps => qps.PrepService)
+ .FirstOrDefaultAsync();
+
+ if (quote == null) return null;
+
+ // QuoteItems with nested coats and prep services loaded separately to avoid
+ // cartesian explosion from multiple collection includes in a single query.
+ quote.QuoteItems = await _context.QuoteItems
+ .Where(qi => qi.QuoteId == id && !qi.IsDeleted)
+ .Include(qi => qi.Coats)
+ .ThenInclude(c => c.InventoryItem)
+ .Include(qi => qi.Coats)
+ .ThenInclude(c => c.Vendor)
+ .Include(qi => qi.CatalogItem)
+ .Include(qi => qi.PrepServices)
+ .ThenInclude(ps => ps.PrepService)
+ .ToListAsync();
+
+ return quote;
+ }
+
+ ///
+ public async Task GetByApprovalTokenAsync(string token)
+ {
+ // IgnoreQueryFilters: approval portal is unauthenticated — no tenant context on the request.
+ return await _context.Quotes
+ .IgnoreQueryFilters()
+ .Include(q => q.Customer)
+ .Include(q => q.QuoteStatus)
+ .Include(q => q.QuoteItems.Where(qi => !qi.IsDeleted))
+ .FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
+ }
+
+ ///
+ public async Task> GetChangeHistoryAsync(int quoteId)
+ {
+ return await _context.QuoteChangeHistories
+ .Where(h => h.QuoteId == quoteId && !h.IsDeleted)
+ .Include(h => h.ChangedBy)
+ .OrderByDescending(h => h.ChangedAt)
+ .AsNoTracking()
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
index 9b4bdde..585964d 100644
--- a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
+++ b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
+using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
@@ -43,13 +44,13 @@ public class UnitOfWork : IUnitOfWork
private IRepository? _powderUsageLogs;
// Core repositories
- private IRepository? _customers;
- private IRepository? _jobs;
+ private ICustomerRepository? _customers;
+ private IJobRepository? _jobs;
private IRepository? _jobDailyPriorities;
private IRepository? _jobItems;
private IRepository? _jobItemCoats;
private IRepository? _jobChangeHistories;
- private IRepository? _quotes;
+ private IQuoteRepository? _quotes;
private IRepository? _quotePhotos;
private IRepository? _quoteItems;
private IRepository? _quoteItemCoats;
@@ -109,7 +110,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository? _giftCertificateRedemptions;
// Purchase Orders
- private IRepository? _purchaseOrders;
+ private IPurchaseOrderRepository? _purchaseOrders;
private IRepository? _purchaseOrderItems;
// Oven Scheduling
@@ -117,14 +118,14 @@ public class UnitOfWork : IUnitOfWork
private IRepository? _ovenBatchItems;
// Invoices, Payments & Deposits
- private IRepository? _invoices;
+ private IInvoiceRepository? _invoices;
private IRepository? _invoiceItems;
private IRepository? _payments;
private IRepository? _deposits;
// Expense Tracking / Accounts Payable
private IRepository? _accounts;
- private IRepository? _bills;
+ private IBillRepository? _bills;
private IRepository? _billLineItems;
private IRepository? _billPayments;
private IRepository? _expenses;
@@ -171,12 +172,12 @@ public class UnitOfWork : IUnitOfWork
// Core repositories
/// Repository for records (commercial and non-commercial); tenant-filtered with soft delete.
- public IRepository Customers =>
- _customers ??= new Repository(_context);
+ public ICustomerRepository Customers =>
+ _customers ??= new CustomerRepository(_context);
/// Repository for records progressing through the 16-status lifecycle; tenant-filtered with soft delete.
- public IRepository Jobs =>
- _jobs ??= new Repository(_context);
+ public IJobRepository Jobs =>
+ _jobs ??= new JobRepository(_context);
/// Repository for overrides that let supervisors re-order the shop floor queue.
public IRepository JobDailyPriorities =>
@@ -195,8 +196,8 @@ public class UnitOfWork : IUnitOfWork
_jobChangeHistories ??= new Repository(_context);
/// Repository for records with multi-item pricing; tenant-filtered with soft delete.
- public IRepository Quotes =>
- _quotes ??= new Repository(_context);
+ public IQuoteRepository Quotes =>
+ _quotes ??= new QuoteRepository(_context);
/// Repository for AI photo uploads; tenant-filtered with soft delete.
public IRepository QuotePhotos =>
@@ -405,8 +406,8 @@ public class UnitOfWork : IUnitOfWork
// Purchase Orders
/// Repository for vendor purchase orders; tenant-filtered with soft delete.
- public IRepository PurchaseOrders =>
- _purchaseOrders ??= new Repository(_context);
+ public IPurchaseOrderRepository PurchaseOrders =>
+ _purchaseOrders ??= new PurchaseOrderRepository(_context);
/// Repository for line-items on a purchase order; cascade-deleted with the PO.
public IRepository PurchaseOrderItems =>
@@ -423,8 +424,8 @@ public class UnitOfWork : IUnitOfWork
// Invoices, Payments & Deposits
/// Repository for customer invoices (1:1 with Job); tenant-filtered with soft delete.
- public IRepository Invoices =>
- _invoices ??= new Repository(_context);
+ public IInvoiceRepository Invoices =>
+ _invoices ??= new InvoiceRepository(_context);
/// Repository for line-items on an invoice; tenant-filtered with soft delete.
public IRepository InvoiceItems =>
@@ -447,8 +448,8 @@ public class UnitOfWork : IUnitOfWork
_accounts ??= new Repository(_context);
/// Repository for vendor bills (accounts payable); tenant-filtered with soft delete.
- public IRepository Bills =>
- _bills ??= new Repository(_context);
+ public IBillRepository Bills =>
+ _bills ??= new BillRepository(_context);
/// Repository for expense line-items on a vendor bill; each assigned to a chart-of-accounts entry.
public IRepository BillLineItems =>
diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
new file mode 100644
index 0000000..1463116
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
@@ -0,0 +1,39 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Application.DTOs.Accounting;
+using PowderCoating.Application.Interfaces;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Implements financial aggregate reports using direct DbContext access with AsNoTracking.
+/// Query logic is migrated here from ReportsController as each report action is
+/// converted during Phase 2/3 of the data-access architecture migration.
+/// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan.
+///
+public class FinancialReportService : IFinancialReportService
+{
+ private readonly ApplicationDbContext _context;
+
+ public FinancialReportService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Implemented — migrated from ReportsController.ProfitAndLoss in Phase 2.
+ public Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
+ => throw new NotImplementedException("Migrate from ReportsController.ProfitAndLoss — Phase 2.");
+
+ ///
+ public Task GetBalanceSheetAsync(int companyId, DateTime asOf)
+ => throw new NotImplementedException("Migrate from ReportsController.BalanceSheet — Phase 2.");
+
+ ///
+ public Task GetArAgingAsync(int companyId, DateTime asOf)
+ => throw new NotImplementedException("Migrate from ReportsController.ArAging — Phase 2.");
+
+ ///
+ public Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
+ => throw new NotImplementedException("Migrate from ReportsController.SalesAndIncome — Phase 2.");
+}
diff --git a/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs
new file mode 100644
index 0000000..b080f2c
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs
@@ -0,0 +1,28 @@
+using PowderCoating.Application.Interfaces;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Implements operational aggregate reports using direct DbContext access with AsNoTracking.
+/// Query logic is migrated here from ReportsController as each report action is
+/// converted during Phase 2/3 of the data-access architecture migration.
+/// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan.
+///
+public class OperationalReportService : IOperationalReportService
+{
+ private readonly ApplicationDbContext _context;
+
+ public OperationalReportService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public Task GetJobCycleTimeAsync(int companyId, int months)
+ => throw new NotImplementedException("Migrate from ReportsController.JobCycleTime — Phase 2.");
+
+ ///
+ public Task GetPowderUsageAsync(int companyId, int months)
+ => throw new NotImplementedException("Migrate from ReportsController.PowderUsage — Phase 2.");
+}
diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs
index 19c74fa..bbc936a 100644
--- a/src/PowderCoating.Web/Program.cs
+++ b/src/PowderCoating.Web/Program.cs
@@ -199,6 +199,8 @@ builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs
index 8419a16..55a1cec 100644
--- a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs
+++ b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs
@@ -4,6 +4,7 @@ using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
+using PowderCoating.Core.Interfaces.Repositories;
using Xunit;
namespace PowderCoating.UnitTests;
@@ -548,7 +549,7 @@ public class PricingCalculationServiceTests
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
- var customerRepo = new Mock>();
+ var customerRepo = new Mock();
customerRepo
.Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>()))
.ReturnsAsync(new[]
@@ -625,7 +626,7 @@ public class PricingCalculationServiceTests
.Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>()))
.ReturnsAsync(catalogItem);
- var customerRepo = new Mock>();
+ var customerRepo = new Mock();
customerRepo
.Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>()))
.ReturnsAsync(Array.Empty());