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();
}
}