Phases 3 & 4: Complete data access architecture migration

Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
@@ -1,12 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Dashboard;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
@@ -17,7 +16,9 @@ public class DashboardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DashboardController> _logger;
private readonly ApplicationDbContext _context;
private readonly IDashboardReadService _dashboardRead;
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
private static readonly string[] CompletedStatusCodes =
[
@@ -39,14 +40,16 @@ public class DashboardController : Controller
"QUALITY_CHECK"
];
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
public DashboardController(IUnitOfWork unitOfWork, ILogger<DashboardController> logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
IDashboardReadService dashboardRead,
ITenantContext tenantContext,
ICompanyConfigHealthService configHealth)
{
_unitOfWork = unitOfWork;
_logger = logger;
_context = context;
_dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
}
@@ -66,23 +69,14 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes
var lookAheadDate = today.AddDays(7);
// Active jobs — filter completed/cancelled statuses at database level
var activeJobs = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
.ToListAsync();
var data = await _dashboardRead.GetIndexDataAsync(today);
var tomorrow = today.AddDays(1);
// Today's Jobs
var todaysJobsFiltered = activeJobs
// ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list
// ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
@@ -93,8 +87,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// Overdue Jobs
var overdueJobsFiltered = activeJobs
var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
@@ -104,8 +97,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// In-Progress Jobs
var inProgressJobs = activeJobs
var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
@@ -113,26 +105,11 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// Monthly Revenue — aggregate at database level (no need to load all jobs)
var monthlyRevenue = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.UpdatedAt >= startOfMonth
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
// Today's Appointments — filter at database level
var todaysAppointmentsRaw = await _context.Appointments
.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser)
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
&& a.AppointmentStatus.StatusCode != "CANCELLED")
.OrderBy(a => a.ScheduledStartTime)
.ToListAsync();
var todaysAppointmentsCount = todaysAppointmentsRaw.Count;
var todaysAppointments = todaysAppointmentsRaw
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto
{
@@ -150,9 +127,11 @@ public class DashboardController : Controller
AssignedWorkerName = a.AssignedUser?.FullName
}).ToList();
// ---------------------------------------------------------------
// Low stock items
// ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
@@ -168,21 +147,10 @@ public class DashboardController : Controller
UnitOfMeasure = i.UnitOfMeasure
}).ToList();
// Maintenance records — filter to pending/overdue at database level
var upcomingMaintenance = await _context.MaintenanceRecords
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate)
.Take(10)
.ToListAsync();
var upcomingMaintenanceDtos = upcomingMaintenance
// ---------------------------------------------------------------
// Maintenance
// ---------------------------------------------------------------
var upcomingMaintenanceDtos = data.UpcomingMaintenance
.Select(m => new DashboardMaintenanceDto
{
Id = m.Id,
@@ -195,14 +163,10 @@ public class DashboardController : Controller
AssignedWorkerName = m.AssignedUser?.FullName
}).ToList();
// Pending Quotes — filter to SENT status at database level
var pendingQuotesData = await _context.Quotes
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.QuoteStatus.StatusCode == "SENT")
.ToListAsync();
var pendingQuotes = pendingQuotesData
// ---------------------------------------------------------------
// Quotes
// ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto
@@ -221,10 +185,9 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total);
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
// Expiring Quotes (next 7 days) - filter at database level
var expiringQuotes = pendingQuotesData
var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
@@ -246,33 +209,17 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
// Active Customers
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
// Financial data — Invoices & Payments
// Invoices & AR aging
// ---------------------------------------------------------------
var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
// Open invoices only — filter at database level
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => openStatuses.Contains(i.Status))
.ToListAsync();
var outstandingAr = openInvoices.Sum(i => i.BalanceDue);
// Invoiced this month — aggregate at database level
var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff
&& i.InvoiceDate >= startOfMonth
&& i.InvoiceDate <= endOfMonth)
.SumAsync(i => i.Total);
// Overdue invoices: open and past due date
var overdueInvoicesList = openInvoices
var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
@@ -295,9 +242,9 @@ public class DashboardController : Controller
})
.ToList();
// AR Aging bucket open invoices by days past due
// AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
foreach (var inv in openInvoices)
foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
@@ -313,17 +260,10 @@ public class DashboardController : Controller
}
}
// Payments this month — aggregate at database level
var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
.SumAsync(p => p.Amount);
// Recent payments — load only the 6 most recent
var recentPayments = (await _context.Payments
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync())
// ---------------------------------------------------------------
// Payments
// ---------------------------------------------------------------
var recentPayments = data.RecentPayments
.Select(p => new DashboardPaymentDto
{
Id = p.Id,
@@ -335,44 +275,39 @@ public class DashboardController : Controller
PaymentDate = p.PaymentDate,
PaymentMethodDisplay = p.PaymentMethod switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital",
PaymentMethod.Cash => "Cash",
PaymentMethod.Check => "Check",
PaymentMethod.CreditDebitCard => "Card",
PaymentMethod.BankTransferACH => "ACH",
PaymentMethod.DigitalPayment => "Digital",
_ => "Other"
}
})
.ToList();
// Equipment Alerts - filter at database level
// ---------------------------------------------------------------
// Equipment alerts
// ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance ||
e.Status == Core.Enums.EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0)
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
EquipmentName = e.EquipmentName,
EquipmentType = e.EquipmentType,
Issue = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning",
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
LastMaintenanceDate = e.LastMaintenanceDate,
NextMaintenanceDue = null // Equipment doesn't track next maintenance due date
NextMaintenanceDue = null
}).ToList();
// Recent Activity (last 10 quotes or jobs created in last 30 days)
var last30Days = today.AddDays(-30);
// Recent quotes — filter to last 30 days at database level
var recentQuotes = (await _context.Quotes
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.CreatedAt >= last30Days)
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync())
// ---------------------------------------------------------------
// Recent activity
// ---------------------------------------------------------------
var recentQuoteDtos = data.RecentQuotes
.Select(q => new DashboardRecentActivityDto
{
Id = q.Id,
@@ -390,14 +325,7 @@ public class DashboardController : Controller
Amount = q.Total
});
// Recent jobs — filter to last 30 days at database level
var recentJobs = (await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync())
var recentJobDtos = data.RecentJobs
.Select(j => new DashboardRecentActivityDto
{
Id = j.Id,
@@ -413,33 +341,15 @@ public class DashboardController : Controller
Amount = j.FinalPrice
});
var recentActivity = recentQuotes.Concat(recentJobs)
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
.OrderByDescending(a => a.ActivityDate)
.Take(10)
.ToList();
// === POWDER ORDERS NEEDED ===
var jobsNeedingPowder = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
.ToListAsync();
// Flatten to individual coat lines that need ordering (with vendor info for grouping)
var powderFlat = jobsNeedingPowder
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
@@ -500,26 +410,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
// === POWDER ORDERS PLACED (ordered, awaiting receipt) ===
var jobsWithOrderedPowder = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
c.PowderOrdered &&
!c.PowderReceived)))
.ToListAsync();
var placedFlat = jobsWithOrderedPowder
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
@@ -584,16 +478,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
// === BILLS DUE ===
var billsDueRaw = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted &&
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
var billsDue = billsDueRaw.Select(b => new DashboardBillDto
// ---------------------------------------------------------------
// Bills due
// ---------------------------------------------------------------
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
{
Id = b.Id,
BillNumber = b.BillNumber,
@@ -608,21 +496,21 @@ public class DashboardController : Controller
var vm = new DashboardViewModel
{
// Counts
ActiveJobsCount = activeJobs.Count(),
ActiveJobsCount = data.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
PendingMaintenanceCount = upcomingMaintenance.Count,
PendingQuotesCount = pendingQuotesData.Count(),
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
MonthlyRevenue = monthlyRevenue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
CollectedThisMonth = collectedThisMonth,
InvoicedThisMonth = invoicedThisMonth,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
@@ -654,7 +542,9 @@ public class DashboardController : Controller
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.Count
PowderOrdersPlacedCount = placedFlat.Count,
TipOfTheDay = data.TipOfTheDay
};
// Dropdowns for the "Add Custom Powder to Inventory" modal
@@ -671,13 +561,6 @@ public class DashboardController : Controller
ViewBag.InventoryCategories = inventoryCategories;
ViewBag.VendorList = vendors;
// Random tip of the day
var tips = await _context.DashboardTips
.Where(t => t.IsActive)
.ToListAsync();
if (tips.Count > 0)
vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText;
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
@@ -705,11 +588,7 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
.Include(c => c.Vendor)
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
.FirstOrDefaultAsync(c => c.Id == coatId);
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat not found." });
@@ -722,7 +601,7 @@ public class DashboardController : Controller
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
@@ -761,9 +640,9 @@ public class DashboardController : Controller
/// Records receipt of a powder shipment against an existing powder order. Sets
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
/// and — when the coat is linked to an inventory item — increases <c>QuantityOnHand</c> and
/// writes a <c>Purchase</c> <see cref="PowderCoating.Core.Entities.InventoryTransaction"/> so
/// the stock movement is fully traceable. Company ownership is verified through the parent job
/// because <c>JobItemCoat</c> carries no <c>CompanyId</c> of its own.
/// writes a <c>Purchase</c> <see cref="InventoryTransaction"/> so the stock movement is fully
/// traceable. Company ownership is verified through the parent job because <c>JobItemCoat</c>
/// carries no <c>CompanyId</c> of its own.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
@@ -771,9 +650,8 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.InventoryItem)
.FirstOrDefaultAsync(c => c.Id == coatId);
// Load coat with inventory item for the stock update
var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -781,29 +659,25 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Verify ownership — JobItemCoat has no CompanyId, check via parent job
// (We need the job for company check; load it if not already included)
// Verify ownership — JobItemCoat has no CompanyId, check via parent job.
// If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
// query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
if (coatJobCompanyId == null)
{
// Reload with parent chain if not included
var coatWithJob = await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job)
.FirstOrDefaultAsync(c => c.Id == coatId);
coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId;
await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
}
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
// Mark coat as received
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
// Update inventory if this coat is linked to an inventory item
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
{
var item = coat.InventoryItem;
@@ -813,26 +687,24 @@ public class DashboardController : Controller
if (coat.PowderCostPerLb.HasValue)
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
// Record purchase transaction
var transaction = new PowderCoating.Core.Entities.InventoryTransaction
var transaction = new InventoryTransaction
{
CompanyId = item.CompanyId,
InventoryItemId = item.Id,
TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
TransactionDate = DateTime.UtcNow,
Reference = coat.JobItem != null ? null : null, // loaded below if needed
Notes = $"Received {lbsReceived:N2} lbs for job order",
BalanceAfter = previousBalance + lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_context.Set<PowderCoating.Core.Entities.InventoryTransaction>().Add(transaction);
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
}
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
}
@@ -864,7 +736,7 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats.FindAsync(coatId);
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -877,16 +749,13 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Resolve company id from tenant context
var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0;
// More reliably get CompanyId from the job chain
var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId));
if (jobItem?.Job != null)
companyId = jobItem.Job.CompanyId;
// Resolve company id from the job chain; fall back to tenant context
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim());
if (existingSku)
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -925,10 +794,10 @@ public class DashboardController : Controller
UpdatedAt = DateTime.UtcNow,
};
_context.InventoryItems.Add(inventoryItem);
await _context.SaveChangesAsync();
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
// Record opening stock transaction
// Opening stock transaction
var transaction = new InventoryTransaction
{
CompanyId = companyId,
@@ -943,7 +812,7 @@ public class DashboardController : Controller
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_context.Set<InventoryTransaction>().Add(transaction);
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Mark coat as received and link to the new inventory item
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
@@ -953,19 +822,12 @@ public class DashboardController : Controller
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
// Scan for other active job coats using the same custom powder and link them
var candidateCoats = await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => !c.IsDeleted
&& c.Id != coatId
&& c.InventoryItemId == null
&& c.JobItem.CompanyId == companyId)
.ToListAsync();
// Scan for sibling coats with the same custom powder and link them to the new item
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
int linkedCount = 0;
foreach (var other in candidateCoats)
{
// Match by color code first (most specific), then fall back to color name
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
@@ -973,7 +835,6 @@ public class DashboardController : Controller
if (!colorMatch) continue;
// If both coats have a vendor set, they must agree
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
@@ -985,7 +846,7 @@ public class DashboardController : Controller
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
@@ -1014,13 +875,10 @@ public class DashboardController : Controller
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
var totalUsers = await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
// Load plan configs from DB so plan display names and distribution are DB-driven
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
@@ -1029,7 +887,6 @@ public class DashboardController : Controller
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
// Companies needing attention: expired (past grace) or in grace period
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
@@ -1066,7 +923,6 @@ public class DashboardController : Controller
})
.ToList();
// Build plan distribution from DB config (sorted by SortOrder)
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));