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());