feat: enhance dashboard report with receivables and discounts calculations

This commit is contained in:
2026-10-22 10:37:56 +05:30
parent d79c5e55d0
commit 872fd42c4a
4 changed files with 93 additions and 22 deletions
@@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId); public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales); public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesKpis( public record SalesKpis(
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue, decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossProfit, decimal? GrossProfitMarginPercent); decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo); public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
public record TrendPoint(string Period, decimal Sales, decimal Returns); public record TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount); public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse( public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity, DateTime From, DateTime To, string Granularity,
SalesKpiComparison Kpis, List<TrendPoint> Trend, SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep); List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount); public record PeriodAmount(DateTime Date, decimal Amount);
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
public Guid RepId { get; set; } public Guid RepId { get; set; }
} }
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs // Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day). // Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
private IQueryable<SoldLine> SoldLines(DashboardFilter f) private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{ {
var start = f.From.Date; var start = f.From.Date;
var end = f.To.Date.AddDays(1); var end = f.To.Date.AddDays(1);
var q = var q =
from li in db.ShopPOItems.AsNoTracking() from li in db.InvoiceItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId where li.IsActive && !li.IsRemoved
where inv.Status == InvoiceStatus.Finalized && li.Invoice.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled && li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& inv.FinalizedAt >= start && inv.FinalizedAt < end && li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
select new SoldLine select new SoldLine
{ {
Date = inv.FinalizedAt!.Value.Date, Date = li.Invoice.FinalizedAt!.Value.Date,
PoId = li.FK_ShopPOId, PoId = li.Invoice.FK_ShopPOId,
Value = li.QuantityFulfilled * li.UnitPrice, Value = li.Quantity * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice, Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId, CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name, CategoryName = li.Item.Category.Name,
RegionId = li.ShopPO.Store.Route.FK_RegionId, RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
RegionName = li.ShopPO.Store.Route.Region.Name, RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
RepId = li.ShopPO.RaisedById, RepId = li.Invoice.ShopPO.RaisedById,
}; };
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value); if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value); if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
{ {
var start = f.From.Date; var start = f.From.Date;
var end = f.To.Date.AddDays(1); var end = f.To.Date.AddDays(1);
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
var q = db.SaleReturnItems.AsNoTracking() var q = db.SaleReturnItems.AsNoTracking()
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end); .Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value); if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value); if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q; return q;
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m; var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 0m; var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m; var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
return new SalesTotals(gross, returns, orders, costedSales, cost); return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
}
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
{
if (f.CategoryId.HasValue) return 0m;
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.Invoices.AsNoTracking()
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
&& i.FinalizedAt >= start && i.FinalizedAt < end);
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
}
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
private const int OverdueDays = 30;
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
if (f.RegionId.HasValue)
{
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
}
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
var rows = await invs.Select(i => new
{
i.FinalizedAt,
i.TotalAmount,
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
.Sum(p => (decimal?)p.Amount) ?? 0m,
}).ToListAsync();
var balances = rows
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
.ToList();
return new ReceivablesTotals(collected,
balances.Sum(b => b.Balance),
balances.Where(b => b.Due < end).Sum(b => b.Balance));
} }
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) => public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo) public class DashboardReportService(IDashboardReportRepository repo)
{ {
public const int MaxRangeDays = 366; public const int MaxRangeDays = 366;
private const int DailyGranularityMaxDays = 62; private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId) public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
{ {
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
private static SalesKpis ToKpis(SalesTotals t) private static SalesKpis ToKpis(SalesTotals t)
{ {
var net = t.GrossSales - t.ReturnValue; var net = t.GrossSales - t.ReturnValue - t.Discounts;
var gp = t.CostedSales - t.CostOfSales; var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis( return new SalesKpis(
t.GrossSales, t.ReturnValue, net, t.Orders, t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m, t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null); gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
} }
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
new(r.Collected, r.Outstanding, r.Overdue,
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f) public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
{ {
var days = (f.To - f.From).Days + 1; var days = (f.To - f.From).Days + 1;
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
var current = await repo.GetSalesTotalsAsync(f); var current = await repo.GetSalesTotalsAsync(f);
var previous = await repo.GetSalesTotalsAsync(prev); var previous = await repo.GetSalesTotalsAsync(prev);
var currentCash = await repo.GetReceivablesAsync(f);
var previousCash = await repo.GetReceivablesAsync(prev);
var sales = await repo.GetDailySalesAsync(f); var sales = await repo.GetDailySalesAsync(f);
var returns = await repo.GetDailyReturnsAsync(f); var returns = await repo.GetDailyReturnsAsync(f);
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
return new SalesSummaryResponse( return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month", f.From, f.To, daily ? "day" : "month",
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To), new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
new CashKpiComparison(
ToCash(currentCash, current.GrossSales - current.Discounts),
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
trend, trend,
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(), (await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(), (await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
@@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount);
public interface IDashboardReportRepository public interface IDashboardReportRepository
{ {
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f); Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f); Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f); Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f); Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);