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:
@@ -0,0 +1,225 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
|
||||
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
|
||||
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
|
||||
/// </summary>
|
||||
public class DashboardReadService : IDashboardReadService
|
||||
{
|
||||
private static readonly string[] CompletedStatusCodes =
|
||||
[
|
||||
"COMPLETED",
|
||||
"READY_FOR_PICKUP",
|
||||
"DELIVERED",
|
||||
"CANCELLED"
|
||||
];
|
||||
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public DashboardReadService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today)
|
||||
{
|
||||
var startOfMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
|
||||
var tomorrow = today.AddDays(1);
|
||||
var lookAheadDate = today.AddDays(7);
|
||||
var last30Days = today.AddDays(-30);
|
||||
|
||||
var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
|
||||
|
||||
// All active jobs (for today/overdue/in-progress panels)
|
||||
var activeJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
|
||||
.ToListAsync();
|
||||
|
||||
// Monthly revenue — sum completed jobs updated in current month
|
||||
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 (non-cancelled)
|
||||
var todaysAppointments = await _context.Appointments
|
||||
.AsNoTracking()
|
||||
.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();
|
||||
|
||||
// Upcoming/overdue maintenance
|
||||
var upcomingMaintenance = await _context.MaintenanceRecords
|
||||
.AsNoTracking()
|
||||
.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();
|
||||
|
||||
// Pending quotes (SENT status)
|
||||
var pendingQuotes = await _context.Quotes
|
||||
.AsNoTracking()
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.QuoteStatus.StatusCode == "SENT")
|
||||
.ToListAsync();
|
||||
|
||||
// Open invoices (for AR aging + overdue list)
|
||||
var openInvoices = await _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => openInvoiceStatuses.Contains(i.Status))
|
||||
.ToListAsync();
|
||||
|
||||
// Invoiced this month
|
||||
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);
|
||||
|
||||
// Collected this month
|
||||
var collectedThisMonth = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
|
||||
.SumAsync(p => p.Amount);
|
||||
|
||||
// Recent payments with Invoice → Customer
|
||||
var recentPayments = await _context.Payments
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
|
||||
.OrderByDescending(p => p.PaymentDate)
|
||||
.Take(6)
|
||||
.ToListAsync();
|
||||
|
||||
// Recent quotes (last 30 days)
|
||||
var recentQuotes = await _context.Quotes
|
||||
.AsNoTracking()
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.CreatedAt >= last30Days)
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
// Recent jobs (last 30 days)
|
||||
var recentJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => j.CreatedAt >= last30Days)
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
// Jobs needing powder (not yet ordered, insufficient stock)
|
||||
var jobsNeedingPowder = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.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();
|
||||
|
||||
// Jobs with powder already ordered but not yet received
|
||||
var jobsWithOrderedPowder = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.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();
|
||||
|
||||
// Bills due (open/partial, balance remaining)
|
||||
var billsDue = await _context.Bills
|
||||
.AsNoTracking()
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
|
||||
&& b.Total > b.AmountPaid)
|
||||
.OrderBy(b => b.DueDate)
|
||||
.Take(15)
|
||||
.ToListAsync();
|
||||
|
||||
// Random tip of the day
|
||||
var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync();
|
||||
var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null;
|
||||
|
||||
return new DashboardIndexData(
|
||||
ActiveJobs: activeJobs,
|
||||
MonthlyRevenue: monthlyRevenue,
|
||||
TodaysAppointments: todaysAppointments,
|
||||
UpcomingMaintenance: upcomingMaintenance,
|
||||
PendingQuotes: pendingQuotes,
|
||||
OpenInvoices: openInvoices,
|
||||
InvoicedThisMonth: invoicedThisMonth,
|
||||
CollectedThisMonth: collectedThisMonth,
|
||||
RecentPayments: recentPayments,
|
||||
RecentQuotes: recentQuotes,
|
||||
RecentJobs: recentJobs,
|
||||
JobsNeedingPowder: jobsNeedingPowder,
|
||||
JobsWithOrderedPowder: jobsWithOrderedPowder,
|
||||
BillsDue: billsDue,
|
||||
TipOfTheDay: tipOfTheDay
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> GetTotalUserCountAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.Where(u => u.CompanyId > 0)
|
||||
.CountAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user