feat: Implement Day End Report feature logic
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DayEndReport;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports/day-end")]
|
||||||
|
public class DayEndReportController(DayEndReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Day([FromQuery] DateTime? date) =>
|
||||||
|
Ok(await service.GetDayAsync(date ?? DateTime.Now));
|
||||||
|
|
||||||
|
[HttpGet("daily")]
|
||||||
|
public async Task<IActionResult> Daily([FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||||
|
{
|
||||||
|
var f = DayEndReportService.BuildFilter(from, to);
|
||||||
|
var error = DayEndReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetDailyAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DayEndReport;
|
||||||
|
|
||||||
|
public record DayEndFilter(DateTime From, DateTime To);
|
||||||
|
|
||||||
|
public record DayEndRow(
|
||||||
|
string Date,
|
||||||
|
decimal TotalSales,
|
||||||
|
decimal Collected,
|
||||||
|
decimal Due,
|
||||||
|
decimal Cash,
|
||||||
|
decimal Cheque,
|
||||||
|
decimal Credit,
|
||||||
|
decimal Returns,
|
||||||
|
decimal Expenses,
|
||||||
|
decimal Income,
|
||||||
|
int InvoiceCount,
|
||||||
|
int PaymentCount,
|
||||||
|
decimal CashCollected,
|
||||||
|
decimal CashExpensesPaid,
|
||||||
|
decimal NetCash);
|
||||||
|
|
||||||
|
public record DayEndMethod(string Method, int Count, decimal Amount);
|
||||||
|
|
||||||
|
public record DayEndExpense(Guid Id, string Category, string? Payee, string? Description, string? Method, decimal Amount);
|
||||||
|
|
||||||
|
public record DayEndResponse(
|
||||||
|
string Date,
|
||||||
|
DayEndRow Summary,
|
||||||
|
List<DayEndMethod> ByMethod,
|
||||||
|
List<DayEndExpense> Expenses);
|
||||||
|
|
||||||
|
public record DayEndDailyResponse(DateTime From, DateTime To, List<DayEndRow> Days, DayEndRow Totals);
|
||||||
|
|
||||||
|
// Repository row shapes
|
||||||
|
public record DayEndInvoiceRow(Guid Id, DateTime FinalizedAt, decimal Total);
|
||||||
|
|
||||||
|
public record DayEndPaymentRow(Guid InvoiceId, PaymentMethod Method, decimal Amount, DateTime CollectedAt);
|
||||||
|
|
||||||
|
public record DayEndReturnRow(DateTime At, decimal Amount);
|
||||||
|
|
||||||
|
public record DayEndExpenseRow(
|
||||||
|
Guid Id, DateTime BillDate, DateTime? PaidAt, PaymentMethod? Method, decimal Amount,
|
||||||
|
string Category, string? Payee, string? Description);
|
||||||
|
|
||||||
|
public record DayEndData(
|
||||||
|
List<DayEndInvoiceRow> Invoices,
|
||||||
|
List<DayEndPaymentRow> Payments,
|
||||||
|
List<DayEndReturnRow> Returns,
|
||||||
|
List<DayEndExpenseRow> Expenses);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DayEndReport;
|
||||||
|
|
||||||
|
public class DayEndReportRepository(AppDBContext db) : IDayEndReportRepository
|
||||||
|
{
|
||||||
|
public async Task<DayEndData> GetDataAsync(DayEndFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
|
||||||
|
var invoices = await db.Invoices.AsNoTracking()
|
||||||
|
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||||
|
&& i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& i.FinalizedAt >= start && i.FinalizedAt < end)
|
||||||
|
.Select(i => new DayEndInvoiceRow(i.Id, i.FinalizedAt!.Value, i.TotalAmount))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var payments = await db.Payments.AsNoTracking()
|
||||||
|
.Where(p => p.IsActive && p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& ((p.CollectedAt >= start && p.CollectedAt < end)
|
||||||
|
|| (p.Invoice.Status == InvoiceStatus.Finalized
|
||||||
|
&& p.Invoice.FinalizedAt >= start && p.Invoice.FinalizedAt < end)))
|
||||||
|
.Select(p => new DayEndPaymentRow(p.FK_InvoiceId, p.Method, p.Amount, p.CollectedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var returns = await db.SaleReturns.AsNoTracking()
|
||||||
|
.Where(r => r.IsActive && r.CreatedAt >= start && r.CreatedAt < end)
|
||||||
|
.Select(r => new DayEndReturnRow(r.CreatedAt, r.TotalCreditAmount))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var expenses = await db.Expenses.AsNoTracking()
|
||||||
|
.Where(e => e.IsActive && (e.Status == ExpenseStatus.Posted || e.Status == ExpenseStatus.Paid)
|
||||||
|
&& ((e.BillDate >= start && e.BillDate < end)
|
||||||
|
|| (e.PaidAt >= start && e.PaidAt < end)))
|
||||||
|
.Select(e => new DayEndExpenseRow(
|
||||||
|
e.Id, e.BillDate, e.PaidAt, e.Method, e.Amount,
|
||||||
|
e.Category != null ? e.Category.Name : "",
|
||||||
|
e.Payee ?? (e.Vendor != null ? e.Vendor.Name : null),
|
||||||
|
e.Description))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new DayEndData(invoices, payments, returns, expenses);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DayEndReport;
|
||||||
|
|
||||||
|
public class DayEndReportService(IDayEndReportRepository repo)
|
||||||
|
{
|
||||||
|
public const int MaxRangeDays = 92;
|
||||||
|
|
||||||
|
public static DayEndFilter BuildFilter(DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
var end = (to ?? DateTime.Now).Date;
|
||||||
|
var start = (from ?? end.AddDays(-29)).Date;
|
||||||
|
return new DayEndFilter(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Validate(DayEndFilter f)
|
||||||
|
{
|
||||||
|
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||||
|
if ((f.To - f.From).TotalDays >= MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DayEndResponse> GetDayAsync(DateTime date)
|
||||||
|
{
|
||||||
|
var day = date.Date;
|
||||||
|
var data = await repo.GetDataAsync(new DayEndFilter(day, day));
|
||||||
|
var row = BuildRow(day, data);
|
||||||
|
|
||||||
|
var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList();
|
||||||
|
var byMethod = Enum.GetValues<PaymentMethod>().Select(m =>
|
||||||
|
{
|
||||||
|
var rows = payments.Where(p => p.Method == m).ToList();
|
||||||
|
return new DayEndMethod(m.ToString(), rows.Count, rows.Sum(p => p.Amount));
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var expenses = data.Expenses
|
||||||
|
.Where(e => e.BillDate.Date == day)
|
||||||
|
.Select(e => new DayEndExpense(e.Id, e.Category, e.Payee, e.Description, e.Method?.ToString(), e.Amount))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new DayEndResponse(row.Date, row, byMethod, expenses);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DayEndDailyResponse> GetDailyAsync(DayEndFilter f)
|
||||||
|
{
|
||||||
|
var data = await repo.GetDataAsync(f);
|
||||||
|
var days = new List<DayEndRow>();
|
||||||
|
for (var d = f.From.Date; d <= f.To.Date; d = d.AddDays(1))
|
||||||
|
days.Add(BuildRow(d, data));
|
||||||
|
|
||||||
|
var totals = new DayEndRow(
|
||||||
|
"Total",
|
||||||
|
days.Sum(r => r.TotalSales), days.Sum(r => r.Collected), days.Sum(r => r.Due),
|
||||||
|
days.Sum(r => r.Cash), days.Sum(r => r.Cheque), days.Sum(r => r.Credit),
|
||||||
|
days.Sum(r => r.Returns), days.Sum(r => r.Expenses), days.Sum(r => r.Income),
|
||||||
|
days.Sum(r => r.InvoiceCount), days.Sum(r => r.PaymentCount),
|
||||||
|
days.Sum(r => r.CashCollected), days.Sum(r => r.CashExpensesPaid), days.Sum(r => r.NetCash));
|
||||||
|
|
||||||
|
return new DayEndDailyResponse(f.From, f.To, days, totals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accrual basis: Income = sales (finalized invoices) - returns - expenses billed that day.
|
||||||
|
// Due is the unpaid part of that day's invoices as at the end of the day; Credit payments never count as paid.
|
||||||
|
private static DayEndRow BuildRow(DateTime day, DayEndData data)
|
||||||
|
{
|
||||||
|
var next = day.AddDays(1);
|
||||||
|
|
||||||
|
var invoices = data.Invoices.Where(i => i.FinalizedAt.Date == day).ToList();
|
||||||
|
var invoiceIds = invoices.Select(i => i.Id).ToHashSet();
|
||||||
|
var paidByInvoice = data.Payments
|
||||||
|
.Where(p => invoiceIds.Contains(p.InvoiceId) && p.Method != PaymentMethod.Credit && p.CollectedAt < next)
|
||||||
|
.GroupBy(p => p.InvoiceId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(p => p.Amount));
|
||||||
|
|
||||||
|
var totalSales = invoices.Sum(i => i.Total);
|
||||||
|
var due = invoices.Sum(i => Math.Max(0m, i.Total - paidByInvoice.GetValueOrDefault(i.Id)));
|
||||||
|
|
||||||
|
var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList();
|
||||||
|
var cash = payments.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount);
|
||||||
|
var cheque = payments.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount);
|
||||||
|
var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount);
|
||||||
|
|
||||||
|
var returns = data.Returns.Where(r => r.At.Date == day).Sum(r => r.Amount);
|
||||||
|
var expenses = data.Expenses.Where(e => e.BillDate.Date == day).Sum(e => e.Amount);
|
||||||
|
var cashExpensesPaid = data.Expenses
|
||||||
|
.Where(e => e.Method == PaymentMethod.Cash && e.PaidAt.HasValue && e.PaidAt.Value.Date == day)
|
||||||
|
.Sum(e => e.Amount);
|
||||||
|
|
||||||
|
return new DayEndRow(
|
||||||
|
day.ToString("yyyy-MM-dd"),
|
||||||
|
totalSales, cash + cheque, due, cash, cheque, credit,
|
||||||
|
returns, expenses, totalSales - returns - expenses,
|
||||||
|
invoices.Count, payments.Count,
|
||||||
|
cash, cashExpensesPaid, cash - cashExpensesPaid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Distribution.Features.DayEndReport;
|
||||||
|
|
||||||
|
public interface IDayEndReportRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Everything needed for [From, To] (whole days): invoices finalized, payments collected (plus payments on
|
||||||
|
/// those invoices, for the due amount), sale returns, and Posted/Paid expenses billed or paid in the range.
|
||||||
|
/// </summary>
|
||||||
|
Task<DayEndData> GetDataAsync(DayEndFilter f);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user