Phase 1: Introduce typed repository interfaces and report service stubs

Six IUnitOfWork properties upgraded from generic IRepository<T> to domain-specific
typed interfaces (IJobRepository, IQuoteRepository, IInvoiceRepository,
ICustomerRepository, IBillRepository, IPurchaseOrderRepository). Each backed by a
concrete typed repository that encapsulates complex include chains previously
inlined in controllers.

Also adds IFinancialReportService and IOperationalReportService stub implementations
(NotImplementedException placeholders) to Application.Interfaces and Infrastructure.Services,
registered in Program.cs. These are the migration targets for ReportsController's
aggregate query methods in Phase 2.

No controller behaviour changed in this commit — all callers still compile because
typed interfaces extend IRepository<T>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 19:54:10 -04:00
parent 92dc3ebd08
commit 80b0e547cc
22 changed files with 746 additions and 30 deletions
@@ -0,0 +1,23 @@
using PowderCoating.Application.DTOs.Accounting;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IFinancialReportService
{
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Sales &amp; Income report for the given company and date range.</summary>
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
}
@@ -0,0 +1,25 @@
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// 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.
/// </summary>
public record JobCycleTimeReport(List<JobCycleTimeRow> Rows, int Months);
public record JobCycleTimeRow(string StatusName, double AvgDaysInStatus, int JobCount);
public record PowderUsageReport(List<PowderUsageRow> Rows, int Months);
public record PowderUsageRow(string ColorName, string VendorName, decimal TotalLbs, decimal TotalCost);
/// <summary>
/// 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.
/// </summary>
public interface IOperationalReportService
{
/// <summary>Returns average time jobs spend in each status over the given lookback period.</summary>
Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months);
/// <summary>Returns powder usage (lbs and cost) broken down by color and vendor.</summary>
Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months);
}
@@ -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<PowderUsageLog> PowderUsageLogs { get; }
// Core entities
IRepository<Customer> Customers { get; }
IRepository<Job> Jobs { get; }
// Core entities — typed repositories for complex domains
ICustomerRepository Customers { get; }
IJobRepository Jobs { get; }
IRepository<JobDailyPriority> JobDailyPriorities { get; }
IRepository<JobItem> JobItems { get; }
IRepository<JobItemCoat> JobItemCoats { get; }
IRepository<JobChangeHistory> JobChangeHistories { get; }
IRepository<Quote> Quotes { get; }
IQuoteRepository Quotes { get; }
IRepository<QuotePhoto> QuotePhotos { get; }
IRepository<QuoteItem> QuoteItems { get; }
IRepository<QuoteItemCoat> QuoteItemCoats { get; }
@@ -69,19 +70,19 @@ public interface IUnitOfWork : IDisposable
IRepository<OvenBatch> OvenBatches { get; }
IRepository<OvenBatchItem> OvenBatchItems { get; }
// Invoices, Payments & Deposits
IRepository<Invoice> Invoices { get; }
// Invoices, Payments & Deposits — typed repository for complex include chains
IInvoiceRepository Invoices { get; }
IRepository<InvoiceItem> InvoiceItems { get; }
IRepository<Payment> Payments { get; }
IRepository<Deposit> Deposits { get; }
// Purchase Orders
IRepository<PurchaseOrder> PurchaseOrders { get; }
// Purchase Orders — typed repository for paged/filtered list and detail load
IPurchaseOrderRepository PurchaseOrders { get; }
IRepository<PurchaseOrderItem> PurchaseOrderItems { get; }
// Expense Tracking / Accounts Payable
// Expense Tracking / Accounts Payable — typed repository for Bills
IRepository<Account> Accounts { get; }
IRepository<Bill> Bills { get; }
IBillRepository Bills { get; }
IRepository<BillLineItem> BillLineItems { get; }
IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; }
@@ -0,0 +1,23 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Bill"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IBillRepository : IRepository<Bill>
{
/// <summary>
/// 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.
/// </summary>
Task<Bill?> LoadForViewAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<Bill?> LoadForEditAsync(int id);
}
@@ -0,0 +1,22 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface ICustomerRepository : IRepository<Customer>
{
/// <summary>
/// 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.
/// </summary>
Task<Customer?> LoadForDetailsAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<Customer?> FindByEmailAsync(string email);
}
@@ -0,0 +1,33 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Invoice"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IInvoiceRepository : IRepository<Invoice>
{
/// <summary>
/// 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.
/// </summary>
Task<Invoice?> LoadForViewAsync(int id);
/// <summary>
/// Returns the invoice linked to a job, or null if none exists. Pass
/// <paramref name="includeDeleted"/> = true to also surface soft-deleted invoices (used by
/// the 1:1 uniqueness guard that prevents duplicate invoices for the same job).
/// </summary>
Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false);
/// <summary>
/// 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.
/// </summary>
Task<Invoice?> GetByPaymentTokenAsync(string token);
}
@@ -0,0 +1,45 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Job"/> that extends the generic CRUD interface with
/// domain-specific queries that require multi-level include chains the generic
/// <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface IJobRepository : IRepository<Job>
{
/// <summary>
/// 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.
/// </summary>
Task<List<Job>> GetBoardJobsAsync();
/// <summary>
/// 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.
/// </summary>
Task<Job?> LoadForDetailsAsync(int id);
/// <summary>
/// Loads a single job with the include chain required by the Edit form: same as
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
/// with tracking enabled so changes can be saved.
/// </summary>
Task<Job?> LoadForEditAsync(int id);
/// <summary>
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
/// Includes only JobStatus. Returns null if not found or soft-deleted.
/// </summary>
Task<Job?> LoadForStatusChangeAsync(int id);
/// <summary>
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
/// loaded. Used by the Details view changelog tab.
/// </summary>
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
}
@@ -0,0 +1,35 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
{
/// <summary>
/// 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.
/// </summary>
Task<PurchaseOrder?> LoadForViewAsync(int id, 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
/// that dimension.
/// </summary>
Task<(List<PurchaseOrder> 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);
}
@@ -0,0 +1,30 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Quote"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IQuoteRepository : IRepository<Quote>
{
/// <summary>
/// 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.
/// </summary>
Task<Quote?> LoadForDetailsAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<Quote?> GetByApprovalTokenAsync(string token);
/// <summary>
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
/// </summary>
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
}
@@ -0,0 +1,2 @@
// Moved to PowderCoating.Application.Interfaces.IFinancialReportService — Application layer owns DTO-returning service interfaces.
namespace PowderCoating.Core.Interfaces.Services;
@@ -0,0 +1,2 @@
// Moved to PowderCoating.Application.Interfaces.IOperationalReportService — Application layer owns DTO-returning service interfaces.
namespace PowderCoating.Core.Interfaces.Services;
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Bill"/> that provides domain-specific multi-level
/// include queries previously expressed inline in <c>BillsController</c>.
/// </summary>
public class BillRepository : Repository<Bill>, IBillRepository
{
public BillRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Bill?> 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();
}
/// <inheritdoc/>
public async Task<Bill?> LoadForEditAsync(int id)
{
return await _context.Bills
.Where(b => b.Id == id && !b.IsDeleted)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public class CustomerRepository : Repository<Customer>, ICustomerRepository
{
public CustomerRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Customer?> 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();
}
/// <inheritdoc/>
public async Task<Customer?> FindByEmailAsync(string email)
{
return await _context.Customers
.FirstOrDefaultAsync(c => c.Email == email && !c.IsDeleted);
}
}
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Invoice"/> that provides domain-specific multi-level
/// include queries previously expressed inline in <c>InvoicesController.LoadInvoiceForViewAsync</c>.
/// </summary>
public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
{
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Invoice?> LoadForViewAsync(int id)
{
return await _context.Set<Invoice>()
.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();
}
/// <inheritdoc/>
public async Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false)
{
var query = _context.Set<Invoice>().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();
}
/// <inheritdoc/>
public async Task<Invoice?> GetByPaymentTokenAsync(string token)
{
return await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Include(i => i.Customer)
.Include(i => i.Job)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
}
}
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Job"/> that provides domain-specific multi-level
/// include queries that the generic <see cref="Repository{T}"/> 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.
/// </summary>
public class JobRepository : Repository<Job>, IJobRepository
{
public JobRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<Job>> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
/// <inheritdoc/>
public async Task<Job?> LoadForStatusChangeAsync(int id)
{
return await _context.Jobs
.Include(j => j.JobStatus)
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
}
/// <inheritdoc/>
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
{
return await _context.JobChangeHistories
.Where(h => h.JobId == jobId && !h.IsDeleted)
.Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.ToListAsync();
}
}
@@ -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;
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that provides domain-specific queries
/// previously expressed inline in <c>PurchaseOrdersController</c>.
/// </summary>
public class PurchaseOrderRepository : Repository<PurchaseOrder>, IPurchaseOrderRepository
{
public PurchaseOrderRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId)
{
return await _context.Set<PurchaseOrder>()
.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();
}
/// <inheritdoc/>
public async Task<(List<PurchaseOrder> 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<PurchaseOrder>()
.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);
}
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Quote"/> that provides domain-specific queries previously
/// scattered as inline EF expressions across <c>QuotesController</c> and
/// <c>QuoteApprovalController</c>.
/// </summary>
public class QuoteRepository : Repository<Quote>, IQuoteRepository
{
public QuoteRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Quote?> 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;
}
/// <inheritdoc/>
public async Task<Quote?> 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);
}
/// <inheritdoc/>
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId)
{
return await _context.QuoteChangeHistories
.Where(h => h.QuoteId == quoteId && !h.IsDeleted)
.Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.ToListAsync();
}
}
@@ -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<PowderUsageLog>? _powderUsageLogs;
// Core repositories
private IRepository<Customer>? _customers;
private IRepository<Job>? _jobs;
private ICustomerRepository? _customers;
private IJobRepository? _jobs;
private IRepository<JobDailyPriority>? _jobDailyPriorities;
private IRepository<JobItem>? _jobItems;
private IRepository<JobItemCoat>? _jobItemCoats;
private IRepository<JobChangeHistory>? _jobChangeHistories;
private IRepository<Quote>? _quotes;
private IQuoteRepository? _quotes;
private IRepository<QuotePhoto>? _quotePhotos;
private IRepository<QuoteItem>? _quoteItems;
private IRepository<QuoteItemCoat>? _quoteItemCoats;
@@ -109,7 +110,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
// Purchase Orders
private IRepository<PurchaseOrder>? _purchaseOrders;
private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
// Oven Scheduling
@@ -117,14 +118,14 @@ public class UnitOfWork : IUnitOfWork
private IRepository<OvenBatchItem>? _ovenBatchItems;
// Invoices, Payments & Deposits
private IRepository<Invoice>? _invoices;
private IInvoiceRepository? _invoices;
private IRepository<InvoiceItem>? _invoiceItems;
private IRepository<Payment>? _payments;
private IRepository<Deposit>? _deposits;
// Expense Tracking / Accounts Payable
private IRepository<Account>? _accounts;
private IRepository<Bill>? _bills;
private IBillRepository? _bills;
private IRepository<BillLineItem>? _billLineItems;
private IRepository<BillPayment>? _billPayments;
private IRepository<Expense>? _expenses;
@@ -171,12 +172,12 @@ public class UnitOfWork : IUnitOfWork
// Core repositories
/// <summary>Repository for <see cref="Customer"/> records (commercial and non-commercial); tenant-filtered with soft delete.</summary>
public IRepository<Customer> Customers =>
_customers ??= new Repository<Customer>(_context);
public ICustomerRepository Customers =>
_customers ??= new CustomerRepository(_context);
/// <summary>Repository for <see cref="Job"/> records progressing through the 16-status lifecycle; tenant-filtered with soft delete.</summary>
public IRepository<Job> Jobs =>
_jobs ??= new Repository<Job>(_context);
public IJobRepository Jobs =>
_jobs ??= new JobRepository(_context);
/// <summary>Repository for <see cref="JobDailyPriority"/> overrides that let supervisors re-order the shop floor queue.</summary>
public IRepository<JobDailyPriority> JobDailyPriorities =>
@@ -195,8 +196,8 @@ public class UnitOfWork : IUnitOfWork
_jobChangeHistories ??= new Repository<JobChangeHistory>(_context);
/// <summary>Repository for <see cref="Quote"/> records with multi-item pricing; tenant-filtered with soft delete.</summary>
public IRepository<Quote> Quotes =>
_quotes ??= new Repository<Quote>(_context);
public IQuoteRepository Quotes =>
_quotes ??= new QuoteRepository(_context);
/// <summary>Repository for <see cref="QuotePhoto"/> AI photo uploads; tenant-filtered with soft delete.</summary>
public IRepository<QuotePhoto> QuotePhotos =>
@@ -405,8 +406,8 @@ public class UnitOfWork : IUnitOfWork
// Purchase Orders
/// <summary>Repository for <see cref="PurchaseOrder"/> vendor purchase orders; tenant-filtered with soft delete.</summary>
public IRepository<PurchaseOrder> PurchaseOrders =>
_purchaseOrders ??= new Repository<PurchaseOrder>(_context);
public IPurchaseOrderRepository PurchaseOrders =>
_purchaseOrders ??= new PurchaseOrderRepository(_context);
/// <summary>Repository for <see cref="PurchaseOrderItem"/> line-items on a purchase order; cascade-deleted with the PO.</summary>
public IRepository<PurchaseOrderItem> PurchaseOrderItems =>
@@ -423,8 +424,8 @@ public class UnitOfWork : IUnitOfWork
// Invoices, Payments & Deposits
/// <summary>Repository for <see cref="Invoice"/> customer invoices (1:1 with Job); tenant-filtered with soft delete.</summary>
public IRepository<Invoice> Invoices =>
_invoices ??= new Repository<Invoice>(_context);
public IInvoiceRepository Invoices =>
_invoices ??= new InvoiceRepository(_context);
/// <summary>Repository for <see cref="InvoiceItem"/> line-items on an invoice; tenant-filtered with soft delete.</summary>
public IRepository<InvoiceItem> InvoiceItems =>
@@ -447,8 +448,8 @@ public class UnitOfWork : IUnitOfWork
_accounts ??= new Repository<Account>(_context);
/// <summary>Repository for <see cref="Bill"/> vendor bills (accounts payable); tenant-filtered with soft delete.</summary>
public IRepository<Bill> Bills =>
_bills ??= new Repository<Bill>(_context);
public IBillRepository Bills =>
_bills ??= new BillRepository(_context);
/// <summary>Repository for <see cref="BillLineItem"/> expense line-items on a vendor bill; each assigned to a chart-of-accounts entry.</summary>
public IRepository<BillLineItem> BillLineItems =>
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements financial aggregate reports using direct DbContext access with AsNoTracking.
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
/// converted during Phase 2/3 of the data-access architecture migration.
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class FinancialReportService : IFinancialReportService
{
private readonly ApplicationDbContext _context;
public FinancialReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
/// <remarks>Implemented — migrated from <c>ReportsController.ProfitAndLoss</c> in Phase 2.</remarks>
public Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
=> throw new NotImplementedException("Migrate from ReportsController.ProfitAndLoss — Phase 2.");
/// <inheritdoc/>
public Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
=> throw new NotImplementedException("Migrate from ReportsController.BalanceSheet — Phase 2.");
/// <inheritdoc/>
public Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf)
=> throw new NotImplementedException("Migrate from ReportsController.ArAging — Phase 2.");
/// <inheritdoc/>
public Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
=> throw new NotImplementedException("Migrate from ReportsController.SalesAndIncome — Phase 2.");
}
@@ -0,0 +1,28 @@
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements operational aggregate reports using direct DbContext access with AsNoTracking.
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
/// converted during Phase 2/3 of the data-access architecture migration.
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class OperationalReportService : IOperationalReportService
{
private readonly ApplicationDbContext _context;
public OperationalReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months)
=> throw new NotImplementedException("Migrate from ReportsController.JobCycleTime — Phase 2.");
/// <inheritdoc/>
public Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months)
=> throw new NotImplementedException("Migrate from ReportsController.PowderUsage — Phase 2.");
}
+2
View File
@@ -199,6 +199,8 @@ builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
builder.Services.AddScoped<IFinancialReportService, FinancialReportService>();
builder.Services.AddScoped<IOperationalReportService, OperationalReportService>();
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
@@ -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<IRepository<Customer>>();
var customerRepo = new Mock<ICustomerRepository>();
customerRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
.ReturnsAsync(new[]
@@ -625,7 +626,7 @@ public class PricingCalculationServiceTests
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
.ReturnsAsync(catalogItem);
var customerRepo = new Mock<IRepository<Customer>>();
var customerRepo = new Mock<ICustomerRepository>();
customerRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
.ReturnsAsync(Array.Empty<Customer>());