1cb7a8ca4a
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>
146 lines
5.3 KiB
C#
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();
|
|
}
|
|
}
|