1cb7a8ca4a
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers, routing all data access through IUnitOfWork. Added IPlainRepository<T> for the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote) that intentionally don't extend BaseEntity and therefore can't use the constrained IRepository<T>. Added permanent-exception comments to the 18 controllers that legitimately retain direct DbContext access (Identity infra, cross-tenant platform ops, bulk streaming exports). Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup gate that reflects over every Controller subclass and throws at boot if any non-exempt controller injects ApplicationDbContext. The app cannot start with a violation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2081 lines
125 KiB
C#
2081 lines
125 KiB
C#
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<ReportsController> _logger;
|
||
private readonly IFinancialReportService _financialReports;
|
||
private readonly IOperationalReportService _operationalReports;
|
||
private readonly IPdfService _pdfService;
|
||
private readonly UserManager<ApplicationUser> _userManager;
|
||
private readonly IAccountingAiService _accountingAi;
|
||
private readonly IAiUsageLogger _usageLogger;
|
||
|
||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
||
{
|
||
_unitOfWork = unitOfWork;
|
||
_logger = logger;
|
||
_financialReports = financialReports;
|
||
_operationalReports = operationalReports;
|
||
_pdfService = pdfService;
|
||
_userManager = userManager;
|
||
_accountingAi = accountingAi;
|
||
_usageLogger = usageLogger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public IActionResult Landing() => View();
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private bool AllowAccounting() => HttpContext.Items["AllowAccounting"] as bool? ?? false;
|
||
|
||
// Catalog is the new index — redirect legacy bookmarks
|
||
public IActionResult Index() => RedirectToAction(nameof(Landing));
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string>();
|
||
var monthlyRevenue = new List<decimal>();
|
||
var monthlyJobCount = new List<int>();
|
||
|
||
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<decimal>();
|
||
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<ApplicationUser>();
|
||
|
||
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<int>();
|
||
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<decimal>();
|
||
var monthlyCollected = new List<decimal>();
|
||
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<AgingBucketItem>
|
||
{
|
||
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<AgingBucketItem>
|
||
{
|
||
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<decimal>();
|
||
var monthlyDirectExpenses = new List<decimal>();
|
||
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<decimal>();
|
||
var monthlyPowderUsageCost = new List<decimal>();
|
||
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<string, (string DisplayName, List<double> 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<double>());
|
||
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<double>());
|
||
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());
|
||
}
|
||
}
|
||
|
||
/// <summary>Converts PascalCase enum names to spaced display strings (e.g. "NeedsMaintenance" → "Needs Maintenance").</summary>
|
||
private static string FormatEnum(string value) =>
|
||
System.Text.RegularExpressions.Regex.Replace(value, "([A-Z])", " $1").Trim();
|
||
|
||
// ── Financial Reports ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>; non-accounting plans redirect to Landing.
|
||
/// </summary>
|
||
// GET: /Reports/ProfitAndLoss
|
||
public async Task<IActionResult> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <c>ComputeBalance</c> 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
// GET: /Reports/BalanceSheet
|
||
public async Task<IActionResult> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
// GET: /Reports/ArAging
|
||
public async Task<IActionResult> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/> — all roles with CanViewReports can see sales totals.
|
||
/// </summary>
|
||
// GET: /Reports/SalesAndIncome
|
||
public async Task<IActionResult> 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 ──────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// PDF export of the Profit & Loss report. Duplicates the data-loading logic from
|
||
/// <see cref="ProfitAndLoss"/> because the two actions need identical data but different
|
||
/// return types (View vs File). The <paramref name="inline"/> 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
// GET: /Reports/ProfitAndLossPdf
|
||
public async Task<IActionResult> 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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// PDF export of the Balance Sheet. Same inline/attachment pattern as
|
||
/// <see cref="ProfitAndLossPdf"/>. Gated behind <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
// GET: /Reports/BalanceSheetPdf
|
||
public async Task<IActionResult> 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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// PDF export of the AR Aging report. Same inline/attachment pattern as
|
||
/// <see cref="ProfitAndLossPdf"/>. Gated behind <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
// GET: /Reports/ArAgingPdf
|
||
public async Task<IActionResult> 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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// PDF export of the Sales & Income report. Not gated by accounting (mirrors
|
||
/// <see cref="SalesAndIncome"/>). Same inline/attachment pattern as other PDF actions.
|
||
/// </summary>
|
||
// GET: /Reports/SalesAndIncomePdf
|
||
public async Task<IActionResult> 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 ──────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string>();
|
||
var monthlyRevenue = new List<decimal>();
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string>(); var monthlyRevenue = new List<decimal>(); var monthlyJobCount = new List<int>(); var avgOv = new List<decimal>();
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<ApplicationUser>();
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string>(); var newCustomersPerMonth = new List<int>();
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string>(); var monthlyInvoiced = new List<decimal>(); var monthlyCollected = new List<decimal>();
|
||
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<AgingBucketItem> { 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
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<AgingBucketItem> { 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<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
|
||
// 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<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string>(); var monthlyLbs = new List<decimal>(); var monthlyCost = new List<decimal>();
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
|
||
public async Task<IActionResult> 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) });
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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")
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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<string, (string DisplayName, List<double> 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<double>()); 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<double>()); 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 });
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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 });
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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) });
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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) });
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<IActionResult> 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 ─────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
public async Task<IActionResult> 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 ─────────────────────────────────────────────────
|
||
|
||
/// <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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
public async Task<IActionResult> 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 ───────────────────────────────────────────────
|
||
|
||
/// <summary>Entry point for the Cash Flow Forecast page. Gated behind <see cref="AllowAccounting"/>.</summary>
|
||
public IActionResult CashFlowForecast() => AllowAccounting() ? View() : RedirectToAction(nameof(Landing));
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
public async Task<IActionResult> 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 ────────────────────────────────────
|
||
|
||
/// <summary>Entry point for the Anomaly Detection page. Gated behind <see cref="AllowAccounting"/>.</summary>
|
||
public IActionResult AnomalyDetection() => AllowAccounting() ? View() : RedirectToAction(nameof(Landing));
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="AllowAccounting"/>.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||
public async Task<IActionResult> 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." });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
private async Task<string> 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<string> MonthLabels { get; set; } = new();
|
||
public List<decimal> MonthlyRevenue { get; set; } = new();
|
||
public List<int> MonthlyJobCount { get; set; } = new();
|
||
public Dictionary<string, decimal> RevenueByCustomerType { get; set; } = new();
|
||
public Dictionary<string, decimal> RevenueByPriority { get; set; } = new();
|
||
public List<decimal> AverageOrderValueTrend { get; set; } = new();
|
||
public List<TopCustomerItem> TopCustomers { get; set; } = new();
|
||
|
||
// Operations Analytics
|
||
public Dictionary<string, int> JobsByStatus { get; set; } = new();
|
||
public Dictionary<string, int> ActiveJobsByPriority { get; set; } = new();
|
||
public Dictionary<string, int> AppointmentsByType { get; set; } = new();
|
||
public Dictionary<string, int> AppointmentsByStatus { get; set; } = new();
|
||
public decimal AppointmentCompletionRate { get; set; }
|
||
public Dictionary<string, int> AppointmentsByDayOfWeek { get; set; } = new();
|
||
public List<WorkerStatsItem> WorkerStats { get; set; } = new();
|
||
public Dictionary<string, int> EquipmentByStatus { get; set; } = new();
|
||
public List<LowStockItem> LowStockItems { get; set; } = new();
|
||
|
||
// Customer Analytics
|
||
public List<int> NewCustomersPerMonth { get; set; } = new();
|
||
public decimal CustomerRetentionRate { get; set; }
|
||
public List<CustomerLifetimeValueItem> CustomerLifetimeValue { get; set; } = new();
|
||
public Dictionary<string, int> QuotesByStatus { get; set; } = new();
|
||
public QuoteConversionFunnel QuoteFunnel { get; set; } = new();
|
||
public Dictionary<string, int> 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<decimal> MonthlyInvoiced { get; set; } = new();
|
||
public List<decimal> MonthlyCollected { get; set; } = new();
|
||
public List<AgingBucketItem> AgingBuckets { get; set; } = new();
|
||
public List<RecentPaymentItem> RecentPayments { get; set; } = new();
|
||
public List<OutstandingCustomerItem> 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<AgingBucketItem> ApAgingBuckets { get; set; } = new();
|
||
public List<VendorSpendItem> VendorSpend { get; set; } = new();
|
||
public List<ExpenseByAccountItem> ExpensesByAccount { get; set; } = new();
|
||
public List<decimal> MonthlyBillsPaid { get; set; } = new();
|
||
public List<decimal> MonthlyDirectExpenses { get; set; } = new();
|
||
public List<decimal> PlRevenue { get; set; } = new();
|
||
public List<decimal> PlExpenses { get; set; } = new();
|
||
public List<decimal> PlNetIncome { get; set; } = new();
|
||
|
||
// Customer Retention
|
||
public List<CustomerRetentionItem> CustomerRetention { get; set; } = new();
|
||
|
||
// Job Cycle Time
|
||
public List<JobCycleTimeItem> JobCycleTime { get; set; } = new();
|
||
public double OverallAvgCycleDays { get; set; }
|
||
|
||
// Job Status Aging
|
||
public List<JobStatusAgingItem> JobStatusAging { get; set; } = new();
|
||
|
||
// Invoice Aging Detail
|
||
public List<InvoiceAgingDetailItem> InvoiceAgingDetail { get; set; } = new();
|
||
|
||
// Powder Consumption vs Purchase
|
||
public List<PowderConsumptionItem> PowderConsumption { get; set; } = new();
|
||
|
||
// Inventory Turnover
|
||
public List<InventoryTurnoverItem> InventoryTurnover { get; set; } = new();
|
||
|
||
// Sales by Customer
|
||
public List<SalesByCustomerItem> 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<PowderUsageByColorItem> TopColorsByUsage { get; set; } = new();
|
||
public List<decimal> MonthlyPowderUsageLbs { get; set; } = new();
|
||
public List<decimal> MonthlyPowderUsageCost { get; set; } = new();
|
||
|
||
// Filters
|
||
public int SelectedMonths { get; set; } = 6;
|
||
}
|
||
|