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:
@@ -11,8 +11,6 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QRCoder;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -29,7 +27,6 @@ public class InventoryController : Controller
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public InventoryController(
|
||||
@@ -40,7 +37,6 @@ public class InventoryController : Controller
|
||||
IMeasurementConversionService measurementService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
@@ -50,7 +46,6 @@ public class InventoryController : Controller
|
||||
_measurementService = measurementService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@@ -166,12 +161,12 @@ public class InventoryController : Controller
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
// Push stats and category list to the database rather than loading all rows
|
||||
var statsBase = _context.InventoryItems;
|
||||
ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync();
|
||||
ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = await statsBase.SumAsync(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
// Load all items once to compute sidebar stats and category list in memory
|
||||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -560,20 +555,8 @@ public class InventoryController : Controller
|
||||
if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name))
|
||||
return Json(new { success = true, photos = Array.Empty<object>(), totalCount = 0, page, pageSize });
|
||||
|
||||
IQueryable<JobPhoto> query = _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||||
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
|
||||
|
||||
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name)
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name));
|
||||
else if (!string.IsNullOrEmpty(colorName))
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
|
||||
else
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(name!));
|
||||
|
||||
// Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
|
||||
var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
|
||||
var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name);
|
||||
|
||||
var searchTerms = new[] { colorName, name }
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
@@ -620,15 +603,7 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var photos = await _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||||
.Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats)
|
||||
.Where(p => !p.IsDeleted &&
|
||||
p.Job != null &&
|
||||
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)))
|
||||
.OrderByDescending(p => p.UploadedDate)
|
||||
.ToListAsync();
|
||||
var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id);
|
||||
|
||||
var totalCount = photos.Count;
|
||||
var paged = photos
|
||||
@@ -728,12 +703,12 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var allCoatings = await _context.InventoryItems
|
||||
.AsNoTracking()
|
||||
.Include(i => i.InventoryCategory)
|
||||
.Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating)
|
||||
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
||||
false,
|
||||
i => i.InventoryCategory))
|
||||
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Distinct manufacturer list for filter dropdown
|
||||
ViewBag.Manufacturers = allCoatings
|
||||
@@ -955,25 +930,39 @@ public class InventoryController : Controller
|
||||
|
||||
var userId = _userManager.GetUserId(User);
|
||||
|
||||
var myJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId)
|
||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
|
||||
.ToListAsync();
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||
var otherJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id))
|
||||
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(100)
|
||||
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
|
||||
.ToListAsync();
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
ViewBag.ItemDto = _mapper.Map<InventoryItemDto>(item);
|
||||
ViewBag.MyJobs = myJobs;
|
||||
@@ -1169,29 +1158,8 @@ public class InventoryController : Controller
|
||||
.ToList();
|
||||
|
||||
// Build transactions query
|
||||
var txnQuery = _context.InventoryTransactions
|
||||
.AsNoTracking()
|
||||
.Include(t => t.InventoryItem)
|
||||
.Include(t => t.PurchaseOrder)
|
||||
.Include(t => t.Job)
|
||||
.Where(t => !t.IsDeleted);
|
||||
|
||||
if (inventoryItemId.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var parsedType))
|
||||
txnQuery = txnQuery.Where(t => t.TransactionType == parsedType);
|
||||
|
||||
var transactions = await txnQuery
|
||||
.OrderByDescending(t => t.TransactionDate)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var pt) ? pt : null;
|
||||
var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType);
|
||||
|
||||
// Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId
|
||||
var unresolvedRefs = transactions
|
||||
@@ -1202,36 +1170,12 @@ public class InventoryController : Controller
|
||||
var jobRefLookup = new Dictionary<string, (int Id, string JobNumber)>();
|
||||
if (unresolvedRefs.Any())
|
||||
{
|
||||
var matched = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => unresolvedRefs.Contains(j.JobNumber))
|
||||
.Select(j => new { j.Id, j.JobNumber })
|
||||
.ToListAsync();
|
||||
var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber));
|
||||
jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
|
||||
}
|
||||
|
||||
// Build powder usage logs query
|
||||
var usageQuery = _context.PowderUsageLogs
|
||||
.AsNoTracking()
|
||||
.Include(u => u.Job).ThenInclude(j => j.Customer)
|
||||
.Include(u => u.InventoryItem)
|
||||
.Include(u => u.JobItemCoat)
|
||||
.Where(u => !u.IsDeleted);
|
||||
|
||||
if (inventoryItemId.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1));
|
||||
|
||||
// Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display)
|
||||
var usageLogs = await usageQuery
|
||||
.OrderByDescending(u => u.RecordedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
// Powder usage logs with dynamic date + item filters
|
||||
var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo);
|
||||
|
||||
InventoryItem? selectedItem = null;
|
||||
if (inventoryItemId.HasValue)
|
||||
|
||||
Reference in New Issue
Block a user