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