Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/ReportsController.cs
T
spouliot fde24b09c9 Phase F: Add Invoice Write-Off, Fixed Assets, Period Locking, and 1099 Tracking
- Invoice Write-Off: WriteOff POST action in InvoicesController posts bad-debt JE
  (DR bad debt expense / CR AR), reduces customer balance, marks invoice WrittenOff;
  write-off modal added to Invoice Details view with expense account selector
- Fixed Assets: FixedAsset + FixedAssetDepreciationEntry entities with straight-line
  depreciation; FixedAssetsController (Index/Create/Edit/Details/PostDepreciation/Delete);
  PostDepreciation auto-generates one JE per asset per period, skips already-posted,
  fully-depreciated, and disposed assets; full CRUD views + nav link
- Period Locking: Company.BookLockedThrough field; AccountingPeriodValidator static helper;
  lock check added to JE Post and Bill Create (blocks backdating into closed periods);
  SetPeriodLock action + date picker UI in Company Settings Accounting section
- 1099 Tracking: Is1099Vendor flag on Vendor entity + DTOs; checkbox in Create/Edit views;
  TaxReporting1099 report action + view lists payments by year, flags vendors >= $600;
  report card added to Reports Landing
- Migration AddFixedAssetsLockAnd1099 applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:19:32 -04:00

