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