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:
@@ -1,14 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements financial aggregate reports using direct DbContext access with AsNoTracking.
|
||||
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
|
||||
/// converted during Phase 2/3 of the data-access architecture migration.
|
||||
/// Implements financial aggregate reports (P&L, Balance Sheet, AR Aging, Sales & Income)
|
||||
/// using direct DbContext access with AsNoTracking. Migrated from inline queries in
|
||||
/// ReportsController as part of Phase 2 of the data-access architecture migration.
|
||||
/// The four report types each have matching PDF export paths in the controller that
|
||||
/// share the same data by calling these methods, eliminating the previous duplication.
|
||||
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
|
||||
/// </summary>
|
||||
public class FinancialReportService : IFinancialReportService
|
||||
@@ -21,19 +25,415 @@ public class FinancialReportService : IFinancialReportService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Implemented — migrated from <c>ReportsController.ProfitAndLoss</c> in Phase 2.</remarks>
|
||||
public Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
|
||||
=> throw new NotImplementedException("Migrate from ReportsController.ProfitAndLoss — Phase 2.");
|
||||
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
// 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 >= from && 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 >= from && 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: direct Expenses + BillLineItems merged per account
|
||||
var directByAccount = await _context.Expenses
|
||||
.Where(e => e.Date >= from && 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 >= from && 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);
|
||||
}
|
||||
|
||||
return new ProfitAndLossDto
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
|
||||
=> throw new NotImplementedException("Migrate from ReportsController.BalanceSheet — Phase 2.");
|
||||
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// Retained earnings = net P&L from inception through asOf
|
||||
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)
|
||||
.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();
|
||||
|
||||
// Standard double-entry: assets have normal debit balance; liabilities+equity have normal credit balance.
|
||||
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 <= asOf)
|
||||
? 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;
|
||||
|
||||
return new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
CurrentAssets = currentAssets,
|
||||
FixedAssets = fixedAssets,
|
||||
OtherAssets = otherAssets,
|
||||
TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities,
|
||||
LongTermLiabilities = longTermLiabilities,
|
||||
TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines,
|
||||
RetainedEarnings = retainedEarnings,
|
||||
TotalEquity = totalEquity,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf)
|
||||
=> throw new NotImplementedException("Migrate from ReportsController.ArAging — Phase 2.");
|
||||
public async Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
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();
|
||||
|
||||
static string AgingBucket(int d) => d switch
|
||||
{
|
||||
<= 0 => "current",
|
||||
<= 30 => "1-30",
|
||||
<= 60 => "31-60",
|
||||
<= 90 => "61-90",
|
||||
_ => "90+"
|
||||
};
|
||||
|
||||
var customerDtos = new List<ArAgingCustomerDto>();
|
||||
|
||||
foreach (var grp in openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial }))
|
||||
{
|
||||
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)(asOf - 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;
|
||||
}
|
||||
}
|
||||
|
||||
customerDtos.Add(custDto);
|
||||
}
|
||||
|
||||
var sorted = customerDtos.OrderByDescending(c => c.TotalBalance).ToList();
|
||||
|
||||
return new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
Customers = sorted,
|
||||
TotalCurrent = sorted.Sum(c => c.TotalCurrent),
|
||||
Total1to30 = sorted.Sum(c => c.Total1to30),
|
||||
Total31to60 = sorted.Sum(c => c.Total31to60),
|
||||
Total61to90 = sorted.Sum(c => c.Total61to90),
|
||||
TotalOver90 = sorted.Sum(c => c.TotalOver90),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
|
||||
=> throw new NotImplementedException("Migrate from ReportsController.SalesAndIncome — Phase 2.");
|
||||
public async Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
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 >= from && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= from && 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();
|
||||
|
||||
return new SalesIncomeReportDto
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
||||
/// Falls back to "Your Company" if the record is not found.
|
||||
/// </summary>
|
||||
private async Task<string> GetCompanyNameAsync(int companyId)
|
||||
{
|
||||
if (companyId <= 0) return "Your Company";
|
||||
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
|
||||
return company?.CompanyName ?? "Your Company";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user