using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Services; /// /// Implements operational aggregate reports using direct DbContext access with AsNoTracking. /// Query logic is migrated here from ReportsController as each report action is /// converted during Phase 2/3 of the data-access architecture migration. /// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan. /// public class OperationalReportService : IOperationalReportService { private readonly ApplicationDbContext _context; public OperationalReportService(ApplicationDbContext context) { _context = context; } /// public async Task 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 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()); 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()); 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); } /// public async Task 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); } /// public async Task> 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(); } /// public async Task> GetAllExpensesAsync() { return await _context.Expenses .Include(e => e.ExpenseAccount) .Where(e => !e.IsDeleted) .AsNoTracking() .ToListAsync(); } /// public async Task> GetAllJobStatusHistoryAsync() { return await _context.JobStatusHistory .Include(h => h.FromStatus) .Include(h => h.ToStatus) .AsNoTracking() .ToListAsync(); } }