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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user