Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers, routing all data access through IUnitOfWork. Added IPlainRepository<T> for the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote) that intentionally don't extend BaseEntity and therefore can't use the constrained IRepository<T>. Added permanent-exception comments to the 18 controllers that legitimately retain direct DbContext access (Identity infra, cross-tenant platform ops, bulk streaming exports). Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup gate that reflects over every Controller subclass and throws at boot if any non-exempt controller injects ApplicationDbContext. The app cannot start with a violation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Reports;
|
||||
|
||||
@@ -20,17 +19,19 @@ public class ReportsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReportsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
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, ApplicationDbContext context, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, 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;
|
||||
_context = context;
|
||||
_financialReports = financialReports;
|
||||
_operationalReports = operationalReports;
|
||||
_pdfService = pdfService;
|
||||
_userManager = userManager;
|
||||
_accountingAi = accountingAi;
|
||||
@@ -493,11 +494,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// === EXPENSE / AP ANALYTICS ===
|
||||
var allBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
|
||||
var totalBilled = allBills.Sum(b => b.Total);
|
||||
var totalBillsPaid = allBills.Sum(b => b.AmountPaid);
|
||||
@@ -536,10 +533,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// Expenses by account
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var expensesByAccount = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
@@ -664,10 +658,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// === JOB CYCLE TIME ===
|
||||
var allStatusHistory = await _context.JobStatusHistory
|
||||
.Include(h => h.FromStatus)
|
||||
.Include(h => h.ToStatus)
|
||||
.ToListAsync();
|
||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||
|
||||
var historyByJob = allStatusHistory
|
||||
.GroupBy(h => h.JobId)
|
||||
@@ -1002,102 +993,10 @@ public class ReportsController : Controller
|
||||
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 toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
// ── Revenue: InvoiceItems posted to revenue accounts ──────────────────
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
// Unlinked invoice totals (items without a revenue account) → lump into a default "Sales" bucket
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts
|
||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine
|
||||
{
|
||||
AccountId = r.AccountId,
|
||||
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
|
||||
AccountName = revenueAccounts[r.AccountId].Name,
|
||||
Amount = r.Amount
|
||||
})
|
||||
.OrderBy(l => l.AccountNumber)
|
||||
.ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
// ── COGS & Expenses: Expenses + BillLineItems by account type ─────────
|
||||
// Direct expenses
|
||||
var directByAccount = await _context.Expenses
|
||||
.Where(e => e.Date >= fromDate && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
// Bill line items (skip lines with no account — QB-imported without account mapping)
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
// Merge the two expense sources per account
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts
|
||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>();
|
||||
var expenseLines = new List<FinancialReportLine>();
|
||||
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
|
||||
else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
var dto = new ProfitAndLossDto
|
||||
{
|
||||
From = fromDate,
|
||||
To = toDate,
|
||||
CompanyName = companyName,
|
||||
RevenueLines = revenueLines,
|
||||
TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines,
|
||||
TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines,
|
||||
TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1116,157 +1015,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> BalanceSheet(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
// ── Pre-compute balance contributions per account (batch queries) ──────
|
||||
|
||||
// Asset: payments deposited INTO account (DEBIT) — exclude voided/written-off invoices
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Asset: expenses paid FROM account (CREDIT)
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Asset: bill payments FROM account (CREDIT)
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: bills posted to AP account (CREDIT)
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: bill payments reducing AP (DEBIT)
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: sales tax payable (CREDIT)
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// AR total (used for AR sub-type accounts)
|
||||
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// ── Retained earnings = net P&L from inception ────────────────────────
|
||||
var lifetimeRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
// ── Compute balance for each account ──────────────────────────────────
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
debits = arDebits; credits = arCredits;
|
||||
}
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate)
|
||||
? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new()
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
Amount = ComputeBalance(a)
|
||||
};
|
||||
|
||||
// Load accounts by type
|
||||
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset)
|
||||
.Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.FixedAsset)
|
||||
.Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.OtherAsset)
|
||||
.Select(ToLine).ToList();
|
||||
|
||||
var currentLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability)
|
||||
.Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.LongTermLiability)
|
||||
.Select(ToLine).ToList();
|
||||
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
var dto = new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOfDate,
|
||||
CompanyName = companyName,
|
||||
CurrentAssets = currentAssets,
|
||||
FixedAssets = fixedAssets,
|
||||
OtherAssets = otherAssets,
|
||||
TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities,
|
||||
LongTermLiabilities = longTermLiabilities,
|
||||
TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines,
|
||||
RetainedEarnings = retainedEarnings,
|
||||
TotalEquity = totalEquity,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1282,85 +1033,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ArAging(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName)
|
||||
.ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
var customerGroups = openInvoices
|
||||
.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
|
||||
|
||||
static string AgingBucket(int daysOverdue) => daysOverdue switch
|
||||
{
|
||||
<= 0 => "current",
|
||||
<= 30 => "1-30",
|
||||
<= 60 => "31-60",
|
||||
<= 90 => "61-90",
|
||||
_ => "90+"
|
||||
};
|
||||
|
||||
var customers = new List<ArAgingCustomerDto>();
|
||||
|
||||
foreach (var grp in customerGroups)
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial
|
||||
? grp.Key.CompanyName
|
||||
: $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
var bucket = AgingBucket(daysOverdue);
|
||||
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto
|
||||
{
|
||||
InvoiceId = inv.Id,
|
||||
InvoiceNumber = inv.InvoiceNumber,
|
||||
InvoiceDate = inv.InvoiceDate,
|
||||
DueDate = inv.DueDate,
|
||||
BalanceDue = balance,
|
||||
DaysOverdue = daysOverdue
|
||||
});
|
||||
|
||||
switch (bucket)
|
||||
{
|
||||
case "current": custDto.TotalCurrent += balance; break;
|
||||
case "1-30": custDto.Total1to30 += balance; break;
|
||||
case "31-60": custDto.Total31to60 += balance; break;
|
||||
case "61-90": custDto.Total61to90 += balance; break;
|
||||
default: custDto.TotalOver90 += balance; break;
|
||||
}
|
||||
}
|
||||
|
||||
customers.Add(custDto);
|
||||
}
|
||||
|
||||
var dto = new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOfDate,
|
||||
CompanyName = companyName,
|
||||
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
|
||||
TotalCurrent = customers.Sum(c => c.TotalCurrent),
|
||||
Total1to30 = customers.Sum(c => c.Total1to30),
|
||||
Total31to60 = customers.Sum(c => c.Total31to60),
|
||||
Total61to90 = customers.Sum(c => c.Total61to90),
|
||||
TotalOver90 = customers.Sum(c => c.TotalOver90),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1375,90 +1050,10 @@ public class ReportsController : Controller
|
||||
// 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 toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
// Payments collected within the period (may differ from invoice dates)
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// By customer
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial
|
||||
? i.Customer.CompanyName
|
||||
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
|
||||
.Select(g => new SalesByCustomerDto
|
||||
{
|
||||
CustomerId = g.Key.CustomerId,
|
||||
CustomerName = g.Key.Name,
|
||||
InvoiceCount = g.Count(),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalPaid = g.Sum(i => i.AmountPaid),
|
||||
BalanceDue = g.Sum(i => i.BalanceDue),
|
||||
})
|
||||
.OrderByDescending(c => c.TotalInvoiced)
|
||||
.ToList();
|
||||
|
||||
// By month
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto
|
||||
{
|
||||
Year = g.Key.Year,
|
||||
Month = g.Key.Month,
|
||||
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalCollected = g.Sum(i => i.AmountPaid),
|
||||
InvoiceCount = g.Count(),
|
||||
})
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||
.ToList();
|
||||
|
||||
// Invoice detail lines
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate,
|
||||
DueDate = i.DueDate,
|
||||
Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal,
|
||||
TaxAmount = i.TaxAmount,
|
||||
Total = i.Total,
|
||||
AmountPaid = i.AmountPaid,
|
||||
BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
var dto = new SalesIncomeReportDto
|
||||
{
|
||||
From = fromDate,
|
||||
To = toDate,
|
||||
CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total),
|
||||
TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount),
|
||||
TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count,
|
||||
CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer,
|
||||
ByMonth = byMonth,
|
||||
Invoices = invoiceLines,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1476,64 +1071,10 @@ public class ReportsController : Controller
|
||||
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 toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts.Where(a => a.AccountType == AccountType.Revenue && a.IsActive).ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine { AccountId = r.AccountId, AccountNumber = revenueAccounts[r.AccountId].AccountNumber, AccountName = revenueAccounts[r.AccountId].Name, Amount = r.Amount })
|
||||
.OrderBy(l => l.AccountNumber).ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
var directByAccount = await _context.Expenses.Where(e => e.Date >= fromDate && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId).Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }).ToListAsync();
|
||||
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value).Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }).ToListAsync();
|
||||
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount) expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive).ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>(); var expenseLines = new List<FinancialReportLine>();
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
var dto = new ProfitAndLossDto
|
||||
{
|
||||
From = fromDate, To = toDate, CompanyName = companyName,
|
||||
RevenueLines = revenueLines, TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines, TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines, TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1546,75 +1087,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> BalanceSheetPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var depositsByAcct = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null)
|
||||
.GroupBy(p => p.DepositAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var expFromByAcct = await _context.Expenses.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpFromByAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var billsByApAcct = await _context.Bills.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpByApAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var taxByAcct = await _context.Invoices.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var lifetimeRevenue = await _context.InvoiceItems.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd).SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
|
||||
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arDebits; credits = arCredits; }
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); }
|
||||
else { debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); }
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate) ? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new() { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, Amount = ComputeBalance(a) };
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
|
||||
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
var dto = new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOfDate, CompanyName = companyName,
|
||||
CurrentAssets = currentAssets, FixedAssets = fixedAssets, OtherAssets = otherAssets, TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities, LongTermLiabilities = longTermLiabilities, TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines, RetainedEarnings = retainedEarnings, TotalEquity = totalEquity,
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1627,46 +1102,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ArAgingPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid && i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName).ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
var customerGroups = openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
|
||||
|
||||
static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" };
|
||||
|
||||
var customers = new List<ArAgingCustomerDto>();
|
||||
foreach (var grp in customerGroups)
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial ? grp.Key.CompanyName : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto { InvoiceId = inv.Id, InvoiceNumber = inv.InvoiceNumber, InvoiceDate = inv.InvoiceDate, DueDate = inv.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue });
|
||||
switch (AgingBucket(daysOverdue)) { case "current": custDto.TotalCurrent += balance; break; case "1-30": custDto.Total1to30 += balance; break; case "31-60": custDto.Total31to60 += balance; break; case "61-90": custDto.Total61to90 += balance; break; default: custDto.TotalOver90 += balance; break; }
|
||||
}
|
||||
customers.Add(custDto);
|
||||
}
|
||||
|
||||
var dto = new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOfDate, CompanyName = companyName,
|
||||
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
|
||||
TotalCurrent = customers.Sum(c => c.TotalCurrent), Total1to30 = customers.Sum(c => c.Total1to30),
|
||||
Total31to60 = customers.Sum(c => c.Total31to60), Total61to90 = customers.Sum(c => c.Total61to90),
|
||||
TotalOver90 = customers.Sum(c => c.TotalOver90),
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1678,48 +1116,10 @@ public class ReportsController : Controller
|
||||
// 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 toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer).Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate).ToListAsync();
|
||||
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
|
||||
.Select(g => new SalesByCustomerDto { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), BalanceDue = g.Sum(i => i.BalanceDue) })
|
||||
.OrderByDescending(c => c.TotalInvoiced).ToList();
|
||||
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TotalInvoiced = g.Sum(i => i.Total), TotalCollected = g.Sum(i => i.AmountPaid), InvoiceCount = g.Count() })
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month).ToList();
|
||||
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
var dto = new SalesIncomeReportDto
|
||||
{
|
||||
From = fromDate, To = toDate, CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total), TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount), TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count, CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer, ByMonth = byMonth, Invoices = invoiceLines,
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1996,8 +1396,8 @@ public class ReportsController : Controller
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateTime.Today;
|
||||
var allBills = await _context.Bills.Include(b => b.Vendor).Include(b => b.Payments.Where(p => !p.IsDeleted)).Where(b => !b.IsDeleted && b.Status != BillStatus.Voided).ToListAsync();
|
||||
var allExpenses = await _context.Expenses.Include(e => e.ExpenseAccount).Where(e => !e.IsDeleted).ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList();
|
||||
var apAgingBuckets = new List<AgingBucketItem> { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
|
||||
@@ -2116,7 +1516,7 @@ public class ReportsController : Controller
|
||||
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 _context.JobStatusHistory.Include(h => h.FromStatus).Include(h => h.ToStatus).ToListAsync();
|
||||
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)>();
|
||||
@@ -2293,10 +1693,7 @@ public class ReportsController : Controller
|
||||
.Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30);
|
||||
|
||||
// Expenses by account
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
var expensesByCategory = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
.GroupBy(e => e.ExpenseAccount!.Name)
|
||||
@@ -2306,7 +1703,7 @@ public class ReportsController : Controller
|
||||
var totalExpenses = allExpenses.Sum(e => e.Amount);
|
||||
|
||||
// Also include bills paid as expenses
|
||||
var allBills = await _context.Bills.Where(b => !b.IsDeleted).ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var billsPaid = allBills.Sum(b => b.AmountPaid);
|
||||
totalExpenses += billsPaid;
|
||||
|
||||
@@ -2400,11 +1797,9 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Open AP bills
|
||||
var openBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.AmountPaid < b.Total
|
||||
&& b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
var openBills = (await _operationalReports.GetActiveBillsAsync())
|
||||
.Where(b => b.AmountPaid < b.Total)
|
||||
.ToList();
|
||||
|
||||
var apItems = openBills.Select(b => new CashFlowApItem
|
||||
{
|
||||
@@ -2415,13 +1810,11 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Active job pipeline (non-terminal jobs not yet invoiced)
|
||||
var activeJobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
|
||||
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)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var jobItems = activeJobs.Select(j => new CashFlowJobItem
|
||||
{
|
||||
@@ -2484,12 +1877,9 @@ public class ReportsController : Controller
|
||||
var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var startOfLastMonth = startOfThisMonth.AddMonths(-1);
|
||||
|
||||
// Recent bills (last 90 days)
|
||||
var recentBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.BillDate >= ninetyDaysAgo)
|
||||
.OrderByDescending(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
// 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
|
||||
{
|
||||
@@ -2501,12 +1891,6 @@ public class ReportsController : Controller
|
||||
VendorInvoiceNumber = b.VendorInvoiceNumber
|
||||
}).ToList();
|
||||
|
||||
// Vendor history (all time, for averages)
|
||||
var allBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
|
||||
var vendorHistory = allBills
|
||||
.Where(b => b.Vendor != null)
|
||||
.GroupBy(b => b.Vendor!.CompanyName)
|
||||
@@ -2525,10 +1909,7 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Account spend trends (bills + expenses by account this month vs historical avg)
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var accountTrends = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
@@ -2581,7 +1962,7 @@ public class ReportsController : Controller
|
||||
var companyIdClaim = User.FindFirst("CompanyId")?.Value;
|
||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||
{
|
||||
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
return company?.CompanyName ?? "Your Company";
|
||||
}
|
||||
return "Your Company";
|
||||
|
||||
Reference in New Issue
Block a user