Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs
T
spouliot 1cb7a8ca4a 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>
2026-04-28 09:17:29 -04:00

146 lines
5.3 KiB
C#

using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements operational aggregate reports using direct DbContext access with AsNoTracking.
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
/// converted during Phase 2/3 of the data-access architecture migration.
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class OperationalReportService : IOperationalReportService
{
private readonly ApplicationDbContext _context;
public OperationalReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months)
{
var completedCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var completedJobs = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && completedCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue)
.AsNoTracking()
.ToListAsync();
var history = await _context.JobStatusHistory
.Include(h => h.FromStatus)
.Include(h => h.ToStatus)
.AsNoTracking()
.ToListAsync();
var historyByJob = history
.GroupBy(h => h.JobId)
.ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
foreach (var job in completedJobs)
{
if (!historyByJob.TryGetValue(job.Id, out var jobHistory) || !jobHistory.Any()) continue;
var prevDate = job.CreatedAt;
foreach (var entry in jobHistory)
{
var fc = entry.FromStatus?.StatusCode;
var fn = entry.FromStatus?.DisplayName;
if (fc == null) { prevDate = entry.ChangedDate; continue; }
var d = (entry.ChangedDate - prevDate).TotalDays;
if (d >= 0 && d <= 365)
{
if (!statusTimings.ContainsKey(fc)) statusTimings[fc] = (fn ?? fc, new List<double>());
statusTimings[fc].Days.Add(d);
}
prevDate = entry.ChangedDate;
}
var last = jobHistory.Last();
var tc = last.ToStatus?.StatusCode;
var tn = last.ToStatus?.DisplayName;
if (tc != null)
{
var d = (job.CompletedDate!.Value - last.ChangedDate).TotalDays;
if (d >= 0 && d <= 365)
{
if (!statusTimings.ContainsKey(tc)) statusTimings[tc] = (tn ?? tc, new List<double>());
statusTimings[tc].Days.Add(d);
}
}
}
var rows = statusTimings
.Where(kv => kv.Value.Days.Any())
.Select(kv => new JobCycleTimeRow(kv.Value.DisplayName, Math.Round(kv.Value.Days.Average(), 1), kv.Value.Days.Count))
.ToList();
return new JobCycleTimeReport(rows, months);
}
/// <inheritdoc/>
public async Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months)
{
var startDate = DateTime.UtcNow.AddMonths(-months);
var transactions = await _context.InventoryTransactions
.Include(t => t.InventoryItem)
.Where(t => !t.IsDeleted
&& t.TransactionType == InventoryTransactionType.JobUsage
&& t.TransactionDate >= startDate)
.AsNoTracking()
.ToListAsync();
var rows = transactions
.Where(t => t.InventoryItem != null)
.GroupBy(t => t.InventoryItemId)
.Select(g => new PowderUsageRow(
ColorName: g.First().InventoryItem!.ColorName ?? g.First().InventoryItem!.Name,
VendorName: g.First().InventoryItem!.Manufacturer ?? string.Empty,
TotalLbs: g.Sum(t => Math.Abs(t.Quantity)),
TotalCost: g.Sum(t => Math.Abs(t.TotalCost))))
.OrderByDescending(r => r.TotalLbs)
.ToList();
return new PowderUsageReport(rows, months);
}
/// <inheritdoc/>
public async Task<List<Bill>> GetActiveBillsAsync()
{
return await _context.Bills
.Include(b => b.Vendor)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<Expense>> GetAllExpensesAsync()
{
return await _context.Expenses
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync()
{
return await _context.JobStatusHistory
.Include(h => h.FromStatus)
.Include(h => h.ToStatus)
.AsNoTracking()
.ToListAsync();
}
}