Phase 2: Eliminate ApplicationDbContext from domain controllers

Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.

New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.

All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 21:20:39 -04:00
parent 80b0e547cc
commit 90bc0d965f
20 changed files with 730 additions and 878 deletions
@@ -22,7 +22,9 @@ public interface IUnitOfWork : IDisposable
IRepository<JobDailyPriority> JobDailyPriorities { get; }
IRepository<JobItem> JobItems { get; }
IRepository<JobItemCoat> JobItemCoats { get; }
IRepository<JobItemPrepService> JobItemPrepServices { get; }
IRepository<JobChangeHistory> JobChangeHistories { get; }
IRepository<JobPrepService> JobPrepServices { get; }
IQuoteRepository Quotes { get; }
IRepository<QuotePhoto> QuotePhotos { get; }
IRepository<QuoteItem> QuoteItems { get; }
@@ -87,8 +89,8 @@ public interface IUnitOfWork : IDisposable
IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; }
// Notifications
IRepository<NotificationLog> NotificationLogs { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
// Subscription
@@ -20,4 +20,23 @@ public interface IBillRepository : IRepository<Bill>
/// navigations since those are read-only after the bill is opened.
/// </summary>
Task<Bill?> LoadForEditAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
/// <summary>
/// Returns the last bill number with the given prefix (including soft-deleted records) for
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
/// </summary>
Task<string?> GetLastBillNumberAsync(string prefix);
/// <summary>
/// Returns the last payment number with the given prefix (including soft-deleted records)
/// for sequential payment reference generation.
/// </summary>
Task<string?> GetLastPaymentNumberAsync(string prefix);
}
@@ -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<Invoice>
/// Returns null if the token does not match any invoice.
/// </summary>
Task<Invoice?> GetByPaymentTokenAsync(string token);
/// <summary>
/// Returns the last invoice number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted invoices) for sequential number generation.
/// </summary>
Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// 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.
/// </summary>
Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to);
/// <summary>
/// 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.
/// </summary>
Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to);
}
@@ -42,4 +42,40 @@ public interface IJobRepository : IRepository<Job>
/// loaded. Used by the Details view changelog tab.
/// </summary>
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
/// <summary>
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted jobs) for sequential number generation.
/// </summary>
Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// Looks up a job created from <paramref name="quoteId"/> 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.
/// </summary>
Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId);
/// <summary>
/// Loads all jobs scheduled for <paramref name="date"/> that are not in a terminal status,
/// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations.
/// Optionally filtered to a single worker when <paramref name="userId"/> is supplied.
/// Used by the ShopDisplay (TV board) action.
/// </summary>
Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null);
/// <summary>
/// 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 <paramref name="workerId"/> is supplied.
/// </summary>
Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null);
/// <summary>
/// Loads a single job with the navigations required by the costing breakdown endpoint:
/// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker).
/// Scoped to <paramref name="companyId"/> as an extra safety check.
/// Returns null if not found.
/// </summary>
Task<Job?> LoadForCostingAsync(int jobId, int companyId);
}
@@ -0,0 +1,30 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="NotificationLog"/> 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.
/// </summary>
public interface INotificationLogRepository : IRepository<NotificationLog>
{
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
}
@@ -3,6 +3,9 @@ using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats.</summary>
public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount);
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
@@ -16,6 +19,12 @@ public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
/// </summary>
Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId);
/// <summary>
/// Returns KPI aggregate stats for the Index view using a server-side projection so only three
/// columns are fetched rather than full entities.
/// </summary>
Task<PurchaseOrderStats> GetStatsAsync(int companyId);
/// <summary>
/// Returns a paged, filtered, and sorted list of purchase orders for the Index view.
/// All filter parameters are optional — passing null/empty applies no restriction for
@@ -2,6 +2,9 @@ using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Aggregate counts and totals for the Quotes Index stat cards.</summary>
public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue);
/// <summary>
/// Typed repository for <see cref="Quote"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
@@ -27,4 +30,24 @@ public interface IQuoteRepository : IRepository<Quote>
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
/// </summary>
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
/// <summary>
/// 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.
/// </summary>
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
/// <summary>
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
/// because it skips the parent-quote navigations that callers already have.
/// </summary>
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
/// <summary>
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted quotes) for sequential number generation.
/// </summary>
Task<string?> GetLastQuoteNumberByPrefixAsync(int companyId, string prefix);
}
@@ -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<Bill>, IBillRepository
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Bill>> 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();
}
/// <inheritdoc/>
public async Task<string?> GetLastBillNumberAsync(string prefix)
{
return await _context.Bills
.IgnoreQueryFilters()
.Where(b => b.BillNumber.StartsWith(prefix))
.OrderByDescending(b => b.BillNumber)
.Select(b => b.BillNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
{
return await _context.BillPayments
.IgnoreQueryFilters()
.Where(p => p.PaymentNumber.StartsWith(prefix))
.OrderByDescending(p => p.PaymentNumber)
.Select(p => p.PaymentNumber)
.FirstOrDefaultAsync();
}
}
@@ -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<Invoice>, IInvoiceRepository
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
}
/// <inheritdoc/>
public async Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix)
{
return await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix))
.OrderByDescending(i => i.InvoiceNumber)
.Select(i => i.InvoiceNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to)
{
return await _context.Set<Invoice>()
.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();
}
/// <inheritdoc/>
public async Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to)
{
return await _context.Set<Refund>()
.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();
}
}
@@ -103,4 +103,76 @@ public class JobRepository : Repository<Job>, IJobRepository
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<string?> 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();
}
/// <inheritdoc/>
public async Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.FirstOrDefaultAsync(j => j.QuoteId == quoteId && j.CompanyId == companyId);
}
/// <inheritdoc/>
public async Task<List<Job>> 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();
}
/// <inheritdoc/>
public async Task<List<Job>> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
}
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="NotificationLog"/> that provides IgnoreQueryFilters-based
/// lookups by entity FK (InvoiceId, QuoteId, JobId).
/// </summary>
public class NotificationLogRepository : Repository<NotificationLog>, INotificationLogRepository
{
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == invoiceId)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
/// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == invoiceId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.QuoteId == quoteId)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
/// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.QuoteId == quoteId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
/// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
}
@@ -26,6 +26,29 @@ public class PurchaseOrderRepository : Repository<PurchaseOrder>, IPurchaseOrder
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<PurchaseOrderStats> GetStatsAsync(int companyId)
{
var today = DateTime.UtcNow.Date;
var items = await _context.Set<PurchaseOrder>()
.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)
);
}
/// <inheritdoc/>
public async Task<(List<PurchaseOrder> Items, int TotalCount)> GetPagedAsync(
int companyId,
@@ -67,4 +67,45 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> 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));
}
/// <inheritdoc/>
public async Task<List<QuoteItem>> 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();
}
/// <inheritdoc/>
public async Task<string?> 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();
}
}
@@ -49,7 +49,9 @@ public class UnitOfWork : IUnitOfWork
private IRepository<JobDailyPriority>? _jobDailyPriorities;
private IRepository<JobItem>? _jobItems;
private IRepository<JobItemCoat>? _jobItemCoats;
private IRepository<JobItemPrepService>? _jobItemPrepServices;
private IRepository<JobChangeHistory>? _jobChangeHistories;
private IRepository<JobPrepService>? _jobPrepServices;
private IQuoteRepository? _quotes;
private IRepository<QuotePhoto>? _quotePhotos;
private IRepository<QuoteItem>? _quoteItems;
@@ -88,7 +90,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
// Notifications
private IRepository<NotificationLog>? _notificationLogs;
private INotificationLogRepository? _notificationLogs;
private IRepository<NotificationTemplate>? _notificationTemplates;
// Subscription
@@ -190,11 +192,17 @@ public class UnitOfWork : IUnitOfWork
/// <summary>Repository for <see cref="JobItemCoat"/> powder coat passes; tenant-filtered with soft delete.</summary>
public IRepository<JobItemCoat> JobItemCoats =>
_jobItemCoats ??= new Repository<JobItemCoat>(_context);
public IRepository<JobItemPrepService> JobItemPrepServices =>
_jobItemPrepServices ??= new Repository<JobItemPrepService>(_context);
/// <summary>Repository for <see cref="JobChangeHistory"/> audit entries; tenant-filtered with soft delete.</summary>
public IRepository<JobChangeHistory> JobChangeHistories =>
_jobChangeHistories ??= new Repository<JobChangeHistory>(_context);
/// <summary>Repository for <see cref="JobPrepService"/> job-level prep service assignments; tenant-filtered with soft delete.</summary>
public IRepository<JobPrepService> JobPrepServices =>
_jobPrepServices ??= new Repository<JobPrepService>(_context);
/// <summary>Repository for <see cref="Quote"/> records with multi-item pricing; tenant-filtered with soft delete.</summary>
public IQuoteRepository Quotes =>
_quotes ??= new QuoteRepository(_context);
@@ -351,9 +359,9 @@ public class UnitOfWork : IUnitOfWork
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
// Notifications
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
public IRepository<NotificationLog> NotificationLogs =>
_notificationLogs ??= new Repository<NotificationLog>(_context);
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; provides IgnoreQueryFilters lookups by InvoiceId, QuoteId, and JobId for notification history panels.</summary>
public INotificationLogRepository NotificationLogs =>
_notificationLogs ??= new NotificationLogRepository(_context);
/// <summary>Repository for <see cref="NotificationTemplate"/> per-company channel template overrides; unique on (CompanyId, Type, Channel).</summary>
public IRepository<NotificationTemplate> NotificationTemplates =>
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<BillsController> _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<ApplicationUser> userManager,
ILogger<BillsController> 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<Core.Entities.Expense>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<BillDto>(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<string> 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<string> 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))
@@ -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<ApplicationUser> _userManager;
public CustomersController(
@@ -35,7 +34,6 @@ public class CustomersController : Controller
INotificationService notificationService,
ISubscriptionService subscriptionService,
ITenantContext tenantContext,
ApplicationDbContext context,
UserManager<ApplicationUser> 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);
}
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<InvoicesController> _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<ApplicationUser> userManager,
ILogger<InvoicesController> 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<SubscriptionPlanConfig>()
.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<Invoice>()
.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<int, CatalogItem>();
// Fall back to the default revenue account (4000) if a catalog item has no specific account
var defaultRevenueAccount = await _context.Set<Account>()
.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<PowderCoating.Core.Entities.Quote>()
.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<Invoice>()
.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<Deposit>()
.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<Invoice>()
.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<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// 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 <see cref="IInvoiceRepository.LoadForViewAsync"/> which expresses the full
/// eight-table include chain. Returns null if not found or soft-deleted.
/// </summary>
private async Task<Invoice?> LoadInvoiceForViewAsync(int id)
{
return await _context.Set<Invoice>()
.Where(i => i.Id == id && !i.IsDeleted)
.Include(i => i.Customer)
.Include(i => i.Job)
.Include(i => i.PreparedBy)
.Include(i => i.SalesTaxAccount)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.ThenInclude(ii => ii.RevenueAccount)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.ThenInclude(ii => ii.GeneratedGiftCertificate)
.Include(i => i.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.RecordedBy)
.Include(i => i.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.DepositAccount)
.Include(i => i.Refunds.Where(r => !r.IsDeleted))
.ThenInclude(r => r.IssuedBy)
.Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted))
.ThenInclude(ca => ca.CreditMemo)
.Include(i => i.GiftCertificateRedemptions)
.FirstOrDefaultAsync();
}
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
await _unitOfWork.Invoices.LoadForViewAsync(id);
/// <summary>
/// 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<CreditMemo>()
.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
/// </summary>
private async Task<string> 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<Invoice>()
.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<CatalogItem>()
.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;
}
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active OtherCurrentLiability with "tax" in the name.</summary>
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{
var taxAccount = await _context.Set<Account>()
.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
/// </summary>
private async Task<string?> 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<SubscriptionPlanConfig>()
.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
{
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<JobsController> _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<ApplicationUser> userManager,
ILogger<JobsController> 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<IActionResult> 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<JobDto>(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<List<JobChangeHistoryDto>>(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<Invoice>()
.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<Deposit>()
.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<InventoryTransaction>()
.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<JobDto>(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<int, string> 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
/// <summary>
/// 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 <see cref="Job.AssignedUserId"/>, 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.
/// </summary>
[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<JobPhotoDto>(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<IActionResult> 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<JobItemPrepService>().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();
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<PurchaseOrdersController> _logger;
private readonly ApplicationDbContext _context;
private readonly IPdfService _pdfService;
public PurchaseOrdersController(
@@ -31,14 +29,12 @@ public class PurchaseOrdersController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<PurchaseOrdersController> 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<PurchaseOrder>()
.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<List<PurchaseOrderListDto>>(items);
@@ -129,24 +80,12 @@ public class PurchaseOrdersController : Controller
PageSize = pageSize
};
// Stats
var allForStats = await _context.Set<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrderDto>(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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<InventoryTransaction>().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<PurchaseOrder>()
.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<InventoryItem>()
.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<PurchaseOrder>()
.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
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId)
{
var vendors = await _context.Set<Vendor>()
.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<InventoryItem>()
.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
/// </summary>
private async Task PopulateVendorFilterDropdownAsync(int companyId)
{
var vendors = await _context.Set<Vendor>()
.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;
}
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<QuotesController> _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<ApplicationUser> userManager,
ILogger<QuotesController> 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<QuoteDto>(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<List<QuoteItemDto>>(quoteItems);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(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<List<QuoteChangeHistoryDto>>(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<Deposit>()
.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<List<QuoteItemDto>>(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<List<QuoteItemDto>>(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
/// </summary>
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<List<QuoteItemDto>>(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<IActionResult> 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);
}