diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs index 03d8330..48bc9c9 100644 --- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs +++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs @@ -22,7 +22,9 @@ public interface IUnitOfWork : IDisposable IRepository JobDailyPriorities { get; } IRepository JobItems { get; } IRepository JobItemCoats { get; } + IRepository JobItemPrepServices { get; } IRepository JobChangeHistories { get; } + IRepository JobPrepServices { get; } IQuoteRepository Quotes { get; } IRepository QuotePhotos { get; } IRepository QuoteItems { get; } @@ -87,8 +89,8 @@ public interface IUnitOfWork : IDisposable IRepository BillPayments { get; } IRepository Expenses { get; } - // Notifications - IRepository NotificationLogs { get; } + // Notifications — typed repository for IgnoreQueryFilters-based history lookups + INotificationLogRepository NotificationLogs { get; } IRepository NotificationTemplates { get; } // Subscription diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs index 52a3881..58441a3 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs @@ -20,4 +20,23 @@ public interface IBillRepository : IRepository /// navigations since those are read-only after the bill is opened. /// Task LoadForEditAsync(int id); + + /// + /// Returns all bills for the Index/AP ledger view filtered by status and/or search term. + /// Includes Vendor so the list row can display vendor name without a second round trip. + /// LineItems are included for the search-in-description condition only. + /// + Task> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount); + + /// + /// Returns the last bill number with the given prefix (including soft-deleted records) for + /// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted. + /// + Task GetLastBillNumberAsync(string prefix); + + /// + /// Returns the last payment number with the given prefix (including soft-deleted records) + /// for sequential payment reference generation. + /// + Task GetLastPaymentNumberAsync(string prefix); } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs index b6caf21..f82407a 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs @@ -1,4 +1,5 @@ using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; namespace PowderCoating.Core.Interfaces.Repositories; @@ -30,4 +31,22 @@ public interface IInvoiceRepository : IRepository /// Returns null if the token does not match any invoice. /// Task GetByPaymentTokenAsync(string token); + + /// + /// Returns the last invoice number that starts with for the given + /// company (including soft-deleted invoices) for sequential number generation. + /// + Task GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix); + + /// + /// Returns all non-deleted invoices that have at least one online payment for the given company + /// and date window, with Customer navigation loaded. Used by the Online Payments reconciliation view. + /// + Task> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to); + + /// + /// Returns all non-deleted CreditDebitCard refunds for the given company and date window, + /// with Invoice→Customer navigation loaded. Used by the Online Payments reconciliation view. + /// + Task> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to); } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs index 4173b9e..18c8c3c 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs @@ -42,4 +42,40 @@ public interface IJobRepository : IRepository /// loaded. Used by the Details view changelog tab. /// Task> GetChangeHistoryAsync(int jobId); + + /// + /// Returns the last job number that starts with for the given + /// company (including soft-deleted jobs) for sequential number generation. + /// + Task GetLastJobNumberByPrefixAsync(int companyId, string prefix); + + /// + /// Looks up a job created from that may be incomplete (items not + /// saved). Ignores query filters so it catches soft-deleted leftover rows from a previous + /// failed conversion attempt. Used for orphan cleanup before retrying conversion. + /// + Task GetOrphanedConversionJobAsync(int quoteId, int companyId); + + /// + /// Loads all jobs scheduled for that are not in a terminal status, + /// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations. + /// Optionally filtered to a single worker when is supplied. + /// Used by the ShopDisplay (TV board) action. + /// + Task> GetScheduledJobsForDateAsync(DateTime date, string? userId = null); + + /// + /// Loads all active (non-terminal, non-hold, non-cancelled) jobs for a company's shop mobile + /// view, with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats). + /// Optionally filtered to a single worker when is supplied. + /// + Task> GetActiveJobsForMobileAsync(int companyId, string? workerId = null); + + /// + /// Loads a single job with the navigations required by the costing breakdown endpoint: + /// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker). + /// Scoped to as an extra safety check. + /// Returns null if not found. + /// + Task LoadForCostingAsync(int jobId, int companyId); } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs new file mode 100644 index 0000000..c5759a2 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs @@ -0,0 +1,30 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds IgnoreQueryFilters-based lookups +/// by entity FK (InvoiceId, QuoteId, JobId) on top of the generic CRUD interface. +/// All methods bypass soft-delete and tenant filters so notification history is always visible +/// regardless of whether the linked entity has been soft-deleted. +/// +public interface INotificationLogRepository : IRepository +{ + /// Returns the most recent notification log entry for the given invoice, or null. + Task GetLatestForInvoiceAsync(int invoiceId); + + /// Returns all notification log entries for the given invoice, newest-first. + Task> GetAllForInvoiceAsync(int invoiceId); + + /// Returns the most recent notification log entry for the given quote, or null. + Task GetLatestForQuoteAsync(int quoteId); + + /// Returns all notification log entries for the given quote, newest-first. + Task> GetAllForQuoteAsync(int quoteId); + + /// Returns the most recent notification log entry for the given job, or null. + Task GetLatestForJobAsync(int jobId); + + /// Returns all notification log entries for the given job, newest-first. + Task> GetAllForJobAsync(int jobId); +} diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs index d3135e2..016782b 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs @@ -3,6 +3,9 @@ using PowderCoating.Core.Enums; namespace PowderCoating.Core.Interfaces.Repositories; +/// Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats. +public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount); + /// /// Typed repository for that adds domain-specific queries on top of /// the generic CRUD interface. @@ -16,6 +19,12 @@ public interface IPurchaseOrderRepository : IRepository /// Task LoadForViewAsync(int id, int companyId); + /// + /// Returns KPI aggregate stats for the Index view using a server-side projection so only three + /// columns are fetched rather than full entities. + /// + Task GetStatsAsync(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 diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs index 47527fa..c42cb19 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs @@ -2,6 +2,9 @@ using PowderCoating.Core.Entities; namespace PowderCoating.Core.Interfaces.Repositories; +/// Aggregate counts and totals for the Quotes Index stat cards. +public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue); + /// /// Typed repository for that adds domain-specific queries on top of /// the generic CRUD interface. @@ -27,4 +30,24 @@ public interface IQuoteRepository : IRepository /// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded. /// Task> GetChangeHistoryAsync(int quoteId); + + /// + /// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the + /// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup) + /// to classify open vs. approved/converted quotes. + /// + Task GetIndexStatsAsync(List openStatusIds, List approvedConvertedStatusIds); + + /// + /// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for + /// PDF generation and quote→job conversion. Cheaper than + /// because it skips the parent-quote navigations that callers already have. + /// + Task> GetItemsWithCoatsAsync(int quoteId); + + /// + /// Returns the last quote number that starts with for the given + /// company (including soft-deleted quotes) for sequential number generation. + /// + Task GetLastQuoteNumberByPrefixAsync(int companyId, string prefix); } diff --git a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs index 2930058..1c41dec 100644 --- a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Infrastructure.Data; @@ -37,4 +38,55 @@ public class BillRepository : Repository, IBillRepository .Include(b => b.LineItems.Where(li => !li.IsDeleted)) .FirstOrDefaultAsync(); } + + /// + public async Task> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount) + { + var query = _context.Bills + .Include(b => b.Vendor) + .Include(b => b.LineItems.Where(li => !li.IsDeleted)) + .Where(b => !b.IsDeleted); + + if (statusFilter == "Unpaid") + query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid); + else if (statusFilter == "Overdue") + query = query.Where(b => b.Status != BillStatus.Paid && b.Status != BillStatus.Voided && + b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today); + + if (!string.IsNullOrEmpty(searchTerm)) + { + var term = searchTerm; + query = query.Where(b => + b.BillNumber.Contains(term) || + b.Vendor.CompanyName.Contains(term) || + (b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(term)) || + (b.Memo != null && b.Memo.Contains(term)) || + b.LineItems.Any(li => li.Description.Contains(term)) || + (searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value))); + } + + return await query.OrderByDescending(b => b.BillDate).ToListAsync(); + } + + /// + public async Task GetLastBillNumberAsync(string prefix) + { + return await _context.Bills + .IgnoreQueryFilters() + .Where(b => b.BillNumber.StartsWith(prefix)) + .OrderByDescending(b => b.BillNumber) + .Select(b => b.BillNumber) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetLastPaymentNumberAsync(string prefix) + { + return await _context.BillPayments + .IgnoreQueryFilters() + .Where(p => p.PaymentNumber.StartsWith(prefix)) + .OrderByDescending(p => p.PaymentNumber) + .Select(p => p.PaymentNumber) + .FirstOrDefaultAsync(); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs b/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs index c12429f..a98f81f 100644 --- a/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Infrastructure.Data; @@ -59,4 +60,45 @@ public class InvoiceRepository : Repository, IInvoiceRepository .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted)) .FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted); } + + /// + public async Task GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix) + { + return await _context.Set() + .IgnoreQueryFilters() + .Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix)) + .OrderByDescending(i => i.InvoiceNumber) + .Select(i => i.InvoiceNumber) + .FirstOrDefaultAsync(); + } + + /// + public async Task> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to) + { + return await _context.Set() + .AsNoTracking() + .Include(i => i.Customer) + .Where(i => !i.IsDeleted + && i.CompanyId == companyId + && i.OnlineAmountPaid > 0 + && i.UpdatedAt >= from + && i.UpdatedAt < to) + .OrderByDescending(i => i.UpdatedAt) + .ToListAsync(); + } + + /// + public async Task> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to) + { + return await _context.Set() + .AsNoTracking() + .Include(r => r.Invoice).ThenInclude(inv => inv!.Customer) + .Where(r => !r.IsDeleted + && r.CompanyId == companyId + && r.RefundMethod == PaymentMethod.CreditDebitCard + && r.RefundDate >= from + && r.RefundDate < to) + .OrderByDescending(r => r.RefundDate) + .ToListAsync(); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs index 38aed06..2504c0a 100644 --- a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs @@ -103,4 +103,76 @@ public class JobRepository : Repository, IJobRepository .AsNoTracking() .ToListAsync(); } + + /// + public async Task GetLastJobNumberByPrefixAsync(int companyId, string prefix) + { + return await _context.Jobs + .IgnoreQueryFilters() + .Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix)) + .OrderByDescending(j => j.JobNumber) + .Select(j => j.JobNumber) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetOrphanedConversionJobAsync(int quoteId, int companyId) + { + return await _context.Jobs + .IgnoreQueryFilters() + .FirstOrDefaultAsync(j => j.QuoteId == quoteId && j.CompanyId == companyId); + } + + /// + public async Task> GetScheduledJobsForDateAsync(DateTime date, string? userId = null) + { + var query = _context.Jobs + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats) + .Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == date.Date + && !j.IsDeleted && !j.JobStatus.IsTerminalStatus); + + if (!string.IsNullOrEmpty(userId)) + query = query.Where(j => j.AssignedUserId == userId); + + return await query.ToListAsync(); + } + + /// + public async Task> GetActiveJobsForMobileAsync(int companyId, string? workerId = null) + { + var query = _context.Jobs + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats) + .Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus + && j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED"); + + if (!string.IsNullOrEmpty(workerId)) + query = query.Where(j => j.AssignedUserId == workerId); + + return await query.ToListAsync(); + } + + /// + public async Task LoadForCostingAsync(int jobId, int companyId) + { + return await _context.Jobs + .Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted) + .Include(j => j.OvenCost) + .Include(j => j.Invoice) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) + .Include(j => j.TimeEntries.Where(t => !t.IsDeleted)) + .ThenInclude(t => t.Worker) + .AsNoTracking() + .FirstOrDefaultAsync(); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs new file mode 100644 index 0000000..86b356c --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs @@ -0,0 +1,63 @@ +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 IgnoreQueryFilters-based +/// lookups by entity FK (InvoiceId, QuoteId, JobId). +/// +public class NotificationLogRepository : Repository, INotificationLogRepository +{ + public NotificationLogRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task GetLatestForInvoiceAsync(int invoiceId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.InvoiceId == invoiceId) + .OrderByDescending(n => n.SentAt) + .FirstOrDefaultAsync(); + + /// + public async Task> GetAllForInvoiceAsync(int invoiceId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.InvoiceId == invoiceId) + .OrderByDescending(n => n.SentAt) + .ToListAsync(); + + /// + public async Task GetLatestForQuoteAsync(int quoteId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.QuoteId == quoteId) + .OrderByDescending(n => n.SentAt) + .FirstOrDefaultAsync(); + + /// + public async Task> GetAllForQuoteAsync(int quoteId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.QuoteId == quoteId) + .OrderByDescending(n => n.SentAt) + .ToListAsync(); + + /// + public async Task GetLatestForJobAsync(int jobId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.JobId == jobId) + .OrderByDescending(n => n.SentAt) + .FirstOrDefaultAsync(); + + /// + public async Task> GetAllForJobAsync(int jobId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.JobId == jobId) + .OrderByDescending(n => n.SentAt) + .ToListAsync(); +} diff --git a/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs index 1d7b842..d44522a 100644 --- a/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs @@ -26,6 +26,29 @@ public class PurchaseOrderRepository : Repository, IPurchaseOrder .FirstOrDefaultAsync(); } + /// + public async Task GetStatsAsync(int companyId) + { + var today = DateTime.UtcNow.Date; + var items = await _context.Set() + .Where(po => !po.IsDeleted && po.CompanyId == companyId) + .Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate }) + .ToListAsync(); + + return new PurchaseOrderStats( + TotalCount: items.Count, + OpenCount: items.Count(p => + p.Status == PurchaseOrderStatus.Draft || + p.Status == PurchaseOrderStatus.Submitted || + p.Status == PurchaseOrderStatus.PartiallyReceived), + CommittedValue: items.Where(p => p.Status != PurchaseOrderStatus.Cancelled).Sum(p => p.TotalAmount), + OverdueCount: items.Count(p => + (p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived) && + p.ExpectedDeliveryDate.HasValue && + p.ExpectedDeliveryDate.Value.Date < today) + ); + } + /// public async Task<(List Items, int TotalCount)> GetPagedAsync( int companyId, diff --git a/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs b/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs index e707607..445a463 100644 --- a/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs @@ -67,4 +67,45 @@ public class QuoteRepository : Repository, IQuoteRepository .AsNoTracking() .ToListAsync(); } + + /// + public async Task GetIndexStatsAsync(List openStatusIds, List approvedConvertedStatusIds) + { + var stats = await _context.Quotes + .Where(q => !q.IsDeleted) + .Select(q => new { q.QuoteStatusId, q.Total }) + .ToListAsync(); + + return new QuoteIndexStats( + OpenCount: stats.Count(q => openStatusIds.Contains(q.QuoteStatusId)), + ApprovedConvertedCount: stats.Count(q => approvedConvertedStatusIds.Contains(q.QuoteStatusId)), + TotalValue: stats.Sum(q => q.Total)); + } + + /// + public async Task> GetItemsWithCoatsAsync(int quoteId) + { + return await _context.QuoteItems + .Where(qi => qi.QuoteId == quoteId && !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) + .AsNoTracking() + .ToListAsync(); + } + + /// + public async Task GetLastQuoteNumberByPrefixAsync(int companyId, string prefix) + { + return await _context.Quotes + .IgnoreQueryFilters() + .Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix)) + .OrderByDescending(q => q.QuoteNumber) + .Select(q => q.QuoteNumber) + .FirstOrDefaultAsync(); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs index 585964d..a2d2c92 100644 --- a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs +++ b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs @@ -49,7 +49,9 @@ public class UnitOfWork : IUnitOfWork private IRepository? _jobDailyPriorities; private IRepository? _jobItems; private IRepository? _jobItemCoats; + private IRepository? _jobItemPrepServices; private IRepository? _jobChangeHistories; + private IRepository? _jobPrepServices; private IQuoteRepository? _quotes; private IRepository? _quotePhotos; private IRepository? _quoteItems; @@ -88,7 +90,7 @@ public class UnitOfWork : IUnitOfWork private IRepository? _catalogPriceCheckReports; // Notifications - private IRepository? _notificationLogs; + private INotificationLogRepository? _notificationLogs; private IRepository? _notificationTemplates; // Subscription @@ -190,11 +192,17 @@ public class UnitOfWork : IUnitOfWork /// Repository for powder coat passes; tenant-filtered with soft delete. public IRepository JobItemCoats => _jobItemCoats ??= new Repository(_context); + public IRepository JobItemPrepServices => + _jobItemPrepServices ??= new Repository(_context); /// Repository for audit entries; tenant-filtered with soft delete. public IRepository JobChangeHistories => _jobChangeHistories ??= new Repository(_context); + /// Repository for job-level prep service assignments; tenant-filtered with soft delete. + public IRepository JobPrepServices => + _jobPrepServices ??= new Repository(_context); + /// Repository for records with multi-item pricing; tenant-filtered with soft delete. public IQuoteRepository Quotes => _quotes ??= new QuoteRepository(_context); @@ -351,9 +359,9 @@ public class UnitOfWork : IUnitOfWork _catalogPriceCheckReports ??= new Repository(_context); // Notifications - /// Repository for outbound notification audit records; tenant-filtered with soft delete. - public IRepository NotificationLogs => - _notificationLogs ??= new Repository(_context); + /// Repository for outbound notification audit records; provides IgnoreQueryFilters lookups by InvoiceId, QuoteId, and JobId for notification history panels. + public INotificationLogRepository NotificationLogs => + _notificationLogs ??= new NotificationLogRepository(_context); /// Repository for per-company channel template overrides; unique on (CompanyId, Type, Channel). public IRepository NotificationTemplates => diff --git a/src/PowderCoating.Web/Controllers/BillsController.cs b/src/PowderCoating.Web/Controllers/BillsController.cs index ca7d38a..8d66b2d 100644 --- a/src/PowderCoating.Web/Controllers/BillsController.cs +++ b/src/PowderCoating.Web/Controllers/BillsController.cs @@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Application.DTOs.PurchaseOrder; namespace PowderCoating.Web.Controllers; @@ -29,7 +28,6 @@ public class BillsController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly IAccountBalanceService _accountBalanceService; private readonly IAccountingAiService _accountingAi; private readonly IAzureBlobStorageService _blobStorage; @@ -41,7 +39,6 @@ public class BillsController : Controller IMapper mapper, UserManager userManager, ILogger logger, - ApplicationDbContext context, IAccountBalanceService accountBalanceService, IAccountingAiService accountingAi, IAzureBlobStorageService blobStorage, @@ -52,7 +49,6 @@ public class BillsController : Controller _mapper = mapper; _userManager = userManager; _logger = logger; - _context = context; _accountBalanceService = accountBalanceService; _accountingAi = accountingAi; _blobStorage = blobStorage; @@ -86,23 +82,7 @@ public class BillsController : Controller // Bills if (type == null || type == "Bill") { - var bills = await _context.Bills - .Include(b => b.Vendor) - .Where(b => !b.IsDeleted) - .Where(b => status != "Unpaid" || - (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)) - .Where(b => status != "Overdue" || - (b.Status != BillStatus.Paid && b.Status != BillStatus.Voided && - b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today)) - .Where(b => string.IsNullOrEmpty(search) || - b.BillNumber.Contains(search) || - b.Vendor.CompanyName.Contains(search) || - (b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(search)) || - (b.Memo != null && b.Memo.Contains(search)) || - b.LineItems.Any(li => li.Description.Contains(search)) || - (searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value))) - .OrderByDescending(b => b.BillDate) - .ToListAsync(); + var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount); entries.AddRange(bills.Select(b => new BillExpenseListDto { @@ -133,17 +113,17 @@ public class BillsController : Controller // Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue") { - var expenses = await _context.Set() - .Include(e => e.Vendor) - .Include(e => e.ExpenseAccount) - .Where(e => !e.IsDeleted) - .Where(e => string.IsNullOrEmpty(search) || - e.ExpenseNumber.Contains(search) || - (e.Vendor != null && e.Vendor.CompanyName.Contains(search)) || - (e.Memo != null && e.Memo.Contains(search)) || - (searchAmount.HasValue && e.Amount == searchAmount.Value)) - .OrderByDescending(e => e.Date) - .ToListAsync(); + var expSearch = search; + var expAmount = searchAmount; + var expenseList = await _unitOfWork.Expenses.FindAsync( + e => string.IsNullOrEmpty(expSearch) || + e.ExpenseNumber.Contains(expSearch) || + (e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) || + (e.Memo != null && e.Memo.Contains(expSearch)) || + (expAmount.HasValue && e.Amount == expAmount.Value), + false, + e => e.Vendor!, e => e.ExpenseAccount!); + var expenses = expenseList.OrderByDescending(e => e.Date).ToList(); entries.AddRange(expenses.Select(e => new BillExpenseListDto { @@ -198,11 +178,7 @@ public class BillsController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == purchaseOrderId && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId); if (po == null) return NotFound(); @@ -218,20 +194,16 @@ public class BillsController : Controller return RedirectToAction(nameof(Details), new { id = po.BillId }); } - var apAccount = await _context.Accounts - .Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable) - .OrderBy(a => a.AccountNumber) - .FirstOrDefaultAsync(); + var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountSubType == AccountSubType.AccountsPayable); // Vendor default expense account, fall back to first expense/COGS account int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId; if (!defaultExpenseAccountId.HasValue) { - defaultExpenseAccountId = (await _context.Accounts - .Where(a => !a.IsDeleted && a.IsActive && - (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)) - .OrderBy(a => a.AccountNumber) - .FirstOrDefaultAsync())?.Id; + var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)); + defaultExpenseAccountId = fallbackAccount?.Id; } var lineItems = po.Items @@ -293,10 +265,8 @@ public class BillsController : Controller }; // Pre-fill AP account - var apAccount = await _context.Accounts - .Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable) - .OrderBy(a => a.AccountNumber) - .FirstOrDefaultAsync(); + var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountSubType == AccountSubType.AccountsPayable); dto.APAccountId = apAccount?.Id ?? 0; // Pre-fill default expense account for vendor @@ -385,13 +355,12 @@ public class BillsController : Controller // Link bill back to source PO if created from one if (dto.PurchaseOrderId > 0) { - var po = await _context.Set() - .FirstOrDefaultAsync(p => p.Id == dto.PurchaseOrderId && !p.IsDeleted); + var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value); if (po != null) { po.BillId = bill.Id; po.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } } @@ -416,8 +385,8 @@ public class BillsController : Controller bill.AmountPaid = payment.Amount; bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid; - await _context.BillPayments.AddAsync(payment); - await _context.SaveChangesAsync(); + await _unitOfWork.BillPayments.AddAsync(payment); + await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid."; } @@ -448,28 +417,18 @@ public class BillsController : Controller { if (id == null) return NotFound(); - var bill = await _context.Bills - .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(b => b.Id == id && !b.IsDeleted); - + var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value); if (bill == null) return NotFound(); var dto = _mapper.Map(bill); // Payment form defaults - var bankAccounts = await _context.Accounts - .Where(a => !a.IsDeleted && (a.AccountSubType == AccountSubType.Checking || - a.AccountSubType == AccountSubType.Savings || - a.AccountSubType == AccountSubType.CreditCard)) + var bankAccounts = (await _unitOfWork.Accounts.FindAsync( + a => a.AccountSubType == AccountSubType.Checking || + a.AccountSubType == AccountSubType.Savings || + a.AccountSubType == AccountSubType.CreditCard)) .OrderBy(a => a.AccountNumber) - .ToListAsync(); + .ToList(); ViewBag.BankAccounts = bankAccounts .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) @@ -495,10 +454,7 @@ public class BillsController : Controller { if (id == null) return NotFound(); - var bill = await _context.Bills - .Include(b => b.LineItems.Where(li => !li.IsDeleted)) - .FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted); - + var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value); if (bill == null) return NotFound(); if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided) @@ -561,9 +517,7 @@ public class BillsController : Controller try { - var bill = await _context.Bills - .Include(b => b.LineItems) - .FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted); + var bill = await _unitOfWork.Bills.LoadForEditAsync(id); if (bill == null) return NotFound(); @@ -611,7 +565,7 @@ public class BillsController : Controller bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2); bill.Total = bill.SubTotal + bill.TaxAmount; - await _context.BillLineItems.AddRangeAsync(newLineItems); + await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems); // Handle receipt file replacement if (receiptFile != null && receiptFile.Length > 0) @@ -628,7 +582,7 @@ public class BillsController : Controller } } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Bill {bill.BillNumber} updated."; return RedirectToAction(nameof(Details), new { id }); @@ -1024,12 +978,7 @@ public class BillsController : Controller private async Task GenerateBillNumberAsync() { var prefix = $"BILL-{DateTime.Now:yyMM}-"; - var last = await _context.Bills - .IgnoreQueryFilters() - .Where(b => b.BillNumber.StartsWith(prefix)) - .OrderByDescending(b => b.BillNumber) - .Select(b => b.BillNumber) - .FirstOrDefaultAsync(); + var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) @@ -1046,12 +995,7 @@ public class BillsController : Controller private async Task GeneratePaymentNumberAsync() { var prefix = $"BPMT-{DateTime.Now:yyMM}-"; - var last = await _context.BillPayments - .IgnoreQueryFilters() - .Where(p => p.PaymentNumber.StartsWith(prefix)) - .OrderByDescending(p => p.PaymentNumber) - .Select(p => p.PaymentNumber) - .FirstOrDefaultAsync(); + var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) diff --git a/src/PowderCoating.Web/Controllers/CustomersController.cs b/src/PowderCoating.Web/Controllers/CustomersController.cs index 433879f..c1d3b85 100644 --- a/src/PowderCoating.Web/Controllers/CustomersController.cs +++ b/src/PowderCoating.Web/Controllers/CustomersController.cs @@ -25,7 +25,6 @@ public class CustomersController : Controller private readonly INotificationService _notificationService; private readonly ISubscriptionService _subscriptionService; private readonly ITenantContext _tenantContext; - private readonly ApplicationDbContext _context; private readonly UserManager _userManager; public CustomersController( @@ -35,7 +34,6 @@ public class CustomersController : Controller INotificationService notificationService, ISubscriptionService subscriptionService, ITenantContext tenantContext, - ApplicationDbContext context, UserManager userManager) { _unitOfWork = unitOfWork; @@ -44,7 +42,6 @@ public class CustomersController : Controller _notificationService = notificationService; _subscriptionService = subscriptionService; _tenantContext = tenantContext; - _context = context; _userManager = userManager; } @@ -555,11 +552,9 @@ public class CustomersController : Controller try { await _notificationService.NotifySmsConsentGrantedAsync(customer); } catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); } - var smsLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.CustomerId == customer.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var logs = await _unitOfWork.NotificationLogs.FindAsync( + n => n.CustomerId == customer.Id, ignoreQueryFilters: true); + var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault(); this.SetNotificationResultToast(smsLog); } @@ -679,11 +674,9 @@ public class CustomersController : Controller try { await _notificationService.NotifySmsConsentGrantedAsync(customer); } catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); } - var smsLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.CustomerId == customer.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var logs = await _unitOfWork.NotificationLogs.FindAsync( + n => n.CustomerId == customer.Id, ignoreQueryFilters: true); + var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault(); this.SetNotificationResultToast(smsLog); } diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 0d4abef..1e95d57 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -2,7 +2,6 @@ using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.Interfaces; @@ -10,7 +9,6 @@ using PowderCoating.Core.Entities; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using PowderCoating.Web.Extensions; using PowderCoating.Web.Helpers; @@ -26,7 +24,6 @@ public class InvoicesController : Controller private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; - private readonly ApplicationDbContext _context; private readonly ITenantContext _tenantContext; private readonly INotificationService _notificationService; private readonly IAccountBalanceService _accountBalanceService; @@ -37,7 +34,6 @@ public class InvoicesController : Controller UserManager userManager, ILogger logger, IPdfService pdfService, - ApplicationDbContext context, ITenantContext tenantContext, INotificationService notificationService, IAccountBalanceService accountBalanceService) @@ -47,7 +43,6 @@ public class InvoicesController : Controller _userManager = userManager; _logger = logger; _pdfService = pdfService; - _context = context; _tenantContext = tenantContext; _notificationService = notificationService; _accountBalanceService = accountBalanceService; @@ -256,8 +251,7 @@ public class InvoicesController : Controller // Check whether this company's plan allows online payments var company = await _unitOfWork.Companies.GetByIdAsync(_tenantContext.GetCurrentCompanyId() ?? 0); - var planConfig = company == null ? null : await _context.Set() - .AsNoTracking() + var planConfig = company == null ? null : await _unitOfWork.SubscriptionPlanConfigs .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var onlinePaymentsAllowed = company?.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false); ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed @@ -298,12 +292,10 @@ public class InvoicesController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var prefs = await _context.CompanyPreferences - .AsNoTracking() + var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); - var costs = await _context.CompanyOperatingCosts - .AsNoTracking() + var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); var dto = new CreateInvoiceDto @@ -321,9 +313,7 @@ public class InvoicesController : Controller if (job == null) return NotFound(); // Validate no existing invoice for this job - var existing = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(i => i.JobId == jobId.Value && i.CompanyId == currentUser.CompanyId && !i.IsDeleted); + var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true); if (existing != null) return RedirectToAction(nameof(Details), new { id = existing.Id }); @@ -343,8 +333,8 @@ public class InvoicesController : Controller : new Dictionary(); // Fall back to the default revenue account (4000) if a catalog item has no specific account - var defaultRevenueAccount = await _context.Set() - .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.CompanyId == currentUser.CompanyId && !a.IsDeleted); + var defaultRevenueAccount = await _unitOfWork.Accounts + .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); // If the job came from a quote, load it so we can use the agreed pricing. // The quote stores the approved total including oven batch cost and shop supplies — @@ -352,9 +342,7 @@ public class InvoicesController : Controller PowderCoating.Core.Entities.Quote? sourceQuote = null; if (job.QuoteId.HasValue) { - sourceQuote = await _context.Set() - .AsNoTracking() - .FirstOrDefaultAsync(q => q.Id == job.QuoteId.Value && !q.IsDeleted); + sourceQuote = await _unitOfWork.Quotes.GetByIdAsync(job.QuoteId.Value); } // Pre-populate from job items @@ -494,9 +482,7 @@ public class InvoicesController : Controller // Validate no existing invoice for this job before starting the transaction if (dto.JobId.HasValue) { - var existing = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(i => i.JobId == dto.JobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted); + var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true); if (existing != null) { ModelState.AddModelError("", "An invoice already exists for this job."); @@ -591,11 +577,10 @@ public class InvoicesController : Controller // Auto-apply any unapplied deposits for this job (and its linked quote) var job = dto.JobId.HasValue ? await _unitOfWork.Jobs.GetByIdAsync(dto.JobId.Value) : null; - var depositQuery = _context.Set() - .Where(d => !d.IsDeleted && d.CompanyId == currentUser.CompanyId - && d.AppliedToInvoiceId == null - && (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value))); - pendingDeposits = await depositQuery.ToListAsync(); + pendingDeposits = (await _unitOfWork.Deposits.FindAsync( + d => d.AppliedToInvoiceId == null + && (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value)))) + .ToList(); foreach (var deposit in pendingDeposits) { @@ -908,11 +893,7 @@ public class InvoicesController : Controller id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); } - var notifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(notifLog); TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; @@ -1043,11 +1024,7 @@ public class InvoicesController : Controller } - var paymentNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(paymentNotifLog); TempData["Success"] = overpayment > 0 @@ -1325,9 +1302,7 @@ public class InvoicesController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var existing = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(i => i.JobId == jobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted); + var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true); if (existing != null) return RedirectToAction(nameof(Details), new { id = existing.Id }); @@ -1386,11 +1361,7 @@ public class InvoicesController : Controller await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename); - var latestLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); if (latestLog?.Status == NotificationStatus.Failed) return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); @@ -1420,16 +1391,10 @@ public class InvoicesController : Controller public async Task NotificationsSent(int id) { var tz = ViewBag.CompanyTimeZone as string; - var raw = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), - Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, n.SentAt }) - .ToListAsync(); - - var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName, - n.Recipient, n.Subject, n.ErrorMessage, n.Message, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); + var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id); + var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), + Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, + SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); return Json(logs); } @@ -1474,32 +1439,24 @@ public class InvoicesController : Controller } // Soft-delete line items - var invoiceItems = await _context.InvoiceItems - .Where(ii => ii.InvoiceId == id && !ii.IsDeleted) - .ToListAsync(); + var invoiceItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == id); foreach (var item in invoiceItems) await _unitOfWork.InvoiceItems.SoftDeleteAsync(item.Id); // Soft-delete any payments (draft invoices shouldn't have them, but be safe) - var payments = await _context.Payments - .Where(p => p.InvoiceId == id && !p.IsDeleted) - .ToListAsync(); + var payments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == id); foreach (var payment in payments) await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); // Un-apply any deposits that were applied to this invoice so they can be // re-applied if the invoice is recreated from the same job - var appliedDeposits = await _context.Deposits - .Where(d => d.AppliedToInvoiceId == id && !d.IsDeleted) - .ToListAsync(); + var appliedDeposits = await _unitOfWork.Deposits.FindAsync(d => d.AppliedToInvoiceId == id); foreach (var deposit in appliedDeposits) { deposit.AppliedToInvoiceId = null; deposit.AppliedDate = null; deposit.UpdatedAt = DateTime.UtcNow; } - if (appliedDeposits.Any()) - await _context.SaveChangesAsync(); // Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); @@ -1529,36 +1486,11 @@ public class InvoicesController : Controller // ----------------------------------------------------------------------- /// - /// Loads a complete invoice aggregate from the DB context (bypasses the generic repository - /// to allow the deep multi-level Include chain). Includes customer, job, preparer, all - /// non-deleted line items with revenue accounts and generated GCs, all non-deleted payments - /// with recorders and deposit accounts, refunds, credit applications, and GC redemptions. - /// Returns null if the invoice doesn't exist or is soft-deleted — callers check for null - /// and return NotFound rather than loading a partial object. + /// Delegates to which expresses the full + /// eight-table include chain. Returns null if not found or soft-deleted. /// - private async Task LoadInvoiceForViewAsync(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(); - } + private async Task LoadInvoiceForViewAsync(int id) => + await _unitOfWork.Invoices.LoadForViewAsync(id); /// /// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper @@ -1737,11 +1669,10 @@ public class InvoicesController : Controller { var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix)) + var existing = (await _unitOfWork.CreditMemos.FindAsync( + m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true)) .Select(m => m.MemoNumber) - .ToListAsync(); + .ToList(); var maxNum = 0; foreach (var num in existing) @@ -1764,26 +1695,18 @@ public class InvoicesController : Controller /// private async Task GenerateInvoiceNumberAsync(int companyId) { - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.InvoiceNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences + .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); var invoicePrefix = !string.IsNullOrWhiteSpace(prefs?.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV"; var prefix = $"{invoicePrefix}-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix)) - .Select(i => i.InvoiceNumber) - .ToListAsync(); - + var last = await _unitOfWork.Invoices.GetLastInvoiceNumberByPrefixAsync(companyId, prefix); var maxNum = 0; - foreach (var num in existing) + if (last != null && last.Length >= prefix.Length + 4) { - var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : ""; - if (int.TryParse(suffix, out int n) && n > maxNum) + var suffix = last.Substring(prefix.Length); + if (int.TryParse(suffix, out int n)) maxNum = n; } @@ -1802,8 +1725,7 @@ public class InvoicesController : Controller ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList(); // Expose company default tax rate and exempt customer IDs for client-side tax handling - var costs = await _context.CompanyOperatingCosts - .AsNoTracking() + var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == companyId && !c.IsDeleted); ViewBag.CompanyTaxPercent = costs?.TaxPercent ?? 0; ViewBag.TaxExemptCustomerIds = customers @@ -1812,12 +1734,12 @@ public class InvoicesController : Controller .ToHashSet(); // Merchandise items for the invoice merch picker (all active IsMerchandise items) - var merchItems = await _context.Set() - .Include(i => i.Category) - .Where(i => i.IsMerchandise && i.IsActive && !i.IsDeleted && i.CompanyId == companyId) + var allMerchItems = await _unitOfWork.CatalogItems.FindAsync( + i => i.IsMerchandise && i.IsActive, false, i => i.Category); + var merchItems = allMerchItems .OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId }) - .ToListAsync(); + .ToList(); ViewBag.MerchandiseItems = System.Text.Json.JsonSerializer.Serialize(merchItems, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); } @@ -1845,15 +1767,13 @@ public class InvoicesController : Controller return accounts.FirstOrDefault()?.Id; } - /// Looks up the "2200 Sales Tax Payable" account for this company, or any active OtherCurrentLiability with "tax" in the name. + /// Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name. private async Task ResolveSalesTaxAccountIdAsync(int companyId) { - var taxAccount = await _context.Set() - .Where(a => a.CompanyId == companyId && !a.IsDeleted && a.IsActive) - .OrderBy(a => a.AccountNumber == "2200" ? 0 : 1) - .ThenBy(a => a.AccountNumber) - .FirstOrDefaultAsync(a => a.AccountNumber == "2200" - || (a.AccountType == AccountType.Liability && a.Name.ToLower().Contains("tax"))); + var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountNumber == "2200" && a.IsActive); + taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax")); return taxAccount?.Id; } @@ -2371,16 +2291,13 @@ public class InvoicesController : Controller /// private async Task TryGeneratePaymentTokenAsync(Invoice invoice) { - var company = await _context.Companies - .AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted); + var company = await _unitOfWork.Companies.GetByIdAsync(invoice.CompanyId); if (company == null) return null; if (company.StripeConnectStatus != StripeConnectStatus.Active) return null; if (invoice.BalanceDue <= 0) return null; - var planConfig = await _context.Set() - .AsNoTracking() + var planConfig = await _unitOfWork.SubscriptionPlanConfigs .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var onlinePaymentsAllowed = company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false); @@ -2405,8 +2322,7 @@ public class InvoicesController : Controller { try { - var invoice = await _context.Invoices - .FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted); + var invoice = await _unitOfWork.Invoices.GetByIdAsync(id); if (invoice == null) return NotFound(); if (invoice.BalanceDue <= 0) @@ -2417,8 +2333,8 @@ public class InvoicesController : Controller return Json(new { success = false, message = "Online payments are not available for this company." }); invoice.UpdatedAt = DateTime.UtcNow; - _context.Update(invoice); - await _context.SaveChangesAsync(); + await _unitOfWork.Invoices.UpdateAsync(invoice); + await _unitOfWork.CompleteAsync(); var paymentUrl = Url.Action("Index", "Payment", new { token }, Request.Scheme)! .Replace("/Payment/Index/", "/pay/"); @@ -2452,27 +2368,11 @@ public class InvoicesController : Controller var endDate = to ?? startDate.AddMonths(1).AddDays(-1); var endDateInclusive = endDate.AddDays(1); - var invoices = await _context.Invoices - .AsNoTracking() - .Include(i => i.Customer) - .Where(i => !i.IsDeleted - && i.CompanyId == companyId - && i.OnlineAmountPaid > 0 - && i.UpdatedAt >= startDate - && i.UpdatedAt < endDateInclusive) - .OrderByDescending(i => i.UpdatedAt) - .ToListAsync(); + var invoices = await _unitOfWork.Invoices + .GetOnlineInvoicesForPeriodAsync(companyId, startDate, endDateInclusive); - var refunds = await _context.Refunds - .AsNoTracking() - .Include(r => r.Invoice).ThenInclude(inv => inv!.Customer) - .Where(r => !r.IsDeleted - && r.CompanyId == companyId - && r.RefundMethod == PaymentMethod.CreditDebitCard - && r.RefundDate >= startDate - && r.RefundDate < endDateInclusive) - .OrderByDescending(r => r.RefundDate) - .ToListAsync(); + var refunds = await _unitOfWork.Invoices + .GetOnlineRefundsForPeriodAsync(companyId, startDate, endDateInclusive); var vm = new OnlinePaymentsViewModel { diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 52c0fce..3496a52 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -15,7 +15,6 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Web.Helpers; using PowderCoating.Web.Hubs; @@ -29,7 +28,6 @@ public class JobsController : Controller private readonly IJobPhotoService _jobPhotoService; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly ILookupCacheService _lookupCache; @@ -45,7 +43,6 @@ public class JobsController : Controller IJobPhotoService jobPhotoService, UserManager userManager, ILogger logger, - ApplicationDbContext context, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, @@ -60,7 +57,6 @@ public class JobsController : Controller _jobPhotoService = jobPhotoService; _userManager = userManager; _logger = logger; - _context = context; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; @@ -239,17 +235,7 @@ public class JobsController : Controller .ToList(); // Load all active jobs with related data - var jobs = 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(); + var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync(); var now = DateTime.UtcNow.Date; @@ -296,15 +282,13 @@ public class JobsController : Controller [HttpPost, ValidateAntiForgeryToken] public async Task MoveCard([FromBody] MoveCardRequest req) { - var job = await _context.Jobs - .Include(j => j.JobStatus) - .FirstOrDefaultAsync(j => j.Id == req.JobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId); if (job == null) return Json(new { success = false, message = "Job not found." }); - var newStatus = await _context.JobStatusLookups - .FirstOrDefaultAsync(s => s.Id == req.NewStatusId && s.IsActive); + var newStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync( + s => s.Id == req.NewStatusId && s.IsActive); if (newStatus == null) return Json(new { success = false, message = "Status not found." }); @@ -314,7 +298,7 @@ public class JobsController : Controller job.UpdatedAt = DateTime.UtcNow; job.UpdatedBy = User.Identity?.Name; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged", $"Status → {newStatus.DisplayName}"); @@ -354,49 +338,12 @@ public class JobsController : Controller try { - var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, - j => j.Customer, - j => j.JobStatus, - j => j.JobPriority, - j => j.JobItems, - j => j.AssignedUser, - j => j.Quote, - j => j.OvenCost, - j => j.OriginalJob, - j => j.IntakeCheckedBy); + var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); if (job == null) { return NotFound(); } - // Load JobItemCoats and PrepServices for each JobItem - foreach (var item in job.JobItems) - { - var itemWithDetails = await _context.JobItems - .Where(ji => ji.Id == item.Id) - .Include(ji => ji.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(ji => ji.Coats) - .ThenInclude(c => c.Vendor) - .Include(ji => ji.PrepServices) - .ThenInclude(ps => ps.PrepService) - .AsNoTracking() - .FirstOrDefaultAsync(); - - if (itemWithDetails?.Coats != null) - item.Coats = itemWithDetails.Coats; - if (itemWithDetails?.PrepServices != null) - item.PrepServices = itemWithDetails.PrepServices; - } - - // Load prep services for this job - var jobPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == id.Value && !jps.IsDeleted) - .Include(jps => jps.PrepService) - .AsNoTracking() - .ToListAsync(); - job.JobPrepServices = jobPrepServices; - // Build photo tag suggestions from coat colors on this job var coatColorSuggestions = job.JobItems .SelectMany(ji => ji.Coats) @@ -411,13 +358,7 @@ public class JobsController : Controller var jobDto = _mapper.Map(job); // Load change history - var changeHistories = await _context.JobChangeHistories - .Where(h => h.JobId == id.Value && !h.IsDeleted) - .Include(h => h.ChangedBy) - .OrderByDescending(h => h.ChangedAt) - .AsNoTracking() - .ToListAsync(); - + var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value); _logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value); var changeHistoryDtos = _mapper.Map>(changeHistories); @@ -435,10 +376,7 @@ public class JobsController : Controller ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); // Check if an invoice exists for this job - var jobInvoice = await _context.Set() - .Where(i => i.JobId == id.Value && !i.IsDeleted) - .Select(i => new { i.Id, i.InvoiceNumber, i.Status }) - .FirstOrDefaultAsync(); + var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value); ViewBag.JobInvoiceId = jobInvoice?.Id; ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber; ViewBag.JobInvoiceStatus = jobInvoice?.Status; @@ -498,21 +436,16 @@ public class JobsController : Controller }).ToList(); // Load deposits for this job (and any linked quote) - var depositQuery = _context.Set() - .Where(d => !d.IsDeleted && d.CompanyId == job.CompanyId - && (d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value))) - .Include(d => d.RecordedBy) - .OrderByDescending(d => d.ReceivedDate) - .AsNoTracking(); - ViewBag.Deposits = await depositQuery.ToListAsync(); + var jobDeposits = (await _unitOfWork.Deposits.FindAsync( + d => d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value), + false, d => d.RecordedBy)) + .OrderByDescending(d => d.ReceivedDate).ToList(); + ViewBag.Deposits = jobDeposits; // Materials used on this job via QR scan or manual log - ViewBag.MaterialsUsed = await _context.Set() - .Where(t => !t.IsDeleted && t.JobId == id.Value) - .Include(t => t.InventoryItem) - .OrderByDescending(t => t.TransactionDate) - .AsNoTracking() - .ToListAsync(); + ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync( + t => t.JobId == id.Value, false, t => t.InventoryItem)) + .OrderByDescending(t => t.TransactionDate).ToList(); // Job photo subscription limits — used to disable the upload button in the view var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -650,46 +583,12 @@ public class JobsController : Controller try { - var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, - j => j.Customer, - j => j.JobStatus, - j => j.JobPriority, - j => j.JobItems, - j => j.AssignedUser, - j => j.Quote); + var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); if (job == null) { return NotFound(); } - // Load JobItemCoats and PrepServices for each JobItem - foreach (var item in job.JobItems) - { - var itemWithDetails = await _context.JobItems - .Where(ji => ji.Id == item.Id) - .Include(ji => ji.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(ji => ji.Coats) - .ThenInclude(c => c.Vendor) - .Include(ji => ji.PrepServices) - .ThenInclude(ps => ps.PrepService) - .AsNoTracking() - .FirstOrDefaultAsync(); - - if (itemWithDetails?.Coats != null) - item.Coats = itemWithDetails.Coats; - if (itemWithDetails?.PrepServices != null) - item.PrepServices = itemWithDetails.PrepServices; - } - - // Load prep services for this job - var jobPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == id.Value && !jps.IsDeleted) - .Include(jps => jps.PrepService) - .AsNoTracking() - .ToListAsync(); - job.JobPrepServices = jobPrepServices; - // Use AutoMapper to map the job entity to JobDto var jobDto = _mapper.Map(job); @@ -943,16 +842,23 @@ public class JobsController : Controller // Pre-populate from template if provided if (templateId.HasValue) { - var template = await _context.JobTemplates - .Include(t => t.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .Include(t => t.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) - .ThenInclude(p => p.PrepService) - .FirstOrDefaultAsync(t => t.Id == templateId.Value && !t.IsDeleted); - + var template = await _unitOfWork.JobTemplates.GetByIdAsync(templateId.Value); if (template != null) { + var templateItemsEnum = await _unitOfWork.JobTemplateItems.FindAsync( + i => i.JobTemplateId == templateId.Value, false, i => i.Coats, i => i.PrepServices); + var templateItems = templateItemsEnum.ToList(); + + var tplPrepIds = templateItems + .SelectMany(i => i.PrepServices.Select(p => p.PrepServiceId)) + .Distinct().ToList(); + Dictionary tplPrepNameMap = new(); + if (tplPrepIds.Any()) + { + var tplPreps = await _unitOfWork.PrepServices.FindAsync(p => tplPrepIds.Contains(p.Id)); + tplPrepNameMap = tplPreps.ToDictionary(p => p.Id, p => p.ServiceName); + } + if (!customerId.HasValue && template.CustomerId.HasValue) dto.CustomerId = template.CustomerId.Value; dto.SpecialInstructions = template.SpecialInstructions; @@ -962,7 +868,7 @@ public class JobsController : Controller { id = template.Id, name = template.Name, - items = template.Items.OrderBy(i => i.DisplayOrder).Select(i => new + items = templateItems.OrderBy(i => i.DisplayOrder).Select(i => new { description = i.Description, quantity = i.Quantity, @@ -993,7 +899,7 @@ public class JobsController : Controller prepServices = i.PrepServices.Select(p => new { prepServiceId = p.PrepServiceId, - prepServiceName = p.PrepService?.ServiceName, + prepServiceName = tplPrepNameMap.TryGetValue(p.PrepServiceId, out var psName) ? psName : null, estimatedMinutes = p.EstimatedMinutes }) }) @@ -1071,14 +977,14 @@ public class JobsController : Controller { foreach (var prepServiceId in dto.PrepServiceIds) { - await _context.JobPrepServices.AddAsync(new JobPrepService + await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, CompanyId = companyId }); } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } // Save job items from wizard @@ -1154,7 +1060,7 @@ public class JobsController : Controller { foreach (var psDto in itemDto.PrepServices) { - await _context.JobItemPrepServices.AddAsync(new JobItemPrepService + await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, @@ -1212,26 +1118,12 @@ public class JobsController : Controller try { - var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, j => j.JobStatus, j => j.JobPriority); + var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value); if (job == null) { return NotFound(); } - // Load prep services for this job - var jobPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == id.Value && !jps.IsDeleted) - .Select(jps => jps.PrepServiceId) - .ToListAsync(); - - // Load job items with full detail for wizard pre-fill - var jobItemsWithDetail = await _context.JobItems - .Where(ji => ji.JobId == id.Value && !ji.IsDeleted) - .Include(ji => ji.Coats) - .Include(ji => ji.PrepServices) - .AsNoTracking() - .ToListAsync(); - var dto = new UpdateJobDto { Id = job.Id, @@ -1252,7 +1144,7 @@ public class JobsController : Controller DiscountType = job.DiscountType.ToString(), DiscountValue = job.DiscountValue, DiscountReason = job.DiscountReason, - JobItems = jobItemsWithDetail.Select(ji => new CreateQuoteItemDto + JobItems = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => new CreateQuoteItemDto { Description = ji.Description, Quantity = ji.Quantity, @@ -1289,7 +1181,7 @@ public class JobsController : Controller EstimatedMinutes = ps.EstimatedMinutes }).ToList() }).ToList(), - PrepServiceIds = jobPrepServices + PrepServiceIds = job.JobPrepServices.Select(jps => jps.PrepServiceId).ToList() }; var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -1339,9 +1231,8 @@ public class JobsController : Controller try { // Replace job items: soft-delete old, recreate from wizard - var oldItems = await _context.JobItems - .Where(ji => ji.JobId == id && !ji.IsDeleted) - .ToListAsync(); + var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == id); + var oldItems = oldItemsEnum.ToList(); var itemsChanged = dto.JobItems.Any() || oldItems.Any(); foreach (var oldItem in oldItems) @@ -1349,7 +1240,7 @@ public class JobsController : Controller oldItem.IsDeleted = true; oldItem.DeletedAt = DateTime.UtcNow; } - if (oldItems.Any()) await _context.SaveChangesAsync(); + if (oldItems.Any()) await _unitOfWork.CompleteAsync(); foreach (var itemDto in dto.JobItems) { @@ -1419,7 +1310,7 @@ public class JobsController : Controller { foreach (var psDto in itemDto.PrepServices) { - await _context.JobItemPrepServices.AddAsync(new JobItemPrepService + await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, @@ -1698,11 +1589,13 @@ public class JobsController : Controller } // Update prep services - // Delete existing prep services - var existingPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == job.Id && !jps.IsDeleted) - .ToListAsync(); - _context.JobPrepServices.RemoveRange(existingPrepServices); + // Soft-delete existing prep services + var existingPrepServicesEnum = await _unitOfWork.JobPrepServices.FindAsync(jps => jps.JobId == job.Id); + foreach (var eps in existingPrepServicesEnum) + { + eps.IsDeleted = true; + eps.DeletedAt = DateTime.UtcNow; + } // Add new prep services if (dto.PrepServiceIds != null && dto.PrepServiceIds.Any()) @@ -1715,7 +1608,7 @@ public class JobsController : Controller PrepServiceId = prepServiceId, CompanyId = currentUser.CompanyId }; - await _context.JobPrepServices.AddAsync(jobPrepService); + await _unitOfWork.JobPrepServices.AddAsync(jobPrepService); } _logger.LogInformation("Updated prep services for job {JobNumber}: {Count} services", job.JobNumber, dto.PrepServiceIds.Count); } @@ -1753,11 +1646,7 @@ public class JobsController : Controller _logger.LogWarning(ex, "Notification failed for job {Id}", job.Id); } - var editNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.JobId == job.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id); this.SetNotificationResultToast(editNotifLog); } @@ -1945,23 +1834,13 @@ public class JobsController : Controller var month = DateTime.Now.Month.ToString("D2"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.JobNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB"; var prefix = $"{jobPrefix}-{year}{month}"; - // IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse) - // Explicit CompanyId filter scopes to current company only - var lastJobNumber = await _context.Jobs - .IgnoreQueryFilters() - .Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix)) - .OrderByDescending(j => j.JobNumber) - .Select(j => j.JobNumber) - .FirstOrDefaultAsync(); + var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix); if (lastJobNumber != null) { @@ -1987,27 +1866,14 @@ public class JobsController : Controller var today = date?.Date ?? DateTime.Today; // Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel) - var allStatuses = await _context.JobStatusLookups - .Where(s => !s.IsDeleted && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" - && s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED" - && s.StatusCode != "PENDING" && s.StatusCode != "APPROVED") - .OrderBy(s => s.DisplayOrder) - .ToListAsync(); + var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => + s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" + && s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED" + && s.StatusCode != "PENDING" && s.StatusCode != "APPROVED"); + var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); // Get all jobs scheduled for today with related data including items and coats - var jobQuery = _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Include(j => j.AssignedUser) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats) - .Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted && !j.JobStatus.IsTerminalStatus); - - if (!string.IsNullOrEmpty(userId)) - jobQuery = jobQuery.Where(j => j.AssignedUserId == userId); - - var jobs = await jobQuery.ToListAsync(); + var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today, userId); // Get existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities @@ -2107,27 +1973,13 @@ public class JobsController : Controller var companyId = _tenantContext.GetCurrentCompanyId(); if (!companyId.HasValue) return RedirectToAction(nameof(Index)); - var allStatuses = await _context.JobStatusLookups - .Where(s => !s.IsDeleted && !s.IsTerminalStatus - && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" - && s.StatusCode != "DELIVERED") - .OrderBy(s => s.DisplayOrder) - .ToListAsync(); + var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => + !s.IsTerminalStatus + && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" + && s.StatusCode != "DELIVERED"); + var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); - var jobQuery = _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Include(j => j.AssignedUser) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats) - .Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus - && j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED"); - - if (!string.IsNullOrEmpty(workerId)) - jobQuery = jobQuery.Where(j => j.AssignedUserId == workerId); - - var jobs = await jobQuery.ToListAsync(); + var jobs = await _unitOfWork.Jobs.GetActiveJobsForMobileAsync(companyId.Value, workerId); var jobDtos = jobs.Select(j => { @@ -2251,12 +2103,10 @@ public class JobsController : Controller { try { - var job = await _context.Jobs - .Include(j => j.JobStatus) - .FirstOrDefaultAsync(j => j.Id == request.JobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId); if (job == null) return Json(new { success = false, message = "Job not found" }); - var newStatus = await _context.JobStatusLookups.FindAsync(request.NewStatusId); + var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId); if (newStatus == null) return Json(new { success = false, message = "Status not found" }); var oldStatusId = job.JobStatusId; @@ -2330,9 +2180,8 @@ public class JobsController : Controller /// /// AJAX endpoint for inline worker reassignment on the Job Details page. - /// Uses EF ExecuteUpdateAsync (bulk update without loading the entity) for efficiency — - /// no need to load the full job just to update one FK. The response includes the worker's - /// display name so the UI can update the badge without a page reload. + /// Loads the job, sets , and saves. + /// The response includes the worker's display name so the UI can update the badge without a page reload. /// Passing WorkerId = null unassigns the current worker. /// [HttpPost] @@ -2341,16 +2190,15 @@ public class JobsController : Controller { try { - var rowsAffected = await _context.Jobs - .Where(j => j.Id == request.JobId) - .ExecuteUpdateAsync(s => s - .SetProperty(j => j.AssignedUserId, request.WorkerId) - .SetProperty(j => j.UpdatedAt, DateTime.UtcNow)); - - if (rowsAffected == 0) + var workerJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId); + if (workerJob == null) { return Json(new { success = false, message = "Job not found" }); } + workerJob.AssignedUserId = request.WorkerId; + workerJob.UpdatedAt = DateTime.UtcNow; + await _unitOfWork.Jobs.UpdateAsync(workerJob); + await _unitOfWork.CompleteAsync(); string? workerName = null; if (!string.IsNullOrEmpty(request.WorkerId)) @@ -2359,13 +2207,9 @@ public class JobsController : Controller workerName = user?.FullName; } - var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId); - if (assignedJob != null) - { - var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned"; - await BroadcastJobUpdate(assignedJob.CompanyId, assignedJob.JobNumber!, assignedJob.Id, - "WorkerChanged", assignDetail); - } + var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned"; + await BroadcastJobUpdate(workerJob.CompanyId, workerJob.JobNumber!, workerJob.Id, + "WorkerChanged", assignDetail); return Json(new { success = true, workerName = workerName }); } @@ -2476,11 +2320,7 @@ public class JobsController : Controller _logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId); } - var statusNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.JobId == request.JobId) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId); this.SetNotificationResultToast(statusNotifLog); } @@ -2545,14 +2385,8 @@ public class JobsController : Controller { try { - // Use AsNoTracking to fetch fresh data from database without EF caching - var photos = await _context.JobPhotos - .AsNoTracking() - .Include(p => p.UploadedBy) - .Where(p => p.JobId == jobId && !p.IsDeleted) - .OrderBy(p => p.DisplayOrder) - .ThenBy(p => p.UploadedDate) - .ToListAsync(); + var photosEnum = await _unitOfWork.JobPhotos.FindAsync(p => p.JobId == jobId, false, p => p.UploadedBy); + var photos = photosEnum.OrderBy(p => p.DisplayOrder).ThenBy(p => p.UploadedDate).ToList(); var photoDtos = photos .Select(p => _mapper.Map(p)) @@ -2740,7 +2574,7 @@ public class JobsController : Controller job.CompletedDate = DateTime.UtcNow; // Find the "Completed" status - var completedStatus = await _context.JobStatusLookups + var completedStatus = await _unitOfWork.JobStatusLookups .FirstOrDefaultAsync(s => s.StatusCode == "COMPLETED" && s.CompanyId == job.CompanyId); if (completedStatus != null) @@ -2844,11 +2678,7 @@ public class JobsController : Controller _logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId); } - var completeNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.JobId == dto.JobId) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId); this.SetNotificationResultToast(completeNotifLog); } @@ -2999,9 +2829,8 @@ public class JobsController : Controller try { // Soft-delete existing job items (and their children) - var oldItems = await _context.JobItems - .Where(ji => ji.JobId == job.Id && !ji.IsDeleted) - .ToListAsync(); + var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id); + var oldItems = oldItemsEnum.ToList(); foreach (var oldItem in oldItems) { @@ -3009,7 +2838,7 @@ public class JobsController : Controller oldItem.DeletedAt = DateTime.UtcNow; oldItem.DeletedBy = currentUser.UserName; } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); // Create new items foreach (var itemDto in model.JobItems) @@ -3094,7 +2923,7 @@ public class JobsController : Controller CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow }; - await _context.JobItemPrepServices.AddAsync(ps); + await _unitOfWork.JobItemPrepServices.AddAsync(ps); } } } @@ -3136,8 +2965,7 @@ public class JobsController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var item = await _context.JobItems - .FirstOrDefaultAsync(ji => ji.Id == id && !ji.IsDeleted); + var item = await _unitOfWork.JobItems.GetByIdAsync(id); if (item == null) return NotFound(); var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId); @@ -3146,14 +2974,11 @@ public class JobsController : Controller item.IsDeleted = true; item.DeletedAt = DateTime.UtcNow; item.DeletedBy = currentUser.UserName; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); // Recalculate job total from remaining items - var remainingItems = await _context.JobItems - .Where(ji => ji.JobId == job.Id && !ji.IsDeleted) - .Include(ji => ji.Coats) - .AsNoTracking() - .ToListAsync(); + var remainingItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id, false, ji => ji.Coats); + var remainingItems = remainingItemsEnum.ToList(); var remainingDtos = remainingItems.Select(ji => new CreateQuoteItemDto { @@ -3475,12 +3300,7 @@ public class JobsController : Controller [HttpPost] public async Task AddReworkRecord([FromBody] CreateReworkRecordDto dto) { - var job = await _context.Jobs - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) - .FirstOrDefaultAsync(j => j.Id == dto.JobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId); if (job == null) return NotFound(); var companyId = job.CompanyId; @@ -3572,7 +3392,7 @@ public class JobsController : Controller foreach (var prep in item.PrepServices) { - _context.Set().Add(new JobItemPrepService + await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = newItem.Id, PrepServiceId = prep.PrepServiceId, @@ -3742,16 +3562,7 @@ public class JobsController : Controller var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Load job with items, coats, and time entries - var job = await _context.Jobs - .Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted) - .Include(j => j.OvenCost) - .Include(j => j.Invoice) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .Include(j => j.TimeEntries.Where(t => !t.IsDeleted)) - .ThenInclude(t => t.Worker) - .AsNoTracking() - .FirstOrDefaultAsync(); + var job = await _unitOfWork.Jobs.LoadForCostingAsync(jobId, companyId); if (job == null) return NotFound(); diff --git a/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs b/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs index 7715121..9e74070 100644 --- a/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs +++ b/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs @@ -3,14 +3,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.PurchaseOrder; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Shared.Constants; using PowderCoating.Web.Helpers; @@ -23,7 +22,6 @@ public class PurchaseOrdersController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly IPdfService _pdfService; public PurchaseOrdersController( @@ -31,14 +29,12 @@ public class PurchaseOrdersController : Controller IMapper mapper, UserManager userManager, ILogger logger, - ApplicationDbContext context, IPdfService pdfService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; - _context = context; _pdfService = pdfService; } @@ -69,55 +65,10 @@ public class PurchaseOrdersController : Controller pageSize = Math.Clamp(pageSize, 10, 100); pageNumber = Math.Max(1, pageNumber); - var query = _context.Set() - .Include(po => po.Vendor) - .Include(po => po.Items.Where(i => !i.IsDeleted)) - .Where(po => !po.IsDeleted && po.CompanyId == currentUser.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(); + var (items, totalCount) = await _unitOfWork.PurchaseOrders.GetPagedAsync( + currentUser.CompanyId, pageNumber, pageSize, + statusFilter, vendorId, dateFrom, dateTo, + searchTerm, sortColumn, sortDirection); var dtos = _mapper.Map>(items); @@ -129,24 +80,12 @@ public class PurchaseOrdersController : Controller PageSize = pageSize }; - // Stats - var allForStats = await _context.Set() - .Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId) - .Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate }) - .ToListAsync(); - - ViewBag.TotalCount = allForStats.Count; - ViewBag.OpenCount = allForStats.Count(p => - p.Status == PurchaseOrderStatus.Draft || - p.Status == PurchaseOrderStatus.Submitted || - p.Status == PurchaseOrderStatus.PartiallyReceived); - ViewBag.CommittedValue = allForStats - .Where(p => p.Status != PurchaseOrderStatus.Cancelled) - .Sum(p => p.TotalAmount); - ViewBag.OverdueCount = allForStats.Count(p => - (p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived) - && p.ExpectedDeliveryDate.HasValue - && p.ExpectedDeliveryDate.Value.Date < DateTime.UtcNow.Date); + // Stats (server-side projection — only three columns fetched) + var stats = await _unitOfWork.PurchaseOrders.GetStatsAsync(currentUser.CompanyId); + ViewBag.TotalCount = stats.TotalCount; + ViewBag.OpenCount = stats.OpenCount; + ViewBag.CommittedValue = stats.CommittedValue; + ViewBag.OverdueCount = stats.OverdueCount; await PopulateVendorFilterDropdownAsync(currentUser.CompanyId); ViewBag.SearchTerm = searchTerm; @@ -172,13 +111,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Bill) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); var dto = _mapper.Map(po); @@ -283,10 +216,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { @@ -335,10 +265,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { @@ -416,10 +343,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled) @@ -487,10 +411,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) @@ -560,11 +481,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); @@ -618,11 +535,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); @@ -692,7 +605,7 @@ public class PurchaseOrdersController : Controller CompanyId = po.CompanyId }; - await _context.Set().AddAsync(transaction); + await _unitOfWork.InventoryTransactions.AddAsync(transaction); } } @@ -720,9 +633,8 @@ public class PurchaseOrdersController : Controller } po.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); - }); // end ExecuteInTransactionAsync + }); // end ExecuteInTransactionAsync — SaveChangesAsync called automatically before commit this.ToastSuccess(allReceived ? $"All items received for {po.PoNumber}." @@ -754,11 +666,7 @@ public class PurchaseOrdersController : Controller try { - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); @@ -806,14 +714,12 @@ public class PurchaseOrdersController : Controller if (currentUser == null) return Unauthorized(); // Find low-stock items that have a primary vendor - var lowStockItems = await _context.Set() - .Include(i => i.PrimaryVendor) - .Where(i => !i.IsDeleted - && i.IsActive - && i.CompanyId == currentUser.CompanyId - && i.PrimaryVendorId != null - && i.QuantityOnHand <= i.ReorderPoint) - .ToListAsync(); + var lowStockItems = (await _unitOfWork.InventoryItems.FindAsync( + i => i.IsActive && i.CompanyId == currentUser.CompanyId && + i.PrimaryVendorId != null && i.QuantityOnHand <= i.ReorderPoint, + false, + i => i.PrimaryVendor!)) + .ToList(); if (!lowStockItems.Any()) { @@ -878,11 +784,10 @@ public class PurchaseOrdersController : Controller { var prefix = $"PO-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix)) - .Select(po => po.PoNumber) - .ToListAsync(); + var existingPos = await _unitOfWork.PurchaseOrders.FindAsync( + po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix), + ignoreQueryFilters: true); + var existing = existingPos.Select(po => po.PoNumber).ToList(); var maxNum = 0; foreach (var num in existing) @@ -903,17 +808,18 @@ public class PurchaseOrdersController : Controller /// private async Task PopulateCreateViewBagAsync(int companyId) { - var vendors = await _context.Set() - .Where(v => !v.IsDeleted && v.CompanyId == companyId && v.IsActive) + var vendorEntities = await _unitOfWork.Vendors.FindAsync( + v => v.CompanyId == companyId && v.IsActive); + var vendors = vendorEntities .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())) - .ToListAsync(); - + .ToList(); vendors.Insert(0, new SelectListItem("— Select Vendor —", "")); ViewBag.Vendors = vendors; - var inventoryItems = await _context.Set() - .Where(i => !i.IsDeleted && i.CompanyId == companyId && i.IsActive) + var inventoryEntities = await _unitOfWork.InventoryItems.FindAsync( + i => i.CompanyId == companyId && i.IsActive); + var inventoryItems = inventoryEntities .OrderBy(i => i.Name) .Select(i => new { @@ -922,8 +828,7 @@ public class PurchaseOrdersController : Controller uom = i.UnitOfMeasure ?? "units", cost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost }) - .ToListAsync(); - + .ToList(); ViewBag.InventoryItemsJson = System.Text.Json.JsonSerializer.Serialize(inventoryItems); } @@ -934,12 +839,11 @@ public class PurchaseOrdersController : Controller /// private async Task PopulateVendorFilterDropdownAsync(int companyId) { - var vendors = await _context.Set() - .Where(v => !v.IsDeleted && v.CompanyId == companyId) + var vendorEntities = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); + var vendors = vendorEntities .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())) - .ToListAsync(); - + .ToList(); vendors.Insert(0, new SelectListItem("All Vendors", "")); ViewBag.VendorList = vendors; } diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index b271181..9f1f9d5 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Quote; @@ -15,7 +14,6 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Web.Extensions; using PowderCoating.Web.Helpers; @@ -30,7 +28,6 @@ public class QuotesController : Controller private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; - private readonly ApplicationDbContext _context; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly ILookupCacheService _lookupCache; @@ -51,7 +48,6 @@ public class QuotesController : Controller UserManager userManager, ILogger logger, IPdfService pdfService, - ApplicationDbContext context, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, @@ -71,7 +67,6 @@ public class QuotesController : Controller _userManager = userManager; _logger = logger; _pdfService = pdfService; - _context = context; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; @@ -234,11 +229,10 @@ public class QuotesController : Controller var approvedConvertedIds = quoteStatuses .Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED") .Select(s => s.Id).ToList(); - var allCompanyQuotes = _context.Quotes - .Where(q => q.CompanyId == companyId && !q.IsDeleted); - ViewBag.StatOpenCount = await allCompanyQuotes.CountAsync(q => draftSentIds.Contains(q.QuoteStatusId)); - ViewBag.StatApprovedCount = await allCompanyQuotes.CountAsync(q => approvedConvertedIds.Contains(q.QuoteStatusId)); - ViewBag.StatTotalValue = await allCompanyQuotes.SumAsync(q => (decimal?)q.Total) ?? 0m; + var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds); + ViewBag.StatOpenCount = indexStats.OpenCount; + ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount; + ViewBag.StatTotalValue = indexStats.TotalValue; // Calibration nudge — suppress when named blast setups exist OR legacy CFM is set var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); @@ -275,32 +269,14 @@ public class QuotesController : Controller try { - // Load quote with items and their related entities - var quote = await _unitOfWork.Quotes.GetByIdAsync( - id.Value, - false, - q => q.QuoteItems, - q => q.Customer, - q => q.PreparedBy, - q => q.QuoteStatus, - q => q.QuotePrepServices, - q => q.OvenCost - ); + // Load quote with all navigations needed for the Details view + var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value); if (quote == null) { return NotFound(); } - // Load prep services with their details - var quotePrepServices = await _context.QuotePrepServices - .Where(qps => qps.QuoteId == id.Value && !qps.IsDeleted) - .Include(qps => qps.PrepService) - .ToListAsync(); - - // Assign to quote so AutoMapper can map them - quote.QuotePrepServices = quotePrepServices; - var quoteDto = _mapper.Map(quote); // Get customer info if exists @@ -353,19 +329,7 @@ public class QuotesController : Controller } } - // Get quote items with their related entities (Coats, CatalogItem, and PrepServices) - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !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(); - - quoteDto.QuoteItems = _mapper.Map>(quoteItems); + quoteDto.QuoteItems = _mapper.Map>(quote.QuoteItems); // DEBUG: Log coat data _logger.LogInformation($"=== DETAILS VIEW: Quote {id} has {quoteDto.QuoteItems.Count} items ==="); @@ -427,13 +391,7 @@ public class QuotesController : Controller } // Load change history - var changeHistories = await _context.QuoteChangeHistories - .Where(h => h.QuoteId == id.Value && !h.IsDeleted) - .Include(h => h.ChangedBy) - .OrderByDescending(h => h.ChangedAt) - .AsNoTracking() - .ToListAsync(); - + var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value); var changeHistoryDtos = _mapper.Map>(changeHistories); ViewBag.ChangeHistory = changeHistoryDtos; @@ -458,12 +416,9 @@ public class QuotesController : Controller await PopulateEmailNotificationDefaultsAsync(currentUserDetails.CompanyId); // Load deposits recorded against this quote - var quoteDeposits = await _context.Set() - .Where(d => !d.IsDeleted && d.CompanyId == quote.CompanyId && d.QuoteId == id.Value) - .Include(d => d.RecordedBy) + var quoteDeposits = (await _unitOfWork.Deposits.FindAsync(d => d.QuoteId == id.Value, false, d => d.RecordedBy)) .OrderByDescending(d => d.ReceivedDate) - .AsNoTracking() - .ToListAsync(); + .ToList(); ViewBag.Deposits = quoteDeposits; // Customer list for inline customer-change dropdown @@ -564,16 +519,7 @@ public class QuotesController : Controller } } - // Get quote items with their related entities (Coats and CatalogItem) - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .ToListAsync(); - + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); quoteDto.QuoteItems = _mapper.Map>(quoteItems); // Get company info and logo @@ -632,10 +578,8 @@ public class QuotesController : Controller }; // Load company preferences for PDF template settings - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .AsNoTracking() - .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { @@ -1186,11 +1130,7 @@ public class QuotesController : Controller _logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id); } - var quoteCreateNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == quote.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id); this.SetNotificationResultToast(quoteCreateNotifLog); } @@ -1231,13 +1171,7 @@ public class QuotesController : Controller } // Get quote items with their coats, prep services and catalog item - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.PrepServices) - .Include(qi => qi.CatalogItem) - .ToListAsync(); + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); _logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value); foreach (var item in quoteItems) @@ -2230,15 +2164,13 @@ public class QuotesController : Controller // Check for an orphaned partial job (previous conversion attempt that failed mid-way). // This can happen if SaveChangesAsync succeeded for the Job row but failed for JobItems. // The unique index on Jobs.QuoteId would block a retry — clean it up first. - var orphanedJob = await _context.Jobs - .IgnoreQueryFilters() - .FirstOrDefaultAsync(j => j.QuoteId == id && j.CompanyId == quote.CompanyId); + var orphanedJob = await _unitOfWork.Jobs.GetOrphanedConversionJobAsync(id, quote.CompanyId); if (orphanedJob != null) { _logger.LogWarning("Found orphaned job {JobNumber} (Id={JobId}) from a previous failed conversion of quote {QuoteId}. Cleaning up.", orphanedJob.JobNumber, orphanedJob.Id, id); - _context.Jobs.Remove(orphanedJob); - await _context.SaveChangesAsync(); + await _unitOfWork.Jobs.DeleteAsync(orphanedJob); + await _unitOfWork.CompleteAsync(); } var currentUser = await _userManager.GetUserAsync(User); @@ -2351,25 +2283,11 @@ public class QuotesController : Controller } } - // Get quote items with their related entities (Coats and CatalogItem) - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .ToListAsync(); - + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); quoteDto.QuoteItems = _mapper.Map>(quoteItems); // Warn on confirmation page if a job is linked - var linkedJob = await _context.Jobs - .AsNoTracking() - .Where(j => j.QuoteId == id.Value && !j.IsDeleted) - .Select(j => new { j.Id, j.JobNumber }) - .FirstOrDefaultAsync(); - + var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id.Value); if (linkedJob != null) { ViewBag.LinkedJobId = linkedJob.Id; @@ -2403,12 +2321,7 @@ public class QuotesController : Controller } // Block deletion if a job was created from this quote - var linkedJob = await _context.Jobs - .AsNoTracking() - .Where(j => j.QuoteId == id && !j.IsDeleted) - .Select(j => new { j.Id, j.JobNumber }) - .FirstOrDefaultAsync(); - + var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id); if (linkedJob != null) { this.ToastError($"Quote {quote.QuoteNumber} cannot be deleted because Job {linkedJob.JobNumber} was created from it. Delete the job first, or keep the quote as a record."); @@ -2465,8 +2378,8 @@ public class QuotesController : Controller var currentUser = await _userManager.GetUserAsync(User); // Find the Approved status for this company - var approvedStatus = await _context.QuoteStatusLookups - .FirstOrDefaultAsync(s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId); + var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync( + s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId); if (approvedStatus == null) { @@ -2518,11 +2431,7 @@ public class QuotesController : Controller _logger.LogWarning(ex, "Notification failed for quote {Id}", id); } - var approveNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); this.SetNotificationResultToast(approveNotifLog); } @@ -2766,9 +2675,8 @@ public class QuotesController : Controller /// private async Task SetDefaultTermsAsync(int companyId) { - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters().AsNoTracking() - .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); ViewBag.DefaultTerms = prefs?.QtDefaultTerms; var costs = await _pricingService.GetOperatingCostsAsync(companyId); @@ -2841,13 +2749,7 @@ public class QuotesController : Controller } } - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted) - .Include(qi => qi.Coats).ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats).ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .ToListAsync(); - + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId); quoteDto.QuoteItems = _mapper.Map>(quoteItems); var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); @@ -2864,10 +2766,8 @@ public class QuotesController : Controller PrimaryContactEmail = company.PrimaryContactEmail }; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .AsNoTracking() - .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { @@ -2895,23 +2795,14 @@ public class QuotesController : Controller var now = DateTime.UtcNow; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.QuoteNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT"; var prefix = $"{quotePrefix}-{now:yy}{now:MM}"; // IgnoreQueryFilters so soft-deleted quotes are counted (prevents number reuse) - // Explicit CompanyId filter scopes to current company only - var lastQuoteNumber = await _context.Quotes - .IgnoreQueryFilters() - .Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix)) - .OrderByDescending(q => q.QuoteNumber) - .Select(q => q.QuoteNumber) - .FirstOrDefaultAsync(); + var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix); int nextNumber = 1; if (lastQuoteNumber != null) @@ -3017,15 +2908,7 @@ public class QuotesController : Controller // Always reload quote items with full coat/prep-service data so this works // regardless of which caller loaded the quote (some callers don't include coats). - var fullItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == quote.Id && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.PrepServices) - .AsNoTracking() - .ToListAsync(); + var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id); // Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts. @@ -3166,9 +3049,8 @@ public class QuotesController : Controller // Aggregate unique prep services from all quote items and copy to job // Load from DB directly to ensure prep services are available regardless of caller's includes var quoteItemIds = fullItems.Select(qi => qi.Id).ToList(); - var itemPrepServices = await _context.QuoteItemPrepServices - .Where(ps => quoteItemIds.Contains(ps.QuoteItemId) && !ps.IsDeleted) - .ToListAsync(); + var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync( + ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList(); var uniquePrepServiceIds = itemPrepServices .Select(ps => ps.PrepServiceId) .Distinct() @@ -3178,7 +3060,7 @@ public class QuotesController : Controller { foreach (var prepServiceId in uniquePrepServiceIds) { - await _context.JobPrepServices.AddAsync(new JobPrepService + await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, @@ -3186,7 +3068,7 @@ public class QuotesController : Controller CreatedAt = DateTime.UtcNow }); } - await _context.SaveChangesAsync(); + await _unitOfWork.SaveChangesAsync(); _logger.LogInformation("Copied {Count} unique prep services to job {JobNumber}", uniquePrepServiceIds.Count, job.JobNumber); } @@ -3200,10 +3082,8 @@ public class QuotesController : Controller // AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits try { - var quotePhotos = await _context.QuotePhotos - .IgnoreQueryFilters() - .Where(p => p.QuoteId == quote.Id && !p.IsDeleted) - .ToListAsync(); + var quotePhotos = (await _unitOfWork.QuotePhotos.FindAsync( + p => p.QuoteId == quote.Id && !p.IsDeleted, ignoreQueryFilters: true)).ToList(); foreach (var qp in quotePhotos) { @@ -3223,7 +3103,7 @@ public class QuotesController : Controller if (saved) { - await _context.JobPhotos.AddAsync(new JobPhoto + await _unitOfWork.JobPhotos.AddAsync(new JobPhoto { JobId = job.Id, CompanyId = quote.CompanyId, @@ -3239,7 +3119,7 @@ public class QuotesController : Controller }); } } - await _context.SaveChangesAsync(); + await _unitOfWork.SaveChangesAsync(); } catch (Exception photoEx) { @@ -3263,23 +3143,14 @@ public class QuotesController : Controller var month = DateTime.Now.Month.ToString("D2"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.JobNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB"; var prefix = $"{jobPrefix}-{year}{month}"; // IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse) - // Explicit CompanyId filter scopes to current company only - var lastJobNumber = await _context.Jobs - .IgnoreQueryFilters() - .Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix)) - .OrderByDescending(j => j.JobNumber) - .Select(j => j.JobNumber) - .FirstOrDefaultAsync(); + var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix); int nextNumber = 1; if (lastJobNumber != null) @@ -3352,11 +3223,7 @@ public class QuotesController : Controller await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename); // Check the most recent log entry to get actual send status - var latestLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); if (latestLog?.Status == NotificationStatus.Failed) return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); @@ -3383,16 +3250,10 @@ public class QuotesController : Controller public async Task NotificationsSent(int id) { var tz = ViewBag.CompanyTimeZone as string; - var raw = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == id) - .OrderByDescending(n => n.SentAt) - .Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), - Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.SentAt }) - .ToListAsync(); - - var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName, - n.Recipient, n.Subject, n.ErrorMessage, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); + var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id); + var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), + Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, + SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); return Json(logs); }