From 80b0e547ccdc3537b0b57fbb829e9cdd49eeecb8 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Mon, 27 Apr 2026 19:54:10 -0400 Subject: [PATCH] Phase 1: Introduce typed repository interfaces and report service stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six IUnitOfWork properties upgraded from generic IRepository 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. Co-Authored-By: Claude Sonnet 4.6 --- .../Interfaces/IFinancialReportService.cs | 23 ++++ .../Interfaces/IOperationalReportService.cs | 25 +++++ .../Interfaces/IUnitOfWork.cs | 21 ++-- .../Repositories/IBillRepository.cs | 23 ++++ .../Repositories/ICustomerRepository.cs | 22 ++++ .../Repositories/IInvoiceRepository.cs | 33 ++++++ .../Interfaces/Repositories/IJobRepository.cs | 45 ++++++++ .../Repositories/IPurchaseOrderRepository.cs | 35 ++++++ .../Repositories/IQuoteRepository.cs | 30 +++++ .../Services/IFinancialReportService.cs | 2 + .../Services/IOperationalReportService.cs | 2 + .../Repositories/BillRepository.cs | 40 +++++++ .../Repositories/CustomerRepository.cs | 32 ++++++ .../Repositories/InvoiceRepository.cs | 62 ++++++++++ .../Repositories/JobRepository.cs | 106 ++++++++++++++++++ .../Repositories/PurchaseOrderRepository.cs | 94 ++++++++++++++++ .../Repositories/QuoteRepository.cs | 70 ++++++++++++ .../Repositories/UnitOfWork.cs | 37 +++--- .../Services/FinancialReportService.cs | 39 +++++++ .../Services/OperationalReportService.cs | 28 +++++ src/PowderCoating.Web/Program.cs | 2 + .../PricingCalculationServiceTests.cs | 5 +- 22 files changed, 746 insertions(+), 30 deletions(-) create mode 100644 src/PowderCoating.Application/Interfaces/IFinancialReportService.cs create mode 100644 src/PowderCoating.Application/Interfaces/IOperationalReportService.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/ICustomerRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Services/IFinancialReportService.cs create mode 100644 src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/BillRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/CustomerRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/JobRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Services/FinancialReportService.cs create mode 100644 src/PowderCoating.Infrastructure/Services/OperationalReportService.cs 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());