From 872fd42c4aaa02515f13fe403aaac39add5faa5f Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 22 Oct 2026 10:37:56 +0530 Subject: [PATCH] feat: enhance dashboard report with receivables and discounts calculations --- .../DashboardReport/DashboardReportDTOs.cs | 12 ++- .../DashboardReportRepository.cs | 87 +++++++++++++++---- .../DashboardReport/DashboardReportService.cs | 15 +++- .../IDashboardReportRepository.cs | 1 + 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/Distribution/Features/DashboardReport/DashboardReportDTOs.cs b/Distribution/Features/DashboardReport/DashboardReportDTOs.cs index 5b034f1..95000e7 100644 --- a/Distribution/Features/DashboardReport/DashboardReportDTOs.cs +++ b/Distribution/Features/DashboardReport/DashboardReportDTOs.cs @@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport; 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( - 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); 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 NamedAmount(Guid? Id, string Name, decimal Amount); public record SalesSummaryResponse( DateTime From, DateTime To, string Granularity, - SalesKpiComparison Kpis, List Trend, + SalesKpiComparison Kpis, CashKpiComparison Cash, List Trend, List ByCategory, List ByRegion, List ByRep); public record PeriodAmount(DateTime Date, decimal Amount); diff --git a/Distribution/Features/DashboardReport/DashboardReportRepository.cs b/Distribution/Features/DashboardReport/DashboardReportRepository.cs index 1b2bb32..cb43cd7 100644 --- a/Distribution/Features/DashboardReport/DashboardReportRepository.cs +++ b/Distribution/Features/DashboardReport/DashboardReportRepository.cs @@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi public Guid RepId { get; set; } } - // Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs - // whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day). + // Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of + // 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 SoldLines(DashboardFilter f) { var start = f.From.Date; var end = f.To.Date.AddDays(1); var q = - from li in db.ShopPOItems.AsNoTracking() - join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId - where inv.Status == InvoiceStatus.Finalized - && li.ShopPO.Status != ShopPOStatus.Cancelled - && inv.FinalizedAt >= start && inv.FinalizedAt < end + from li in db.InvoiceItems.AsNoTracking() + where li.IsActive && !li.IsRemoved + && li.Invoice.Status == InvoiceStatus.Finalized + && li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled + && li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end select new SoldLine { - Date = inv.FinalizedAt!.Value.Date, - PoId = li.FK_ShopPOId, - Value = li.QuantityFulfilled * li.UnitPrice, - Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice, + Date = li.Invoice.FinalizedAt!.Value.Date, + PoId = li.Invoice.FK_ShopPOId, + Value = li.Quantity * li.UnitPrice, + Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice, CategoryId = li.Item.FK_CategoryId, CategoryName = li.Item.Category.Name, - RegionId = li.ShopPO.Store.Route.FK_RegionId, - RegionName = li.ShopPO.Store.Route.Region.Name, - RepId = li.ShopPO.RaisedById, + RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId, + RegionName = li.Invoice.ShopPO.Store.Route.Region.Name, + RepId = li.Invoice.ShopPO.RaisedById, }; 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); @@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi { var start = f.From.Date; 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() - .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.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value); return q; @@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m; var cost = await costed.SumAsync(l => l.Cost) ?? 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 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 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> GetDailySalesAsync(DashboardFilter f) => diff --git a/Distribution/Features/DashboardReport/DashboardReportService.cs b/Distribution/Features/DashboardReport/DashboardReportService.cs index e393d48..27b3c93 100644 --- a/Distribution/Features/DashboardReport/DashboardReportService.cs +++ b/Distribution/Features/DashboardReport/DashboardReportService.cs @@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport; public class DashboardReportService(IDashboardReportRepository repo) { 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) { @@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo) 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; 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, 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 GetSalesSummaryAsync(DashboardFilter f) { var days = (f.To - f.From).Days + 1; @@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo) var current = await repo.GetSalesTotalsAsync(f); 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 returns = await repo.GetDailyReturnsAsync(f); @@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo) return new SalesSummaryResponse( f.From, f.To, daily ? "day" : "month", 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, (await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(), (await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(), diff --git a/Distribution/Features/DashboardReport/IDashboardReportRepository.cs b/Distribution/Features/DashboardReport/IDashboardReportRepository.cs index 56c81fb..3f818d3 100644 --- a/Distribution/Features/DashboardReport/IDashboardReportRepository.cs +++ b/Distribution/Features/DashboardReport/IDashboardReportRepository.cs @@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount); public interface IDashboardReportRepository { Task GetSalesTotalsAsync(DashboardFilter f); + Task GetReceivablesAsync(DashboardFilter f); Task> GetDailySalesAsync(DashboardFilter f); Task> GetDailyReturnsAsync(DashboardFilter f); Task> GetSalesByCategoryAsync(DashboardFilter f);