2322 lines
137 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&amp;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 (030 days)", Amount = 0, Count = 0 },
new AgingBucketItem { Label = "3160 days", Amount = 0, Count = 0 },
new AgingBucketItem { Label = "6190 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 (030 days)", Amount = 0, Count = 0 },
new() { Label = "3160 days", Amount = 0, Count = 0 },
new() { Label = "6190 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 ? "130 Days"
: daysOverdue <= 60 ? "3160 Days"
: daysOverdue <= 90 ? "6190 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 &amp; 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 / 130 / 3160 / 6190 / 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 &amp; 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 &amp; 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 &amp; 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");
}
/// <summary>
/// Sales Tax Liability report (invoice basis). Shows taxable vs non-taxable sales,
/// total tax billed, breakdown by tax account and by month, and a full invoice detail grid.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/SalesTax
public async Task<IActionResult> SalesTax(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.GetSalesTaxReportAsync(companyId, fromDate, toDate);
return View(dto);
}
/// <summary>
/// PDF export of the Sales Tax Liability report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/SalesTaxPdf
public async Task<IActionResult> SalesTaxPdf(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.GetSalesTaxReportAsync(companyId, fromDate, toDate);
var pdfBytes = await _pdfService.GenerateSalesTaxReportPdfAsync(dto);
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
/// <summary>
/// CSV export of the Sales Tax Liability report. Returns one row per invoice, suitable
/// for handing to an accountant or importing into tax filing software.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/SalesTaxCsv
public async Task<IActionResult> SalesTaxCsv(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.GetSalesTaxReportAsync(companyId, fromDate, toDate);
var sb = new System.Text.StringBuilder();
sb.AppendLine("Invoice #,Customer,Date,Status,Subtotal,Tax %,Tax Amount,Total,Amount Paid,Balance Due,Tax Account");
foreach (var inv in dto.Invoices)
{
sb.AppendLine(string.Join(",",
$"\"{inv.InvoiceNumber}\"",
$"\"{inv.CustomerName.Replace("\"", "\"\"")}\"",
inv.InvoiceDate.ToString("yyyy-MM-dd"),
$"\"{inv.Status}\"",
inv.SubTotal.ToString("F2"),
inv.TaxPercent.ToString("F4"),
inv.TaxAmount.ToString("F2"),
inv.Total.ToString("F2"),
inv.AmountPaid.ToString("F2"),
inv.BalanceDue.ToString("F2"),
$"\"{inv.TaxAccountName.Replace("\"", "\"\"")}\""));
}
var bytes = System.Text.Encoding.UTF8.GetPreamble().Concat(System.Text.Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
return File(bytes, "text/csv", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.csv");
}
/// <summary>
/// Accounts Payable Aging report — mirrors the AR Aging but groups open bills by vendor
/// and buckets them by days past due date. Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/ApAging
public async Task<IActionResult> ApAging(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.GetApAgingAsync(companyId, asOfDate);
return View(dto);
}
/// <summary>
/// PDF export of the AP Aging report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/ApAgingPdf
public async Task<IActionResult> ApAgingPdf(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.GetApAgingAsync(companyId, asOfDate);
var pdfBytes = await _pdfService.GenerateApAgingPdfAsync(dto);
return inline
? File(pdfBytes, "application/pdf")
: File(pdfBytes, "application/pdf", $"AP-Aging-{asOfDate:yyyyMMdd}.pdf");
}
/// <summary>
/// Trial Balance report — lists all active accounts with debit and credit balances using
/// <c>Account.CurrentBalance</c> (live, not point-in-time). Validates that debits equal
/// credits. Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/TrialBalance
public async Task<IActionResult> TrialBalance(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.GetTrialBalanceAsync(companyId, asOfDate);
return View(dto);
}
/// <summary>
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/TrialBalancePdf
public async Task<IActionResult> TrialBalancePdf(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.GetTrialBalanceAsync(companyId, asOfDate);
var pdfBytes = await _pdfService.GenerateTrialBalancePdfAsync(dto);
return inline
? File(pdfBytes, "application/pdf")
: File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf");
}
/// <summary>
/// Cash Flow Statement — shows cash receipts from customers, cash payments to vendors and
/// for direct expenses, and a summary of beginning/ending cash position. Uses the direct
/// (cash-basis) method for operating activities so the numbers reflect actual cash movement
/// regardless of the company's accrual vs cash accounting preference.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/CashFlowStatement
public async Task<IActionResult> CashFlowStatement(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.GetCashFlowStatementAsync(companyId, fromDate, toDate);
return View(dto);
}
/// <summary>
/// PDF export of the Cash Flow Statement. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/CashFlowStatementPdf
public async Task<IActionResult> CashFlowStatementPdf(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.GetCashFlowStatementAsync(companyId, fromDate, toDate);
var pdfBytes = await _pdfService.GenerateCashFlowStatementPdfAsync(dto);
return inline
? File(pdfBytes, "application/pdf")
: File(pdfBytes, "application/pdf", $"CashFlow-{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 (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 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 &amp; 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 (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 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, &gt;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 &gt;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 ? "130 Days" : daysOverdue <= 60 ? "3160 Days" : daysOverdue <= 90 ? "6190 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, &lt;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." });
}
}
// GET: /Reports/TaxReporting1099
/// <summary>
/// 1099-NEC report: sums all bill payments + expenses paid to vendors marked Is1099Vendor=true
/// for the selected calendar year. Flags vendors that exceed the $600 reporting threshold.
/// </summary>
public async Task<IActionResult> TaxReporting1099(int? year)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _rcid) ? _rcid : 0;
var reportYear = year ?? DateTime.Now.Year;
var periodStart = new DateTime(reportYear, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
// Load 1099-eligible vendors
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList();
var rows = new List<Vendor1099Row>();
foreach (var vendor in vendors)
{
// Sum bills paid (using bill payment records) within the year
var bills = await _unitOfWork.Bills.FindAsync(
b => b.VendorId == vendor.Id,
false,
b => b.Payments);
decimal billPaid = bills
.SelectMany(b => b.Payments)
.Where(p => p.PaymentDate >= periodStart && p.PaymentDate <= periodEnd)
.Sum(p => p.Amount);
// Sum direct expenses for this vendor within the year
var expenses = await _unitOfWork.Expenses.FindAsync(
e => e.VendorId == vendor.Id && e.Date >= periodStart && e.Date <= periodEnd);
decimal expensePaid = expenses.Sum(e => e.Amount);
var total = billPaid + expensePaid;
rows.Add(new Vendor1099Row
{
VendorId = vendor.Id,
VendorName = vendor.CompanyName,
TaxId = vendor.TaxId,
Address = string.Join(", ", new[] { vendor.Address, vendor.City, vendor.State, vendor.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s))),
BillsPaid = billPaid,
ExpensesPaid = expensePaid,
TotalPaid = total,
NeedsForm = total >= 600m
});
}
ViewBag.ReportYear = reportYear;
ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 5, 6).OrderDescending().ToList();
ViewBag.VendorsOver600 = rows.Count(r => r.NeedsForm);
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
}
/// <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;
}
public class Vendor1099Row
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public string? TaxId { get; set; }
public string? Address { get; set; }
public decimal BillsPaid { get; set; }
public decimal ExpensesPaid { get; set; }
public decimal TotalPaid { get; set; }
public bool NeedsForm { get; set; }
}