using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Shared.Constants; using PowderCoating.Web.ViewModels.Reports; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanViewReports)] public class ReportsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IFinancialReportService _financialReports; private readonly IOperationalReportService _operationalReports; private readonly IPdfService _pdfService; private readonly UserManager _userManager; private readonly IAccountingAiService _accountingAi; private readonly IAiUsageLogger _usageLogger; public ReportsController(IUnitOfWork unitOfWork, ILogger logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) { _unitOfWork = unitOfWork; _logger = logger; _financialReports = financialReports; _operationalReports = operationalReports; _pdfService = pdfService; _userManager = userManager; _accountingAi = accountingAi; _usageLogger = usageLogger; } /// /// The report catalog / landing page. All report links live here rather than in the nav so /// accounting-gated reports can be shown or hidden without duplicating the policy check on /// every individual action. /// public IActionResult Landing() => View(); /// /// Guard helper checked at the start of every accounting-sensitive action (P&L, Balance Sheet, /// AR Aging, etc.). The AllowAccounting flag is set by a middleware or filter reading the /// company's subscription plan — non-accounting plans return false and those actions redirect to /// the landing page instead of showing financial data. Reads from HttpContext.Items so the value /// is computed once per request and cached for the lifetime of the HTTP pipeline. /// private bool AllowAccounting() => HttpContext.Items["AllowAccounting"] as bool? ?? false; // Catalog is the new index — redirect legacy bookmarks public IActionResult Index() => RedirectToAction(nameof(Landing)); /// /// Legacy full-analytics dashboard — the original single-page report before individual report /// pages were introduced. Kept for reference during transition; the individual report pages /// (KpiDashboard, RevenueTrends, etc.) are the current canonical destinations. /// Loads multiple entity collections in-memory because they are needed for cross-cutting /// aggregations (e.g. revenue by priority requires both job AND priority lookup tables). /// Status codes are compared as string constants ("COMPLETED", "IN_PREPARATION", etc.) rather /// than enum values because JobStatus is now a lookup table entity, not an enum. /// public async Task Analytics(int months = 6) { try { var now = DateTime.UtcNow; var today = DateTime.Today; var startOfMonth = new DateTime(now.Year, now.Month, 1); var startOfLastMonth = startOfMonth.AddMonths(-1); var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; // Load only necessary data - optimized with filtering and minimal eager loading // Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution) // Note: Date filtering would exclude data needed for jobsByStatus calculation var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList(); // Quotes: Load all quotes (needed for quote status distribution and conversion funnel) var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList(); // Customers: Load all (needed for active count and customer creation trend across all months) var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); // Equipment: Load all for status distribution var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); // Inventory: Load all for low stock analysis var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); // Appointments: Filter to relevant date range at DB level var appointments = (await _unitOfWork.Appointments.FindAsync( a => a.ScheduledStartTime >= startDate, false, a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus)).ToList(); // Users with assigned jobs/appointments will be loaded below when building worker stats // CatalogItems: Load all for category distribution var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList(); // === OVERVIEW METRICS === var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var totalRevenue = completedJobs.Sum(j => j.FinalPrice); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var completedThisMonth = jobs.Count(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfMonth); var completedLastMonth = jobs.Count(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfLastMonth && j.UpdatedAt < startOfMonth); var approvedQuotes = quotes.Where(q => q.QuoteStatus.StatusCode == "APPROVED" || q.QuoteStatus.StatusCode == "CONVERTED").ToList(); var winRate = quotes.Any() ? Math.Round((decimal)approvedQuotes.Count / quotes.Count * 100, 1) : 0m; var avgJobValue = completedJobs.Any() ? totalRevenue / completedJobs.Count : 0m; var appointmentsThisMonth = appointments.Count(a => a.ScheduledStartTime >= startOfMonth); // Calculate average job duration (days from created to completed) var completedJobsWithDates = completedJobs.Where(j => j.CompletedDate.HasValue).ToList(); var avgJobDuration = completedJobsWithDates.Any() ? completedJobsWithDates.Average(j => (j.CompletedDate.GetValueOrDefault() - j.CreatedAt).TotalDays) : 0; // Month-over-month growth var revenueThisMonth = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfMonth).Sum(j => j.FinalPrice); var revenueLastMonth = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfLastMonth && j.UpdatedAt < startOfMonth).Sum(j => j.FinalPrice); var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m; // === REVENUE ANALYTICS === // Pre-filter completed jobs by date range once for monthly calculations var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList(); // Group by month for efficient monthly aggregations var jobsByMonth = completedJobsInRange .GroupBy(j => new DateTime(j.UpdatedAt.Value.Year, j.UpdatedAt.Value.Month, 1)) .ToDictionary(g => g.Key, g => g.ToList()); // Monthly revenue trend var monthLabels = new List(); var monthlyRevenue = new List(); var monthlyJobCount = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var monthStart = new DateTime(month.Year, month.Month, 1); monthLabels.Add(month.ToString("MMM yyyy")); if (jobsByMonth.TryGetValue(monthStart, out var monthJobs)) { monthlyRevenue.Add(monthJobs.Sum(j => j.FinalPrice)); monthlyJobCount.Add(monthJobs.Count); } else { monthlyRevenue.Add(0); monthlyJobCount.Add(0); } } // Revenue by customer type var revenueByCustomerType = completedJobs .Where(j => j.Customer != null) .GroupBy(j => j.Customer!.IsCommercial ? "Commercial" : "Individual") .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice)); // Revenue by priority — build order lookup once to avoid O(n²) per group var priorityOrder = completedJobs .GroupBy(j => j.JobPriority.DisplayName) .ToDictionary(g => g.Key, g => g.First().JobPriority.DisplayOrder); var revenueByPriority = completedJobs .GroupBy(j => j.JobPriority.DisplayName) .OrderBy(g => priorityOrder.TryGetValue(g.Key, out var o) ? o : 999) .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice)); // Average order value trend - reuse jobsByMonth var avgOrderValueTrend = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var monthStart = new DateTime(month.Year, month.Month, 1); if (jobsByMonth.TryGetValue(monthStart, out var monthJobs) && monthJobs.Any()) { avgOrderValueTrend.Add(monthJobs.Average(j => j.FinalPrice)); } else { avgOrderValueTrend.Add(0); } } // Top customers by revenue var topCustomers = completedJobs .Where(j => j.Customer != null) .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() }) .OrderByDescending(x => x.Revenue) .Take(10) .ToList(); // === OPERATIONS ANALYTICS === // Job status distribution — build order lookups once to avoid O(n²) per group var statusOrder = jobs .GroupBy(j => j.JobStatus.DisplayName) .ToDictionary(g => g.Key, g => g.First().JobStatus.DisplayOrder); var jobsByStatus = jobs .GroupBy(j => j.JobStatus.DisplayName) .OrderBy(g => statusOrder.TryGetValue(g.Key, out var o) ? o : 999) .ToDictionary(g => g.Key, g => g.Count()); // Active jobs by priority — same pattern var activePriorityOrder = activeJobs .GroupBy(j => j.JobPriority.DisplayName) .ToDictionary(g => g.Key, g => g.First().JobPriority.DisplayOrder); var jobsByPriority = activeJobs .GroupBy(j => j.JobPriority.DisplayName) .OrderBy(g => activePriorityOrder.TryGetValue(g.Key, out var o) ? o : 999) .ToDictionary(g => g.Key, g => g.Count()); // Appointment analytics var appointmentsByType = appointments .Where(a => a.AppointmentType != null) .GroupBy(a => a.AppointmentType!.DisplayName) .ToDictionary(g => g.Key, g => g.Count()); var appointmentsByStatus = appointments .Where(a => a.AppointmentStatus != null) .GroupBy(a => a.AppointmentStatus!.DisplayName) .ToDictionary(g => g.Key, g => g.Count()); var completedAppointments = appointments.Count(a => a.AppointmentStatus?.StatusCode == "COMPLETED"); var appointmentCompletionRate = appointments.Any() ? Math.Round((decimal)completedAppointments / appointments.Count * 100, 1) : 0m; // Appointments by day of week var appointmentsByDayOfWeek = appointments .GroupBy(a => a.ScheduledStartTime.DayOfWeek) .OrderBy(g => (int)g.Key) .ToDictionary(g => g.Key.ToString(), g => g.Count()); // Worker performance var assignedUserIds = jobs.Where(j => j.AssignedUserId != null).Select(j => j.AssignedUserId!) .Concat(appointments.Where(a => a.AssignedUserId != null).Select(a => a.AssignedUserId!)) .Distinct().ToList(); var assignedUsers = assignedUserIds.Any() ? await _userManager.Users.Where(u => assignedUserIds.Contains(u.Id)).ToListAsync() : new List(); var workerStats = assignedUsers.Select(u => new WorkerStatsItem { Name = u.FullName, Role = u.Position ?? u.CompanyRole ?? "User", JobsAssigned = jobs.Count(j => j.AssignedUserId == u.Id), JobsCompleted = jobs.Count(j => j.AssignedUserId == u.Id && completedStatusCodes.Contains(j.JobStatus.StatusCode)), AppointmentsAssigned = appointments.Count(a => a.AssignedUserId == u.Id) }) .Where(w => w.JobsAssigned > 0 || w.AppointmentsAssigned > 0) .OrderByDescending(w => w.JobsCompleted) .Take(10) .ToList(); // Equipment health var equipmentByStatus = equipment .GroupBy(e => e.Status) .ToDictionary(g => FormatEnum(g.Key.ToString()), g => g.Count()); // Low stock items var lowStock = inventory .Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint) .OrderBy(i => i.QuantityOnHand) .Take(10) .Select(i => new LowStockItem { Id = i.Id, Name = i.Name, ColorName = i.ColorName, QuantityOnHand = i.QuantityOnHand, ReorderPoint = i.ReorderPoint, UnitOfMeasure = i.UnitOfMeasure }) .ToList(); // === CUSTOMER ANALYTICS === // Pre-filter customers by date range once for monthly calculations var customersByMonth = customers .Where(c => c.CreatedAt >= startDate) .GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)) .ToDictionary(g => g.Key, g => g.Count()); // New customers per month var newCustomersPerMonth = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var monthStart = new DateTime(month.Year, month.Month, 1); newCustomersPerMonth.Add(customersByMonth.TryGetValue(monthStart, out var count) ? count : 0); } // Customer retention (customers with multiple jobs) var customersWithMultipleJobs = completedJobs .Where(j => j.Customer != null) .GroupBy(j => j.Customer!.Id) .Count(g => g.Count() > 1); var totalCustomersWithJobs = completedJobs .Where(j => j.Customer != null) .Select(j => j.Customer!.Id) .Distinct() .Count(); var customerRetentionRate = totalCustomersWithJobs > 0 ? Math.Round((decimal)customersWithMultipleJobs / totalCustomersWithJobs * 100, 1) : 0m; // Customer lifetime value (top 10) var customerLifetimeValue = completedJobs .Where(j => j.Customer != null) .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .Select(g => new CustomerLifetimeValueItem { CustomerName = g.Key.CompanyName, TotalRevenue = g.Sum(j => j.FinalPrice), JobCount = g.Count(), AvgOrderValue = g.Average(j => j.FinalPrice), FirstJobDate = g.Min(j => j.CreatedAt), LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) }) .OrderByDescending(c => c.TotalRevenue) .Take(10) .ToList(); // Quote conversion funnel — build order lookup once to avoid O(n²) per group var quoteStatusOrder = quotes .GroupBy(q => q.QuoteStatus.DisplayName) .ToDictionary(g => g.Key, g => g.First().QuoteStatus.DisplayOrder); var quotesByStatus = quotes .GroupBy(q => q.QuoteStatus.DisplayName) .OrderBy(g => quoteStatusOrder.TryGetValue(g.Key, out var o) ? o : 999) .ToDictionary(g => g.Key, g => g.Count()); var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") }; // Most popular catalog items (from jobs - would need JobItem relationship) // For now, just show catalog stats var catalogByCategory = catalogItems .Where(c => c.Category != null) .GroupBy(c => c.Category!.Name) .OrderByDescending(g => g.Count()) .Take(8) .ToDictionary(g => g.Key, g => g.Count()); // === FINANCIAL ANALYTICS === var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var totalInvoiced = activeInvoices.Sum(i => i.Total); var totalCollected = activeInvoices.Sum(i => i.AmountPaid); var totalOutstanding = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid).Sum(i => i.BalanceDue); var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList(); var totalOverdue = overdueInvoices.Sum(i => i.BalanceDue); var invoicesOverdueCount = overdueInvoices.Count; var invoicesDraftCount = allInvoices.Count(i => i.Status == InvoiceStatus.Draft); var invoicesPaidCount = allInvoices.Count(i => i.Status == InvoiceStatus.Paid); // Monthly invoiced vs collected (same month labels as existing revenue chart) var invoicesByMonth = activeInvoices .GroupBy(i => new DateTime(i.InvoiceDate.Year, i.InvoiceDate.Month, 1)) .ToDictionary(g => g.Key, g => g.ToList()); var paymentsByMonth = activeInvoices .SelectMany(i => i.Payments.Where(p => !p.IsDeleted)) .GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)) .ToDictionary(g => g.Key, g => g.ToList()); var monthlyInvoiced = new List(); var monthlyCollected = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var monthStart = new DateTime(month.Year, month.Month, 1); monthlyInvoiced.Add(invoicesByMonth.TryGetValue(monthStart, out var mInv) ? mInv.Sum(x => x.Total) : 0m); monthlyCollected.Add(paymentsByMonth.TryGetValue(monthStart, out var mPay) ? mPay.Sum(x => x.Amount) : 0m); } // AR Aging buckets (outstanding invoices only) var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList(); var agingBuckets = new List { new AgingBucketItem { Label = "Current (0–30 days)", Amount = 0, Count = 0 }, new AgingBucketItem { Label = "31–60 days", Amount = 0, Count = 0 }, new AgingBucketItem { Label = "61–90 days", Amount = 0, Count = 0 }, new AgingBucketItem { Label = "Over 90 days", Amount = 0, Count = 0 } }; foreach (var inv in outstandingInvoices) { var daysOverdue = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var bucket = daysOverdue <= 30 ? 0 : daysOverdue <= 60 ? 1 : daysOverdue <= 90 ? 2 : 3; agingBuckets[bucket].Amount += balance; agingBuckets[bucket].Count++; } // Recent payments (last 10, with invoice number + customer name) var recentPayments = activeInvoices .SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })) .OrderByDescending(p => p.PaymentDate) .Take(10) .ToList(); // Top outstanding customers var topOutstandingCustomers = outstandingInvoices .Where(i => i.Customer != null) .GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }) .Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }) .OrderByDescending(x => x.OutstandingBalance) .Take(5) .ToList(); // Average days to payment var paidInvoicesWithDates = allInvoices .Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue) .ToList(); var avgDaysToPayment = paidInvoicesWithDates.Any() ? Math.Round(paidInvoicesWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays), 1) : 0.0; // === SALES BY CUSTOMER === var salesByCustomer = activeInvoices .Where(i => i.Customer != null) .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial }) .Select(g => new SalesByCustomerItem { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name ?? "Unknown", IsCommercial = g.Key.IsCommercial, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), LastInvoiceDate = g.Max(i => (DateTime?)i.InvoiceDate) }) .OrderByDescending(x => x.TotalInvoiced) .ToList(); // === EXPENSE / AP ANALYTICS === var allBills = await _operationalReports.GetActiveBillsAsync(); var totalBilled = allBills.Sum(b => b.Total); var totalBillsPaid = allBills.Sum(b => b.AmountPaid); var totalApOutstanding = allBills.Sum(b => b.BalanceDue); // AP Aging var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList(); var apAgingBuckets = new List { new() { Label = "Current (0–30 days)", Amount = 0, Count = 0 }, new() { Label = "31–60 days", Amount = 0, Count = 0 }, new() { Label = "61–90 days", Amount = 0, Count = 0 }, new() { Label = "Over 90 days", Amount = 0, Count = 0 } }; foreach (var bill in outstandingBills) { var daysOverdue = bill.DueDate.HasValue ? (int)(today - bill.DueDate.Value).TotalDays : 0; var bucket = daysOverdue <= 30 ? 0 : daysOverdue <= 60 ? 1 : daysOverdue <= 90 ? 2 : 3; apAgingBuckets[bucket].Amount += bill.BalanceDue; apAgingBuckets[bucket].Count++; } // Vendor spend (top vendors) var vendorSpend = allBills .Where(b => b.Vendor != null) .GroupBy(b => new { b.VendorId, Name = b.Vendor!.CompanyName }) .Select(g => new VendorSpendItem { VendorName = g.Key.Name, TotalBilled = g.Sum(b => b.Total), TotalPaid = g.Sum(b => b.AmountPaid), BillCount = g.Count() }) .OrderByDescending(v => v.TotalBilled) .Take(8) .ToList(); // Expenses by account var allExpenses = await _operationalReports.GetAllExpensesAsync(); var expensesByAccount = allExpenses .Where(e => e.ExpenseAccount != null) .GroupBy(e => new { e.ExpenseAccountId, Name = $"{e.ExpenseAccount!.AccountNumber} – {e.ExpenseAccount.Name}" }) .Select(g => new ExpenseByAccountItem { AccountName = g.Key.Name, Amount = g.Sum(e => e.Amount), Count = g.Count() }) .OrderByDescending(x => x.Amount) .ToList(); var totalDirectExpenses = allExpenses.Sum(e => e.Amount); // Monthly expenses (bills paid + direct expenses) for P&L var monthlyBillsPaid = new List(); var monthlyDirectExpenses = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var monthStart = new DateTime(month.Year, month.Month, 1); var monthEnd = monthStart.AddMonths(1); var billsThisMonth = allBills .SelectMany(b => b.Payments) .Where(p => p.PaymentDate >= monthStart && p.PaymentDate < monthEnd) .Sum(p => p.Amount); var expensesThisMonth = allExpenses .Where(e => e.Date >= monthStart && e.Date < monthEnd) .Sum(e => e.Amount); monthlyBillsPaid.Add(billsThisMonth); monthlyDirectExpenses.Add(expensesThisMonth); } // P&L: Revenue (collected) vs Total Expenses (bills paid + direct) var plRevenue = monthlyCollected; var plExpenses = monthlyBillsPaid.Zip(monthlyDirectExpenses, (a, b) => a + b).ToList(); var plNetIncome = plRevenue.Zip(plExpenses, (r, e) => r - e).ToList(); // === POWDER USAGE ANALYTICS === var powderTransactions = (await _unitOfWork.InventoryTransactions .FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem)) .ToList(); var totalPowderUsedLbs = powderTransactions.Sum(t => Math.Abs(t.Quantity)); var totalPowderCost = powderTransactions.Sum(t => Math.Abs(t.TotalCost)); var totalJobsWithPowderUsage = powderTransactions .Where(t => !string.IsNullOrEmpty(t.Reference)) .Select(t => t.Reference) .Distinct() .Count(); var topColorsByUsage = powderTransactions .Where(t => t.InventoryItem != null) .GroupBy(t => t.InventoryItemId) .Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)) .Select(t => t.Reference).Distinct().Count() }) .OrderByDescending(x => x.TotalLbsUsed) .Take(15) .ToList(); var monthlyPowderUsageLbs = new List(); var monthlyPowderUsageCost = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var monthStart = new DateTime(month.Year, month.Month, 1); var monthEnd = monthStart.AddMonths(1); var monthTx = powderTransactions.Where(t => t.TransactionDate >= monthStart && t.TransactionDate < monthEnd).ToList(); monthlyPowderUsageLbs.Add(monthTx.Sum(t => Math.Abs(t.Quantity))); monthlyPowderUsageCost.Add(monthTx.Sum(t => Math.Abs(t.TotalCost))); } // === CUSTOMER RETENTION === var customerRetention = customers .Where(c => c.IsActive) .Select(c => { var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList(); var lastJob = cJobs.Any() ? cJobs.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) : (DateTime?)null; var daysSince = lastJob.HasValue ? (int)(now - lastJob.Value).TotalDays : -1; var status = cJobs.Count == 0 ? "Never Ordered" : daysSince <= 30 ? "Active" : daysSince <= 60 ? "At Risk" : daysSince <= 90 ? "Lapsing" : "Churned"; return new CustomerRetentionItem { CustomerId = c.Id, CustomerName = c.IsCommercial ? c.CompanyName ?? "Unknown" : $"{c.ContactFirstName} {c.ContactLastName}".Trim(), Email = c.Email, Phone = c.Phone ?? c.MobilePhone, TotalJobs = cJobs.Count, LifetimeRevenue = cJobs.Sum(j => j.FinalPrice), LastJobDate = lastJob, DaysSinceLastJob = daysSince, RetentionStatus = status }; }) .OrderBy(c => c.RetentionStatus == "Churned" ? 0 : c.RetentionStatus == "Lapsing" ? 1 : c.RetentionStatus == "At Risk" ? 2 : c.RetentionStatus == "Active" ? 3 : 4) .ThenByDescending(c => c.LifetimeRevenue) .ToList(); // === JOB CYCLE TIME === var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync(); var historyByJob = allStatusHistory .GroupBy(h => h.JobId) .ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" }; var statusTimings = new Dictionary Days)>(); foreach (var job in completedJobs.Where(j => j.CompletedDate.HasValue)) { if (!historyByJob.TryGetValue(job.Id, out var history) || !history.Any()) continue; var prevDate = job.CreatedAt; foreach (var entry in history) { var fromCode = entry.FromStatus?.StatusCode; var fromName = entry.FromStatus?.DisplayName; if (fromCode == null) { prevDate = entry.ChangedDate; continue; } var days = (entry.ChangedDate - prevDate).TotalDays; if (days >= 0 && days <= 365) { if (!statusTimings.ContainsKey(fromCode)) statusTimings[fromCode] = (fromName ?? fromCode, new List()); statusTimings[fromCode].Days.Add(days); } prevDate = entry.ChangedDate; } // Time in final status before completion var last = history.Last(); var toCode = last.ToStatus?.StatusCode; var toName = last.ToStatus?.DisplayName; if (toCode != null) { var days = (job.CompletedDate!.Value - last.ChangedDate).TotalDays; if (days >= 0 && days <= 365) { if (!statusTimings.ContainsKey(toCode)) statusTimings[toCode] = (toName ?? toCode, new List()); statusTimings[toCode].Days.Add(days); } } } var jobCycleTime = statusTimings .Where(kv => kv.Value.Days.Any()) .Select(kv => new JobCycleTimeItem { StatusCode = kv.Key, StatusName = kv.Value.DisplayName, DisplayOrder = Array.IndexOf(statusDisplayOrder, kv.Key), AvgDays = Math.Round(kv.Value.Days.Average(), 1), MinDays = Math.Round(kv.Value.Days.Min(), 1), MaxDays = Math.Round(kv.Value.Days.Max(), 1), JobCount = kv.Value.Days.Count }) .OrderBy(x => x.DisplayOrder < 0 ? 99 : x.DisplayOrder) .ToList(); var overallAvgCycleDays = completedJobs .Where(j => j.CompletedDate.HasValue) .Select(j => (j.CompletedDate!.Value - j.CreatedAt).TotalDays) .Where(d => d >= 0 && d <= 365) .DefaultIfEmpty(0) .Average(); // === JOB STATUS AGING === var jobStatusAging = activeJobs .Select(j => new JobStatusAgingItem { JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(), StatusName = j.JobStatus.DisplayName, StatusCode = j.JobStatus.StatusCode, PriorityName = j.JobPriority?.DisplayName ?? "Normal", PriorityCode = j.JobPriority?.PriorityCode ?? "NORMAL", DaysInCurrentStatus = (int)(now - (j.UpdatedAt ?? j.CreatedAt)).TotalDays, DueDate = j.DueDate, IsOverdue = j.DueDate.HasValue && j.DueDate.Value < today }) .OrderByDescending(x => x.DaysInCurrentStatus) .ToList(); // === INVOICE AGING DETAIL === var invoiceAgingDetail = allInvoices .Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid) .Select(i => { var daysOverdue = i.DueDate.HasValue ? (int)(today - i.DueDate.Value.Date).TotalDays : 0; return new InvoiceAgingDetailItem { InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName ?? "Unknown" : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), CustomerEmail = i.Customer.Email, CustomerPhone = i.Customer.Phone ?? i.Customer.MobilePhone, InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue, DaysOverdue = Math.Max(0, daysOverdue), AgingBucket = daysOverdue <= 0 ? "Current" : daysOverdue <= 30 ? "1–30 Days" : daysOverdue <= 60 ? "31–60 Days" : daysOverdue <= 90 ? "61–90 Days" : "90+ Days", StatusDisplay = i.Status.ToString() }; }) .OrderByDescending(i => i.DaysOverdue) .ToList(); // === POWDER CONSUMPTION VS PURCHASE === var allInventoryTransactions = (await _unitOfWork.InventoryTransactions .GetAllAsync(false, t => t.InventoryItem)) .ToList(); var powderConsumptionItems = allInventoryTransactions .Where(t => t.InventoryItem != null) .GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer }) .Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial) .Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste) .Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)) .Select(t => t.Reference).Distinct().Count() }) .Where(x => x.TotalPurchasedLbs > 0 || x.TotalConsumedLbs > 0) .OrderByDescending(x => x.TotalConsumedLbs) .ToList(); // === INVENTORY TURNOVER === var daysInPeriod = months * 30.0; var inventoryTurnover = inventory .Where(i => i.IsActive) .Select(i => { var iTx = allInventoryTransactions.Where(t => t.InventoryItemId == i.Id).ToList(); var consumed = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste) .Sum(t => Math.Abs(t.Quantity)); var purchased = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.Purchase) .Sum(t => t.Quantity); var dailyUse = daysInPeriod > 0 ? consumed / daysInPeriod : 0; var dsOut = dailyUse > 0 ? (double)i.QuantityOnHand / dailyUse : 9999; return new InventoryTurnoverItem { InventoryItemId = i.Id, ItemName = i.Name, SKU = i.SKU, ColorName = i.ColorName, CurrentStockLbs = i.QuantityOnHand, TotalConsumedLbs = (decimal)consumed, TotalPurchasedLbs = (decimal)purchased, DailyConsumptionLbs = Math.Round((decimal)dailyUse, 3), DaysToStockout = dsOut >= 9999 ? 9999 : Math.Round(dsOut, 0), TurnoverRate = consumed > 0 && (double)i.QuantityOnHand > 0 ? Math.Round(consumed / (double)i.QuantityOnHand, 2) : 0, StockStatus = dsOut <= 7 ? "Critical" : dsOut <= 30 ? "Low" : dsOut <= 90 ? "Normal" : dsOut < 9999 ? "Overstocked" : "No Usage" }; }) .Where(x => x.TotalConsumedLbs > 0 || x.CurrentStockLbs > 0) .OrderBy(x => x.DaysToStockout) .ToList(); var vm = new AnalyticsDashboardViewModel { // Overview KPIs TotalRevenue = totalRevenue, ActiveJobsCount = activeJobs.Count, ActiveCustomersCount = customers.Count(c => c.IsActive), QuoteWinRate = winRate, TotalQuotes = quotes.Count, CompletedJobsThisMonth = completedThisMonth, AverageJobValue = avgJobValue, AppointmentsThisMonth = appointmentsThisMonth, AverageJobDuration = avgJobDuration, MonthOverMonthGrowth = momGrowth, // Revenue MonthLabels = monthLabels, MonthlyRevenue = monthlyRevenue, MonthlyJobCount = monthlyJobCount, RevenueByCustomerType = revenueByCustomerType, RevenueByPriority = revenueByPriority, AverageOrderValueTrend = avgOrderValueTrend, TopCustomers = topCustomers, // Operations JobsByStatus = jobsByStatus, ActiveJobsByPriority = jobsByPriority, AppointmentsByType = appointmentsByType, AppointmentsByStatus = appointmentsByStatus, AppointmentCompletionRate = appointmentCompletionRate, AppointmentsByDayOfWeek = appointmentsByDayOfWeek, WorkerStats = workerStats, EquipmentByStatus = equipmentByStatus, LowStockItems = lowStock, // Customers NewCustomersPerMonth = newCustomersPerMonth, CustomerRetentionRate = customerRetentionRate, CustomerLifetimeValue = customerLifetimeValue, QuotesByStatus = quotesByStatus, QuoteFunnel = quoteFunnel, CatalogByCategory = catalogByCategory, // Financial TotalInvoiced = totalInvoiced, TotalCollected = totalCollected, TotalOutstanding = totalOutstanding, TotalOverdue = totalOverdue, InvoicesOverdueCount = invoicesOverdueCount, InvoicesDraftCount = invoicesDraftCount, InvoicesPaidCount = invoicesPaidCount, MonthlyInvoiced = monthlyInvoiced, MonthlyCollected = monthlyCollected, AgingBuckets = agingBuckets, RecentPayments = recentPayments, TopOutstandingCustomers = topOutstandingCustomers, AvgDaysToPayment = avgDaysToPayment, // Expense / AP TotalBilled = totalBilled, TotalBillsPaid = totalBillsPaid, TotalApOutstanding = totalApOutstanding, TotalDirectExpenses = totalDirectExpenses, ApAgingBuckets = apAgingBuckets, VendorSpend = vendorSpend, ExpensesByAccount = expensesByAccount, MonthlyBillsPaid = monthlyBillsPaid, MonthlyDirectExpenses = monthlyDirectExpenses, PlRevenue = plRevenue, PlExpenses = plExpenses, PlNetIncome = plNetIncome, // Powder Usage TotalPowderUsedLbs = totalPowderUsedLbs, TotalPowderCost = totalPowderCost, TotalJobsWithPowderUsage = totalJobsWithPowderUsage, ColorsUsedCount = topColorsByUsage.Count, TopColorsByUsage = topColorsByUsage, MonthlyPowderUsageLbs = monthlyPowderUsageLbs, MonthlyPowderUsageCost = monthlyPowderUsageCost, // Sales by Customer SalesByCustomer = salesByCustomer, SalesByCustomerTotalInvoiced = salesByCustomer.Sum(x => x.TotalInvoiced), SalesByCustomerTotalPaid = salesByCustomer.Sum(x => x.TotalPaid), // Customer Retention CustomerRetention = customerRetention, // Job Cycle Time JobCycleTime = jobCycleTime, OverallAvgCycleDays = overallAvgCycleDays, // Job Status Aging JobStatusAging = jobStatusAging, // Invoice Aging Detail InvoiceAgingDetail = invoiceAgingDetail, // Powder Consumption vs Purchase PowderConsumption = powderConsumptionItems, // Inventory Turnover InventoryTurnover = inventoryTurnover, // Filters SelectedMonths = months }; return View("Index", vm); } catch (Exception ex) { _logger.LogError(ex, "Error loading analytics"); TempData["Error"] = "An error occurred while loading analytics."; return View("Index", new AnalyticsDashboardViewModel()); } } /// Converts PascalCase enum names to spaced display strings (e.g. "NeedsMaintenance" → "Needs Maintenance"). private static string FormatEnum(string value) => System.Text.RegularExpressions.Regex.Replace(value, "([A-Z])", " $1").Trim(); // ── Financial Reports ───────────────────────────────────────────────────── /// /// Profit & Loss report for a date range. Revenue is summed from InvoiceItems posted to /// revenue-type accounts (excludes Draft and Voided invoices). Expenses come from two sources: /// direct Expenses (one-off spend) and BillLineItems (vendor purchase order costs) — both are /// merged by account before building the report lines so each account appears once. /// Items without a revenue account fall into an "Other Sales (unclassified)" bucket rather than /// being silently dropped, so the total always reconciles to total invoice revenue. /// Gated behind ; non-accounting plans redirect to Landing. /// // GET: /Reports/ProfitAndLoss public async Task ProfitAndLoss(DateTime? from, DateTime? to) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; var toDate = (to ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate); return View(dto); } /// /// Balance Sheet as of a point-in-time date. All transaction amounts are pre-computed per /// account via batch GROUP BY queries (not per-row loops) to avoid N+1 queries across potentially /// large transaction tables. The local ComputeBalance function applies standard /// double-entry rules: assets have a normal debit balance; liabilities and equity have a normal /// credit balance. AR and AP accounts are handled specially because their balances come from /// Invoice/Bill totals rather than generic deposit/expense entries. Retained earnings is computed /// as lifetime revenue − COGS − bills from inception through asOf — there is no explicit /// retained-earnings transaction table; it is always derived. /// Gated behind . /// // GET: /Reports/BalanceSheet public async Task BalanceSheet(DateTime? asOf) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var asOfDate = (asOf ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate); return View(dto); } /// /// AR Aging report as of a point-in-time date. Shows open invoice balances bucketed into /// Current / 1–30 / 31–60 / 61–90 / 90+ day ranges based on days past the invoice DueDate. /// Invoices without a DueDate are treated as current (0 days overdue) to avoid erroneous /// classification. Results are grouped by customer and sorted by total outstanding balance /// descending so the biggest exposures appear at the top. /// Gated behind . /// // GET: /Reports/ArAging public async Task ArAging(DateTime? asOf) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var asOfDate = (asOf ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate); return View(dto); } /// /// Sales & Income report for a date range. Distinguishes between invoiced amounts (invoice /// date within range) and collected amounts (payment date within range) — these can differ /// when a customer pays in a different month than the invoice was issued. The "collectedInPeriod" /// total uses payment dates so cash-basis bookkeeping is possible from this report even though /// the invoice list uses accrual-basis invoice dates. /// Not gated by — all roles with CanViewReports can see sales totals. /// // GET: /Reports/SalesAndIncome public async Task SalesAndIncome(DateTime? from, DateTime? to) { var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; var toDate = (to ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate); return View(dto); } // ── PDF Download Actions ────────────────────────────────────────────────── /// /// PDF export of the Profit & Loss report. Duplicates the data-loading logic from /// because the two actions need identical data but different /// return types (View vs File). The flag controls Content-Disposition: /// true = inline (browser renders in tab), false = attachment (browser prompts download). /// The inline=true path is used when embedding the PDF in an iframe within the view. /// Gated behind . /// // GET: /Reports/ProfitAndLossPdf public async Task ProfitAndLossPdf(DateTime? from, DateTime? to, bool inline = false) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; var toDate = (to ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate); var pdfBytes = await _pdfService.GenerateProfitAndLossPdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"ProfitAndLoss-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf"); } /// /// PDF export of the Balance Sheet. Same inline/attachment pattern as /// . Gated behind . /// // GET: /Reports/BalanceSheetPdf public async Task BalanceSheetPdf(DateTime? asOf, bool inline = false) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var asOfDate = (asOf ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate); var pdfBytes = await _pdfService.GenerateBalanceSheetPdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"BalanceSheet-{asOfDate:yyyyMMdd}.pdf"); } /// /// PDF export of the AR Aging report. Same inline/attachment pattern as /// . Gated behind . /// // GET: /Reports/ArAgingPdf public async Task ArAgingPdf(DateTime? asOf, bool inline = false) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var asOfDate = (asOf ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate); var pdfBytes = await _pdfService.GenerateArAgingPdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"AR-Aging-{asOfDate:yyyyMMdd}.pdf"); } /// /// PDF export of the Sales & Income report. Not gated by accounting (mirrors /// ). Same inline/attachment pattern as other PDF actions. /// // GET: /Reports/SalesAndIncomePdf public async Task SalesAndIncomePdf(DateTime? from, DateTime? to, bool inline = false) { var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; var toDate = (to ?? DateTime.Today).Date; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate); var pdfBytes = await _pdfService.GenerateSalesAndIncomePdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf"); } // ── INDIVIDUAL REPORT PAGES ────────────────────────────────────────────── /// /// KPI Dashboard — a focused summary of the most important business metrics: revenue, job /// volume, quote win rate, customer retention, average job value, and equipment health. /// Uses string-based status code comparisons ("COMPLETED", "PENDING", etc.) rather than enum /// values because JobStatus and JobPriority are lookup-table entities, not enums. /// public async Task KpiDashboard(int months = 6) { var now = DateTime.UtcNow; var today = DateTime.Today; var startOfMonth = new DateTime(now.Year, now.Month, 1); var startOfLastMonth = startOfMonth.AddMonths(-1); var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList(); var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus); var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var totalRevenue = completedJobs.Sum(j => j.FinalPrice); var completedThisMonth = jobs.Count(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfMonth); var approvedQuotes = quotes.Where(q => q.QuoteStatus.StatusCode == "APPROVED" || q.QuoteStatus.StatusCode == "CONVERTED").ToList(); var winRate = quotes.Any() ? Math.Round((decimal)approvedQuotes.Count / quotes.Count * 100, 1) : 0m; var avgJobValue = completedJobs.Any() ? totalRevenue / completedJobs.Count : 0m; var avgJobDuration = completedJobs.Where(j => j.CompletedDate.HasValue) .Select(j => (j.CompletedDate!.Value - j.CreatedAt).TotalDays).DefaultIfEmpty(0).Average(); var revenueThisMonth = completedJobs.Where(j => j.UpdatedAt >= startOfMonth).Sum(j => j.FinalPrice); var revenueLastMonth = completedJobs.Where(j => j.UpdatedAt >= startOfLastMonth && j.UpdatedAt < startOfMonth).Sum(j => j.FinalPrice); var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m; var completedAppointments = appointments.Count(a => a.AppointmentStatus?.StatusCode == "COMPLETED"); var appointmentCompletionRate = appointments.Any() ? Math.Round((decimal)completedAppointments / appointments.Count * 100, 1) : 0m; var customersWithMultipleJobs = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1); var totalCustomersWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count(); var customerRetentionRate = totalCustomersWithJobs > 0 ? Math.Round((decimal)customersWithMultipleJobs / totalCustomersWithJobs * 100, 1) : 0m; var lowStockCount = inventory.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); var jobsByMonth = completedJobs.Where(j => j.UpdatedAt >= startDate) .GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)) .ToDictionary(g => g.Key, g => g.ToList()); var monthLabels = new List(); var monthlyRevenue = new List(); for (var i = months - 1; i >= 0; i--) { var month = now.AddMonths(-i); var ms = new DateTime(month.Year, month.Month, 1); monthLabels.Add(month.ToString("MMM yyyy")); monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m); } var topCustomers = completedJobs.Where(j => j.Customer != null) .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() }) .OrderByDescending(x => x.Revenue).Take(5).ToList(); var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName) .OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder) .ToDictionary(g => g.Key, g => g.Count()); var equipmentByStatus = equipment.GroupBy(e => e.Status) .ToDictionary(g => FormatEnum(g.Key.ToString()), g => g.Count()); var vm = new KpiDashboardViewModel { ReportTitle = "KPI Dashboard", ReportDescription = "Key performance indicators across all business areas", SelectedMonths = months, TotalRevenue = totalRevenue, ActiveJobsCount = activeJobs.Count, ActiveCustomersCount = customers.Count(c => c.IsActive), QuoteWinRate = winRate, TotalQuotes = quotes.Count, CompletedJobsThisMonth = completedThisMonth, AverageJobValue = avgJobValue, AppointmentsThisMonth = appointments.Count(a => a.ScheduledStartTime >= startOfMonth), AverageJobDuration = avgJobDuration, MonthOverMonthGrowth = momGrowth, CustomerRetentionRate = customerRetentionRate, AppointmentCompletionRate = appointmentCompletionRate, LowStockCount = lowStockCount, MonthLabels = monthLabels, MonthlyRevenue = monthlyRevenue, JobsByStatus = jobsByStatus, TopCustomers = topCustomers, EquipmentByStatus = equipmentByStatus }; return View(vm); } /// /// Revenue Trends report — monthly revenue/job-count trend, top 10 customers by revenue, /// and revenue broken down by customer type and job priority. All data is in-memory after a /// single jobs query; month labels and series values are built in a single forward-iteration loop. /// public async Task RevenueTrends(int months = 6) { var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList(); var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList()); var monthLabels = new List(); var monthlyRevenue = new List(); var monthlyJobCount = new List(); var avgOv = new List(); for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); monthLabels.Add(m.ToString("MMM yyyy")); if (byMonth.TryGetValue(ms, out var mj)) { monthlyRevenue.Add(mj.Sum(j => j.FinalPrice)); monthlyJobCount.Add(mj.Count); avgOv.Add(mj.Any() ? mj.Average(j => j.FinalPrice) : 0m); } else { monthlyRevenue.Add(0); monthlyJobCount.Add(0); avgOv.Add(0); } } var revenueByCustomerType = completedJobs.Where(j => j.Customer != null) .GroupBy(j => j.Customer!.IsCommercial ? "Commercial" : "Individual") .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice)); var revenueByPriority = completedJobs .GroupBy(j => j.JobPriority.DisplayName) .OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder) .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice)); var topCustomers = completedJobs.Where(j => j.Customer != null) .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() }) .OrderByDescending(x => x.Revenue).Take(10).ToList(); return View(new RevenueTrendsViewModel { ReportTitle = "Revenue Trends", ReportDescription = "Monthly revenue, job count, and customer breakdown", SelectedMonths = months, MonthLabels = monthLabels, MonthlyRevenue = monthlyRevenue, MonthlyJobCount = monthlyJobCount, AverageOrderValueTrend = avgOv, RevenueByCustomerType = revenueByCustomerType, RevenueByPriority = revenueByPriority, TopCustomers = topCustomers, TotalRevenue = completedJobs.Sum(j => j.FinalPrice), TotalCompletedJobs = completedJobs.Count, AverageJobValue = completedJobs.Any() ? completedJobs.Average(j => j.FinalPrice) : 0m }); } /// /// Operations Report — job-status distribution, appointment analytics, worker performance, /// equipment health, and low-stock alerts. Worker stats require loading ApplicationUser records /// separately (via UserManager) because assigned users are Identity users, not domain entities, /// so they can't be eagerly loaded via the generic repository. /// public async Task OperationsReport(int months = 6) { var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList(); var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus); var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList(); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName) .OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder) .ToDictionary(g => g.Key, g => g.Count()); var jobsByPriority = activeJobs.GroupBy(j => j.JobPriority.DisplayName) .OrderBy(g => activeJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder) .ToDictionary(g => g.Key, g => g.Count()); var appointmentsByType = appointments.Where(a => a.AppointmentType != null).GroupBy(a => a.AppointmentType!.DisplayName).ToDictionary(g => g.Key, g => g.Count()); var appointmentsByStatus = appointments.Where(a => a.AppointmentStatus != null).GroupBy(a => a.AppointmentStatus!.DisplayName).ToDictionary(g => g.Key, g => g.Count()); var completedAppts = appointments.Count(a => a.AppointmentStatus?.StatusCode == "COMPLETED"); var appointmentCompletionRate = appointments.Any() ? Math.Round((decimal)completedAppts / appointments.Count * 100, 1) : 0m; var appointmentsByDay = appointments.GroupBy(a => a.ScheduledStartTime.DayOfWeek).OrderBy(g => (int)g.Key).ToDictionary(g => g.Key.ToString(), g => g.Count()); var assignedUserIds = jobs.Where(j => j.AssignedUserId != null).Select(j => j.AssignedUserId!).Concat(appointments.Where(a => a.AssignedUserId != null).Select(a => a.AssignedUserId!)).Distinct().ToList(); var assignedUsers = assignedUserIds.Any() ? await _userManager.Users.Where(u => assignedUserIds.Contains(u.Id)).ToListAsync() : new List(); var workerStats = assignedUsers.Select(u => new WorkerStatsItem { Name = u.FullName, Role = u.Position ?? u.CompanyRole ?? "User", JobsAssigned = jobs.Count(j => j.AssignedUserId == u.Id), JobsCompleted = completedJobs.Count(j => j.AssignedUserId == u.Id), AppointmentsAssigned = appointments.Count(a => a.AssignedUserId == u.Id) }).Where(w => w.JobsAssigned > 0 || w.AppointmentsAssigned > 0).OrderByDescending(w => w.JobsCompleted).Take(10).ToList(); var equipmentByStatus = equipment.GroupBy(e => e.Status).ToDictionary(g => FormatEnum(g.Key.ToString()), g => g.Count()); var lowStock = inventory.Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint).OrderBy(i => i.QuantityOnHand).Take(10) .Select(i => new LowStockItem { Id = i.Id, Name = i.Name, ColorName = i.ColorName, QuantityOnHand = i.QuantityOnHand, ReorderPoint = i.ReorderPoint, UnitOfMeasure = i.UnitOfMeasure }).ToList(); return View(new OperationsReportViewModel { ReportTitle = "Operations Report", ReportDescription = "Job status, appointments, worker performance, and equipment health", SelectedMonths = months, ActiveJobsCount = activeJobs.Count, TotalAppointments = appointments.Count, JobsByStatus = jobsByStatus, ActiveJobsByPriority = jobsByPriority, AppointmentsByType = appointmentsByType, AppointmentsByStatus = appointmentsByStatus, AppointmentCompletionRate = appointmentCompletionRate, AppointmentsByDayOfWeek = appointmentsByDay, WorkerStats = workerStats, EquipmentByStatus = equipmentByStatus, LowStockItems = lowStock }); } /// /// Customer Overview report — acquisition trend (new customers per month), retention rate, /// lifetime value top-10, and quote conversion funnel. Retention is defined as customers /// who have more than one completed job — a simple but actionable proxy for repeat business. /// public async Task CustomerOverview(int months = 6) { var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList(); var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)) .Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count()); var monthLabels = new List(); var newCustomersPerMonth = new List(); for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); monthLabels.Add(m.ToString("MMM yyyy")); newCustomersPerMonth.Add(customersByMonth.TryGetValue(ms, out var c) ? c : 0); } var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1); var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count(); var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m; var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .Select(g => new CustomerLifetimeValueItem { CustomerName = g.Key.CompanyName, TotalRevenue = g.Sum(j => j.FinalPrice), JobCount = g.Count(), AvgOrderValue = g.Average(j => j.FinalPrice), FirstJobDate = g.Min(j => j.CreatedAt), LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) }) .OrderByDescending(c => c.TotalRevenue).Take(10).ToList(); var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count()); var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") }; var catalogByCategory = catalogItems.Where(c => c.Category != null).GroupBy(c => c.Category!.Name).OrderByDescending(g => g.Count()).Take(8).ToDictionary(g => g.Key, g => g.Count()); return View(new CustomerOverviewViewModel { ReportTitle = "Customer Overview", ReportDescription = "Acquisition trends, lifetime value, and quote conversion", SelectedMonths = months, MonthLabels = monthLabels, NewCustomersPerMonth = newCustomersPerMonth, CustomerRetentionRate = retentionRate, ActiveCustomersCount = customers.Count(c => c.IsActive), CustomerLifetimeValue = clv, QuotesByStatus = quotesByStatus, QuoteFunnel = quoteFunnel, CatalogByCategory = catalogByCategory }); } /// /// Financial Summary report — AR overview with aging buckets, monthly invoiced-vs-collected /// chart, top 5 outstanding customers, recent payments, and average days-to-payment metric. /// AvgDaysToPayment is calculated only on invoices with both SentDate and PaidDate set, so it /// reflects actual turnaround time from sending to collection rather than creation-to-payment. /// Gated behind . /// public async Task FinancialSummary(int months = 6) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var now = DateTime.UtcNow; var today = DateTime.Today; var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList(); var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList(); var invoicesByMonth = activeInvoices.GroupBy(i => new DateTime(i.InvoiceDate.Year, i.InvoiceDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList()); var paymentsByMonth = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList()); var monthLabels = new List(); var monthlyInvoiced = new List(); var monthlyCollected = new List(); for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); monthLabels.Add(m.ToString("MMM yyyy")); monthlyInvoiced.Add(invoicesByMonth.TryGetValue(ms, out var mi) ? mi.Sum(x => x.Total) : 0m); monthlyCollected.Add(paymentsByMonth.TryGetValue(ms, out var mp) ? mp.Sum(x => x.Amount) : 0m); } var agingBuckets = new List { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } }; foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; } var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList(); var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList(); var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList(); var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0; return View(new FinancialSummaryViewModel { ReportTitle = "Financial Summary", ReportDescription = "Invoicing, collections, and accounts receivable overview", SelectedMonths = months, MonthLabels = monthLabels, TotalInvoiced = activeInvoices.Sum(i => i.Total), TotalCollected = activeInvoices.Sum(i => i.AmountPaid), TotalOutstanding = activeInvoices.Sum(i => i.BalanceDue), TotalOverdue = overdueInvoices.Sum(i => i.BalanceDue), InvoicesOverdueCount = overdueInvoices.Count, InvoicesDraftCount = allInvoices.Count(i => i.Status == InvoiceStatus.Draft), InvoicesPaidCount = allInvoices.Count(i => i.Status == InvoiceStatus.Paid), AvgDaysToPayment = avgDays, MonthlyInvoiced = monthlyInvoiced, MonthlyCollected = monthlyCollected, AgingBuckets = agingBuckets, RecentPayments = recentPayments, TopOutstandingCustomers = topOutstanding }); } /// /// Expenses & AP report — outstanding vendor bills bucketed by aging, vendor spend ranking, /// and direct expenses by account. Mirrors the AR Aging structure on the payable side. /// Gated behind . /// public async Task ExpensesAp(int months = 6) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var now = DateTime.UtcNow; var today = DateTime.Today; var allBills = await _operationalReports.GetActiveBillsAsync(); var allExpenses = await _operationalReports.GetAllExpensesAsync(); var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList(); var apAgingBuckets = new List { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } }; foreach (var bill in outstandingBills) { var days = bill.DueDate.HasValue ? (int)(today - bill.DueDate.Value).TotalDays : 0; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; apAgingBuckets[b].Amount += bill.BalanceDue; apAgingBuckets[b].Count++; } var vendorSpend = allBills.Where(b => b.Vendor != null).GroupBy(b => new { b.VendorId, Name = b.Vendor!.CompanyName }).Select(g => new VendorSpendItem { VendorName = g.Key.Name, TotalBilled = g.Sum(b => b.Total), TotalPaid = g.Sum(b => b.AmountPaid), BillCount = g.Count() }).OrderByDescending(v => v.TotalBilled).Take(8).ToList(); var expensesByAccount = allExpenses.Where(e => e.ExpenseAccount != null).GroupBy(e => new { e.ExpenseAccountId, Name = $"{e.ExpenseAccount!.AccountNumber} – {e.ExpenseAccount.Name}" }).Select(g => new ExpenseByAccountItem { AccountName = g.Key.Name, Amount = g.Sum(e => e.Amount), Count = g.Count() }).OrderByDescending(x => x.Amount).ToList(); var monthLabels = new List(); var monthlyBillsPaid = new List(); var monthlyDirectExpenses = new List(); // Also load collected payments for P&L comparison var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList(); var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList()); var plRevenue = new List(); var plExpenses = new List(); var plNet = new List(); for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); var me = ms.AddMonths(1); monthLabels.Add(m.ToString("MMM yyyy")); var billsPaid = allBills.SelectMany(b => b.Payments).Where(p => p.PaymentDate >= ms && p.PaymentDate < me).Sum(p => p.Amount); var expenses = allExpenses.Where(e => e.Date >= ms && e.Date < me).Sum(e => e.Amount); var revenue = paymentsByMonth.TryGetValue(ms, out var mp) ? mp.Sum(x => x.Amount) : 0m; monthlyBillsPaid.Add(billsPaid); monthlyDirectExpenses.Add(expenses); plRevenue.Add(revenue); plExpenses.Add(billsPaid + expenses); plNet.Add(revenue - billsPaid - expenses); } return View(new ExpensesApViewModel { ReportTitle = "Expenses & AP", ReportDescription = "Vendor spend, accounts payable aging, and expense breakdown", SelectedMonths = months, MonthLabels = monthLabels, TotalBilled = allBills.Sum(b => b.Total), TotalBillsPaid = allBills.Sum(b => b.AmountPaid), TotalApOutstanding = allBills.Sum(b => b.BalanceDue), TotalDirectExpenses = allExpenses.Sum(e => e.Amount), ApAgingBuckets = apAgingBuckets, VendorSpend = vendorSpend, ExpensesByAccount = expensesByAccount, MonthlyBillsPaid = monthlyBillsPaid, MonthlyDirectExpenses = monthlyDirectExpenses, PlRevenue = plRevenue, PlExpenses = plExpenses, PlNetIncome = plNet }); } /// /// Powder Usage report — top 15 powder colors by lbs consumed (via JobUsage inventory /// transactions) and a monthly trend of total lbs used and cost. Job count per color is /// approximated using distinct transaction Reference values (which store the job number). /// public async Task PowderUsage(int months = 6) { var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)) .Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList(); var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId) .Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() }) .OrderByDescending(x => x.TotalLbsUsed).Take(15).ToList(); var monthLabels = new List(); var monthlyLbs = new List(); var monthlyCost = new List(); for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); var me = ms.AddMonths(1); var mTx = powderTransactions.Where(t => t.TransactionDate >= ms && t.TransactionDate < me).ToList(); monthLabels.Add(m.ToString("MMM yyyy")); monthlyLbs.Add(mTx.Sum(t => Math.Abs(t.Quantity))); monthlyCost.Add(mTx.Sum(t => Math.Abs(t.TotalCost))); } return View(new PowderUsageViewModel { ReportTitle = "Powder Usage", ReportDescription = "Powder consumption by color and monthly trend", SelectedMonths = months, MonthLabels = monthLabels, TotalPowderUsedLbs = powderTransactions.Sum(t => Math.Abs(t.Quantity)), TotalPowderCost = powderTransactions.Sum(t => Math.Abs(t.TotalCost)), TotalJobsWithPowderUsage = powderTransactions.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count(), ColorsUsedCount = topColors.Count, TopColorsByUsage = topColors, MonthlyPowderUsageLbs = monthlyLbs, MonthlyPowderUsageCost = monthlyCost }); } /// Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced. public async Task SalesByCustomer(int months = 6) { var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var items = activeInvoices.Where(i => i.Customer != null) .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial }) .Select(g => new SalesByCustomerItem { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name ?? "Unknown", IsCommercial = g.Key.IsCommercial, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), LastInvoiceDate = g.Max(i => (DateTime?)i.InvoiceDate) }) .OrderByDescending(x => x.TotalInvoiced).ToList(); return View(new SalesByCustomerViewModel { ReportTitle = "Sales by Customer", ReportDescription = "Revenue and collection totals per customer", SelectedMonths = months, Items = items, TotalInvoiced = items.Sum(x => x.TotalInvoiced), TotalPaid = items.Sum(x => x.TotalPaid) }); } /// /// Customer Retention report — classifies every active customer into Active / At Risk / Lapsing / /// Churned / Never Ordered based on days since their last completed job. Thresholds: /// ≤30 days = Active, ≤60 = At Risk, ≤90 = Lapsing, >90 = Churned. /// Results are sorted worst-to-best by status so churned customers appear first. /// public async Task CustomerRetention(int months = 6) { var now = DateTime.UtcNow; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var items = customers.Where(c => c.IsActive).Select(c => { var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList(); var lastJob = cJobs.Any() ? cJobs.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) : (DateTime?)null; var daysSince = lastJob.HasValue ? (int)(now - lastJob.Value).TotalDays : -1; var status = cJobs.Count == 0 ? "Never Ordered" : daysSince <= 30 ? "Active" : daysSince <= 60 ? "At Risk" : daysSince <= 90 ? "Lapsing" : "Churned"; return new CustomerRetentionItem { CustomerId = c.Id, CustomerName = c.IsCommercial ? c.CompanyName ?? "Unknown" : $"{c.ContactFirstName} {c.ContactLastName}".Trim(), Email = c.Email, Phone = c.Phone ?? c.MobilePhone, TotalJobs = cJobs.Count, LifetimeRevenue = cJobs.Sum(j => j.FinalPrice), LastJobDate = lastJob, DaysSinceLastJob = daysSince, RetentionStatus = status }; }).OrderBy(c => c.RetentionStatus == "Churned" ? 0 : c.RetentionStatus == "Lapsing" ? 1 : c.RetentionStatus == "At Risk" ? 2 : c.RetentionStatus == "Active" ? 3 : 4).ThenByDescending(c => c.LifetimeRevenue).ToList(); return View(new CustomerRetentionViewModel { ReportTitle = "Customer Retention", ReportDescription = "Identify at-risk and churned customers by recency of activity", SelectedMonths = months, Items = items, ActiveCount = items.Count(r => r.RetentionStatus == "Active"), AtRiskCount = items.Count(r => r.RetentionStatus == "At Risk"), LapsingCount = items.Count(r => r.RetentionStatus == "Lapsing"), ChurnedCount = items.Count(r => r.RetentionStatus == "Churned"), NeverOrderedCount = items.Count(r => r.RetentionStatus == "Never Ordered") }); } /// /// Job Cycle Time report — shows average, min, and max days spent in each workflow stage /// for all completed jobs with a CompletedDate. Reads from JobStatusHistory (the change-log /// table) to calculate time in each status; the final status's time is measured from the /// last recorded change to CompletedDate. Jobs with cycle times >365 days are excluded as /// likely data-entry errors. Only applies to completed jobs; active jobs are excluded because /// their current-status time is still accumulating. /// public async Task JobCycleTime(int months = 6) { var now = DateTime.UtcNow; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList(); var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync(); var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" }; var statusTimings = new Dictionary Days)>(); foreach (var job in completedJobs) { if (!historyByJob.TryGetValue(job.Id, out var history) || !history.Any()) continue; var prevDate = job.CreatedAt; foreach (var entry in history) { 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 = history.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 items = statusTimings.Where(kv => kv.Value.Days.Any()) .Select(kv => new JobCycleTimeItem { StatusCode = kv.Key, StatusName = kv.Value.DisplayName, DisplayOrder = Array.IndexOf(statusDisplayOrder, kv.Key), AvgDays = Math.Round(kv.Value.Days.Average(), 1), MinDays = Math.Round(kv.Value.Days.Min(), 1), MaxDays = Math.Round(kv.Value.Days.Max(), 1), JobCount = kv.Value.Days.Count }) .OrderBy(x => x.DisplayOrder < 0 ? 99 : x.DisplayOrder).ToList(); var overallAvg = completedJobs.Select(j => (j.CompletedDate!.Value - j.CreatedAt).TotalDays).Where(d => d >= 0 && d <= 365).DefaultIfEmpty(0).Average(); return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg }); } /// /// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current /// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status" /// because there's no per-status entry-timestamp on the Job entity itself. Jobs that haven't /// moved in a long time bubble to the top, making it easy to spot stalled work. /// public async Task JobStatusAging(int months = 6) { var now = DateTime.UtcNow; var today = DateTime.Today; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var items = activeJobs.Select(j => new JobStatusAgingItem { JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(), StatusName = j.JobStatus.DisplayName, StatusCode = j.JobStatus.StatusCode, PriorityName = j.JobPriority?.DisplayName ?? "Normal", PriorityCode = j.JobPriority?.PriorityCode ?? "NORMAL", DaysInCurrentStatus = (int)(now - (j.UpdatedAt ?? j.CreatedAt)).TotalDays, DueDate = j.DueDate, IsOverdue = j.DueDate.HasValue && j.DueDate.Value < today }).OrderByDescending(j => j.DaysInCurrentStatus).ToList(); return View(new JobStatusAgingViewModel { ReportTitle = "Job Status Aging", ReportDescription = "Active jobs ranked by days in current status", SelectedMonths = months, Items = items }); } /// /// Invoice Aging Detail — line-by-line view of every open (unpaid, non-voided) invoice with /// aging bucket and days overdue. Sorted by days overdue descending. Includes customer contact /// info so staff can use this report for collections outreach without switching screens. /// Gated behind . /// public async Task InvoiceAgingDetail(int months = 6) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var today = DateTime.Today; var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid) .Select(i => { var daysOverdue = i.DueDate.HasValue ? (int)(today - i.DueDate.Value.Date).TotalDays : 0; return new InvoiceAgingDetailItem { InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName ?? "Unknown" : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), CustomerEmail = i.Customer.Email, CustomerPhone = i.Customer.Phone ?? i.Customer.MobilePhone, InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue, DaysOverdue = Math.Max(0, daysOverdue), AgingBucket = daysOverdue <= 0 ? "Current" : daysOverdue <= 30 ? "1–30 Days" : daysOverdue <= 60 ? "31–60 Days" : daysOverdue <= 90 ? "61–90 Days" : "90+ Days", StatusDisplay = i.Status.ToString() }; }).OrderByDescending(i => i.DaysOverdue).ToList(); return View(new InvoiceAgingDetailViewModel { ReportTitle = "Invoice Aging Detail", ReportDescription = "Outstanding invoices with aging analysis", SelectedMonths = months, Items = items, TotalBalance = items.Sum(i => i.BalanceDue) }); } /// /// Powder Consumption vs Purchase report — per-SKU comparison of total pounds purchased vs /// consumed (JobUsage + Waste transactions). Items with no purchases and no consumption are /// excluded. Helps identify powder that was purchased but never used, or SKUs running down /// faster than they're being restocked. /// public async Task PowderConsumption(int months = 6) { var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList(); var items = allTx.Where(t => t.InventoryItem != null) .GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer }) .Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() }) .Where(x => x.TotalPurchasedLbs > 0 || x.TotalConsumedLbs > 0).OrderByDescending(x => x.TotalConsumedLbs).ToList(); return View(new PowderConsumptionViewModel { ReportTitle = "Powder Consumption vs Purchase", ReportDescription = "Compare purchased vs consumed powder per SKU", SelectedMonths = months, Items = items, TotalPurchasedLbs = items.Sum(x => x.TotalPurchasedLbs), TotalConsumedLbs = items.Sum(x => x.TotalConsumedLbs) }); } /// /// Inventory Turnover report — days-to-stockout and turnover rate per active inventory item. /// DailyConsumptionLbs = total consumed ÷ (months × 30) to normalize across reporting periods. /// DaysToStockout = current stock ÷ daily consumption; items with zero usage get DaysToStockout = 9999 /// (sentinel for "no usage data") which the view renders as "N/A". StockStatus thresholds: /// ≤7 days = Critical, ≤30 = Low, ≤90 = Normal, <9999 = Overstocked, 9999 = No Usage. /// public async Task InventoryTurnover(int months = 6) { var daysInPeriod = months * 30.0; var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList(); var items = inventory.Where(i => i.IsActive).Select(i => { var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList(); var consumed = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)); var purchased = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.Purchase).Sum(t => t.Quantity); var dailyUse = daysInPeriod > 0 ? consumed / daysInPeriod : 0; var dsOut = dailyUse > 0 ? (double)i.QuantityOnHand / dailyUse : 9999; return new InventoryTurnoverItem { InventoryItemId = i.Id, ItemName = i.Name, SKU = i.SKU, ColorName = i.ColorName, CurrentStockLbs = i.QuantityOnHand, TotalConsumedLbs = (decimal)consumed, TotalPurchasedLbs = (decimal)purchased, DailyConsumptionLbs = Math.Round((decimal)dailyUse, 3), DaysToStockout = dsOut >= 9999 ? 9999 : Math.Round(dsOut, 0), TurnoverRate = consumed > 0 && (double)i.QuantityOnHand > 0 ? Math.Round(consumed / (double)i.QuantityOnHand, 2) : 0, StockStatus = dsOut <= 7 ? "Critical" : dsOut <= 30 ? "Low" : dsOut <= 90 ? "Normal" : dsOut < 9999 ? "Overstocked" : "No Usage" }; }).Where(x => x.TotalConsumedLbs > 0 || x.CurrentStockLbs > 0).OrderBy(x => x.DaysToStockout).ToList(); return View(new InventoryTurnoverViewModel { ReportTitle = "Inventory Turnover", ReportDescription = "Stock consumption rates and days-to-stockout by item", SelectedMonths = months, Items = items }); } // ── AI: AR Follow-up Email Draft ───────────────────────────────────────── /// /// AJAX POST — uses Anthropic Claude to draft a collections follow-up email for a specific /// overdue invoice. The request body supplies the invoice details (amount, days overdue, /// customer name); the AI adjusts tone based on severity. Company name is injected server-side /// so the client can't spoof it. Rate-limited by the AI rate-limit policy. /// Gated behind . /// [HttpPost] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task DraftArFollowUp([FromBody] ArFollowUpRequest? request) { if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." }); if (request == null) return Json(new { success = false, error = "Invalid request." }); request.CompanyName = await GetCompanyNameAsync(); var result = await _accountingAi.DraftFollowUpEmailAsync(request); var _arCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _arC) ? _arC : 0; await _usageLogger.LogAsync(_arCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.ArFollowUp); return Json(result); } // ── AI: Financial Summary ───────────────────────────────────────────────── /// /// AJAX POST — assembles current-period financial data (AR totals, expense breakdown, /// month-over-month trends, overdue invoice count) and sends it to Claude for a plain-English /// narrative summary. Designed to be embedded in the Full Analytics view as a one-click insight. /// Bill payments are added to total expenses alongside direct expenses so the AI sees the /// complete cost picture. Rate-limited by the AI rate-limit policy. /// Gated behind . /// [HttpPost] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task GenerateFinancialSummary() { if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." }); try { var companyName = await GetCompanyNameAsync(); var now = DateTime.UtcNow; var today = DateTime.Today; // Load invoices for AR data var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList(); var totalRevenue = activeInvoices.Sum(i => i.Total); var totalCollected = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).Sum(p => p.Amount); // Prior month revenue var startOfThisMonth = new DateTime(now.Year, now.Month, 1); var startOfLastMonth = startOfThisMonth.AddMonths(-1); var priorMonthInvoices = activeInvoices.Where(i => i.InvoiceDate >= startOfLastMonth && i.InvoiceDate < startOfThisMonth).ToList(); var thisMonthInvoices = activeInvoices.Where(i => i.InvoiceDate >= startOfThisMonth).ToList(); var priorMonthRevenue = priorMonthInvoices.Sum(i => i.Total); var thisMonthRevenue = thisMonthInvoices.Sum(i => i.Total); // AR overdue var totalArOutstanding = outstandingInvoices.Sum(i => i.BalanceDue); var arOverdue30 = outstandingInvoices .Where(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30) .Sum(i => i.BalanceDue); var overdueCount = outstandingInvoices .Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30); // Expenses by account var allExpenses = await _operationalReports.GetAllExpensesAsync(); var expensesByCategory = allExpenses .Where(e => e.ExpenseAccount != null) .GroupBy(e => e.ExpenseAccount!.Name) .Select(g => new ExpenseByCategory { Category = g.Key, Amount = g.Sum(e => e.Amount) }) .OrderByDescending(x => x.Amount) .ToList(); var totalExpenses = allExpenses.Sum(e => e.Amount); // Also include bills paid as expenses var allBills = await _operationalReports.GetActiveBillsAsync(); var billsPaid = allBills.Sum(b => b.AmountPaid); totalExpenses += billsPaid; var priorMonthExpenses = allExpenses .Where(e => e.Date >= startOfLastMonth && e.Date < startOfThisMonth) .Sum(e => e.Amount); var summaryRequest = new FinancialSummaryRequest { CompanyName = companyName, Period = "Last 6 months", TotalRevenue = thisMonthRevenue > 0 ? thisMonthRevenue : totalRevenue, TotalExpenses = totalExpenses, NetIncome = totalRevenue - totalExpenses, PriorMonthRevenue = priorMonthRevenue, PriorMonthExpenses = priorMonthExpenses, TotalArOutstanding = totalArOutstanding, ArOverdue30Days = arOverdue30, OverdueInvoiceCount = overdueCount, ExpensesByCategory = expensesByCategory }; var result = await _accountingAi.GenerateFinancialSummaryAsync(summaryRequest); var _fsCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _fsC) ? _fsC : 0; await _usageLogger.LogAsync(_fsCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.FinancialSummary, result.Success); return Json(result); } catch (Exception ex) { _logger.LogError(ex, "Error generating financial summary"); return Json(new FinancialSummaryResult { Success = false, ErrorMessage = "An error occurred while generating the summary." }); } } // ── AI: Cash Flow Forecast ─────────────────────────────────────────────── /// Entry point for the Cash Flow Forecast page. Gated behind . public IActionResult CashFlowForecast() => AllowAccounting() ? View() : RedirectToAction(nameof(Landing)); /// /// AJAX POST — builds a 30/60/90-day cash flow forecast using Claude. Passes three data streams: /// — Open AR invoices with customer-specific avg-days-to-pay (derived from historical paid invoices). /// Customers with no payment history default to 30 days. /// — Open AP bills with their due dates so expected outflows are included. /// — Top 30 active (non-terminal) jobs by value as a pipeline of expected future invoices. /// The AI synthesises these into an outlook badge (Strong/Moderate/Tight/Concerning) with /// narrative projections. Rate-limited by the AI rate-limit policy. /// Gated behind . /// [HttpPost] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task GenerateCashFlowForecast() { if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." }); try { var companyName = await GetCompanyNameAsync(); var today = DateTime.Today; // Open AR invoices var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)) .Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid) .ToList(); // Compute avg days to pay per customer from paid invoices var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)) .Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default) .ToList(); var avgDaysByCustomer = paidInvoices .Where(i => i.Payments.Any(p => !p.IsDeleted)) .GroupBy(i => i.CustomerId) .ToDictionary( g => g.Key, g => (int)g.Select(i => { var firstPay = i.Payments.Where(p => !p.IsDeleted).OrderBy(p => p.PaymentDate).FirstOrDefault(); return firstPay == null ? 30.0 : (firstPay.PaymentDate - i.InvoiceDate).TotalDays; }).DefaultIfEmpty(30).Average()); var arItems = openInvoices.Select(i => new CashFlowArItem { CustomerName = i.Customer != null ? (i.Customer.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()) : "Unknown", InvoiceNumber = i.InvoiceNumber, BalanceDue = i.BalanceDue, DueDateIso = i.DueDate?.ToString("yyyy-MM-dd"), DaysOverdue = i.DueDate.HasValue && today > i.DueDate.Value ? (today - i.DueDate.Value).Days : 0, AvgDaysToPay = avgDaysByCustomer.TryGetValue(i.CustomerId, out var avg) ? avg : 30 }).ToList(); // Open AP bills var openBills = (await _operationalReports.GetActiveBillsAsync()) .Where(b => b.AmountPaid < b.Total) .ToList(); var apItems = openBills.Select(b => new CashFlowApItem { VendorName = b.Vendor?.CompanyName ?? "Unknown", BillNumber = b.BillNumber, BalanceDue = b.BalanceDue, DueDateIso = b.DueDate?.ToString("yyyy-MM-dd") }).ToList(); // Active job pipeline (non-terminal jobs not yet invoiced) var activeJobs = (await _unitOfWork.Jobs.GetBoardJobsAsync()) .Where(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus) .OrderByDescending(j => j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice) .Take(30) .ToList(); var jobItems = activeJobs.Select(j => new CashFlowJobItem { JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim()) : "Unknown", Status = j.JobStatus?.DisplayName ?? "Active", EstimatedValue = j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice }).ToList(); var forecastRequest = new CashFlowForecastRequest { CompanyName = companyName, AsOfDate = today.ToString("yyyy-MM-dd"), OpenInvoices = arItems, OpenBills = apItems, ActiveJobs = jobItems }; var result = await _accountingAi.GenerateCashFlowForecastAsync(forecastRequest); var _cfCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _cfC) ? _cfC : 0; await _usageLogger.LogAsync(_cfCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.CashFlowForecast, result.Success); return Json(result); } catch (Exception ex) { _logger.LogError(ex, "Error generating cash flow forecast"); return Json(new CashFlowForecastResult { Success = false, ErrorMessage = "An error occurred while generating the forecast." }); } } // ── AI: Anomaly / Duplicate Detection ──────────────────────────────────── /// Entry point for the Anomaly Detection page. Gated behind . public IActionResult AnomalyDetection() => AllowAccounting() ? View() : RedirectToAction(nameof(Landing)); /// /// AJAX POST — scans the last 90 days of vendor bills for anomalies using Claude. Passes three /// data streams to the AI: /// — Recent bills (last 90 days) as candidates for analysis. /// — Vendor history (all time) with per-vendor average invoice amount and monthly spend, so the /// AI can flag amounts that deviate significantly from the vendor's normal range. /// — Account spend trends (this month vs last month vs historical average by account) so the AI /// can flag accounts with unusual spikes. /// Claude returns a severity-sorted list of flag cards (duplicate invoice numbers, amount spikes, /// unknown vendors, account overruns). Rate-limited by the AI rate-limit policy. /// Gated behind . /// [HttpPost] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task RunAnomalyDetection() { if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." }); try { var companyName = await GetCompanyNameAsync(); var today = DateTime.Today; var ninetyDaysAgo = today.AddDays(-90); var startOfThisMonth = new DateTime(today.Year, today.Month, 1); var startOfLastMonth = startOfThisMonth.AddMonths(-1); // All active bills — used for both recent-bill candidates and all-time vendor history var allBills = await _operationalReports.GetActiveBillsAsync(); var recentBills = allBills.Where(b => b.BillDate >= ninetyDaysAgo).OrderByDescending(b => b.BillDate).ToList(); var billSummaries = recentBills.Select(b => new AnomalyBillSummary { Id = b.Id, BillNumber = b.BillNumber, VendorName = b.Vendor?.CompanyName ?? "Unknown", Total = b.Total, BillDateIso = b.BillDate.ToString("yyyy-MM-dd"), VendorInvoiceNumber = b.VendorInvoiceNumber }).ToList(); var vendorHistory = allBills .Where(b => b.Vendor != null) .GroupBy(b => b.Vendor!.CompanyName) .Select(g => { var amounts = g.Select(b => b.Total).ToList(); var months = g.GroupBy(b => new { b.BillDate.Year, b.BillDate.Month }) .Select(m => m.Sum(b => b.Total)).ToList(); return new AnomalyVendorHistory { VendorName = g.Key, AverageInvoiceAmount = amounts.Count > 0 ? amounts.Average() : 0, AverageMonthlySpend = months.Count > 0 ? months.Average() : 0, InvoiceCount = amounts.Count }; }).ToList(); // Account spend trends (bills + expenses by account this month vs historical avg) var allExpenses = await _operationalReports.GetAllExpensesAsync(); var accountTrends = allExpenses .Where(e => e.ExpenseAccount != null) .GroupBy(e => e.ExpenseAccount!.Name) .Select(g => { var thisMonth = g.Where(e => e.Date >= startOfThisMonth).Sum(e => e.Amount); var lastMonth = g.Where(e => e.Date >= startOfLastMonth && e.Date < startOfThisMonth).Sum(e => e.Amount); var allMonths = g.GroupBy(e => new { e.Date.Year, e.Date.Month }) .Select(m => m.Sum(e => e.Amount)).ToList(); return new AnomalyAccountTrend { AccountName = g.Key, ThisMonthAmount = thisMonth, LastMonthAmount = lastMonth, AverageMonthlyAmount = allMonths.Count > 0 ? allMonths.Average() : 0 }; }) .Where(t => t.AverageMonthlyAmount > 0 || t.ThisMonthAmount > 0) .ToList(); var detectionRequest = new AnomalyDetectionRequest { CompanyName = companyName, RecentBills = billSummaries, VendorHistory = vendorHistory, AccountTrends = accountTrends }; var result = await _accountingAi.DetectAnomaliesAsync(detectionRequest); var _adCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _adC) ? _adC : 0; await _usageLogger.LogAsync(_adCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.AnomalyDetection, result.Success); return Json(result); } catch (Exception ex) { _logger.LogError(ex, "Error running anomaly detection"); return Json(new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." }); } } /// /// Looks up the current tenant's company name from the CompanyId claim. Used to inject the /// company name into AI prompts so the generated text refers to the actual business, not a /// generic placeholder. Falls back to "Your Company" if the claim is missing or the company /// record is not found (prevents blank company names in AI output). /// private async Task GetCompanyNameAsync() { var companyIdClaim = User.FindFirst("CompanyId")?.Value; if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId)) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId); return company?.CompanyName ?? "Your Company"; } return "Your Company"; } } // View Models public class AnalyticsDashboardViewModel { // Overview KPIs public decimal TotalRevenue { get; set; } public int ActiveJobsCount { get; set; } public int ActiveCustomersCount { get; set; } public decimal QuoteWinRate { get; set; } public int TotalQuotes { get; set; } public int CompletedJobsThisMonth { get; set; } public decimal AverageJobValue { get; set; } public int AppointmentsThisMonth { get; set; } public double AverageJobDuration { get; set; } public decimal MonthOverMonthGrowth { get; set; } // Revenue Analytics public List MonthLabels { get; set; } = new(); public List MonthlyRevenue { get; set; } = new(); public List MonthlyJobCount { get; set; } = new(); public Dictionary RevenueByCustomerType { get; set; } = new(); public Dictionary RevenueByPriority { get; set; } = new(); public List AverageOrderValueTrend { get; set; } = new(); public List TopCustomers { get; set; } = new(); // Operations Analytics public Dictionary JobsByStatus { get; set; } = new(); public Dictionary ActiveJobsByPriority { get; set; } = new(); public Dictionary AppointmentsByType { get; set; } = new(); public Dictionary AppointmentsByStatus { get; set; } = new(); public decimal AppointmentCompletionRate { get; set; } public Dictionary AppointmentsByDayOfWeek { get; set; } = new(); public List WorkerStats { get; set; } = new(); public Dictionary EquipmentByStatus { get; set; } = new(); public List LowStockItems { get; set; } = new(); // Customer Analytics public List NewCustomersPerMonth { get; set; } = new(); public decimal CustomerRetentionRate { get; set; } public List CustomerLifetimeValue { get; set; } = new(); public Dictionary QuotesByStatus { get; set; } = new(); public QuoteConversionFunnel QuoteFunnel { get; set; } = new(); public Dictionary CatalogByCategory { get; set; } = new(); // Financial Analytics public decimal TotalInvoiced { get; set; } public decimal TotalCollected { get; set; } public decimal TotalOutstanding { get; set; } public decimal TotalOverdue { get; set; } public int InvoicesOverdueCount { get; set; } public int InvoicesDraftCount { get; set; } public int InvoicesPaidCount { get; set; } public List MonthlyInvoiced { get; set; } = new(); public List MonthlyCollected { get; set; } = new(); public List AgingBuckets { get; set; } = new(); public List RecentPayments { get; set; } = new(); public List TopOutstandingCustomers { get; set; } = new(); public double AvgDaysToPayment { get; set; } // Expense / AP Analytics public decimal TotalBilled { get; set; } public decimal TotalBillsPaid { get; set; } public decimal TotalApOutstanding { get; set; } public decimal TotalDirectExpenses { get; set; } public List ApAgingBuckets { get; set; } = new(); public List VendorSpend { get; set; } = new(); public List ExpensesByAccount { get; set; } = new(); public List MonthlyBillsPaid { get; set; } = new(); public List MonthlyDirectExpenses { get; set; } = new(); public List PlRevenue { get; set; } = new(); public List PlExpenses { get; set; } = new(); public List PlNetIncome { get; set; } = new(); // Customer Retention public List CustomerRetention { get; set; } = new(); // Job Cycle Time public List JobCycleTime { get; set; } = new(); public double OverallAvgCycleDays { get; set; } // Job Status Aging public List JobStatusAging { get; set; } = new(); // Invoice Aging Detail public List InvoiceAgingDetail { get; set; } = new(); // Powder Consumption vs Purchase public List PowderConsumption { get; set; } = new(); // Inventory Turnover public List InventoryTurnover { get; set; } = new(); // Sales by Customer public List SalesByCustomer { get; set; } = new(); public decimal SalesByCustomerTotalInvoiced { get; set; } public decimal SalesByCustomerTotalPaid { get; set; } // Powder Usage Analytics public decimal TotalPowderUsedLbs { get; set; } public decimal TotalPowderCost { get; set; } public int TotalJobsWithPowderUsage { get; set; } public int ColorsUsedCount { get; set; } public List TopColorsByUsage { get; set; } = new(); public List MonthlyPowderUsageLbs { get; set; } = new(); public List MonthlyPowderUsageCost { get; set; } = new(); // Filters public int SelectedMonths { get; set; } = 6; }