From 4e12b03e4b605a61c23cd4ed31f5a7b51dae213b Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 8 Oct 2026 16:28:56 +0530 Subject: [PATCH] Add analytics functionality for stores --- .../Features/Store/IStoreRepository.cs | 1 + .../Features/Store/StoreController.cs | 7 ++ Distribution/Features/Store/StoreDTOs.cs | 37 ++++++++ .../Features/Store/StoreRepository.cs | 8 ++ Distribution/Features/Store/StoreService.cs | 88 +++++++++++++++++++ 5 files changed, 141 insertions(+) diff --git a/Distribution/Features/Store/IStoreRepository.cs b/Distribution/Features/Store/IStoreRepository.cs index 296ce50..ef6678c 100644 --- a/Distribution/Features/Store/IStoreRepository.cs +++ b/Distribution/Features/Store/IStoreRepository.cs @@ -13,4 +13,5 @@ public interface IStoreRepository void Update(Models.Store entity); void Remove(Models.Store entity); Task> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive); + Task> GetInvoicesForAnalyticsAsync(Guid storeId); } diff --git a/Distribution/Features/Store/StoreController.cs b/Distribution/Features/Store/StoreController.cs index 11235e2..0a8e100 100644 --- a/Distribution/Features/Store/StoreController.cs +++ b/Distribution/Features/Store/StoreController.cs @@ -92,6 +92,13 @@ public class StoreController(StoreService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpGet("{id:guid}/analytics")] + public async Task GetAnalytics(Guid id, [FromQuery] int months = 12, [FromQuery] int overdueDays = 30) + { + var result = await service.GetAnalyticsAsync(id, months, overdueDays); + return result is null ? NotFound() : Ok(result); + } + [HttpPatch("{id:guid}/status")] public async Task PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Store/StoreDTOs.cs b/Distribution/Features/Store/StoreDTOs.cs index a9f0ffc..7f66542 100644 --- a/Distribution/Features/Store/StoreDTOs.cs +++ b/Distribution/Features/Store/StoreDTOs.cs @@ -59,3 +59,40 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason); public record PatchStoreStatusRequest(bool IsActive); public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate); + +public record StoreAnalyticsSummary( + decimal TotalSales, + decimal TotalCollected, + decimal Outstanding, + decimal OverdueAmount, + int OverdueInvoiceCount, + int InvoiceCount, + decimal AverageOrderValue, + DateTime? LastSaleDate, + decimal CreditLimit, + decimal CreditUtilizationPercent); + +public record StoreMonthlySales(string Month, decimal Invoiced, decimal Collected, decimal Outstanding); + +public record StoreTopProduct(Guid ItemId, string Name, int Quantity, decimal Revenue, DateTime LastSoldAt); + +public record StoreAgeingBucket(string Label, decimal Amount, int InvoiceCount); + +public record StoreAnalyticsInvoice( + Guid InvoiceId, + DateTime InvoiceDate, + DateTime DueDate, + decimal TotalAmount, + decimal PaidAmount, + decimal Balance, + string PaymentStatus, + int DaysOverdue); + +public record StoreAnalyticsResponse( + Guid StoreId, + int OverdueDays, + StoreAnalyticsSummary Summary, + List MonthlySales, + List TopProducts, + List Ageing, + List Invoices); diff --git a/Distribution/Features/Store/StoreRepository.cs b/Distribution/Features/Store/StoreRepository.cs index a9fcff6..f2d96ea 100644 --- a/Distribution/Features/Store/StoreRepository.cs +++ b/Distribution/Features/Store/StoreRepository.cs @@ -51,4 +51,12 @@ public class StoreRepository(AppDBContext context) : IStoreRepository await context.ShopPOs .Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive) .ToListAsync(); + + public async Task> GetInvoicesForAnalyticsAsync(Guid storeId) => + await context.Invoices + .AsNoTracking() + .Include(i => i.Payments) + .Include(i => i.ShopPO).ThenInclude(p => p.Items).ThenInclude(x => x.Item) + .Where(i => i.IsActive && i.Status != InvoiceStatus.Draft && i.ShopPO.FK_StoreId == storeId) + .ToListAsync(); } diff --git a/Distribution/Features/Store/StoreService.cs b/Distribution/Features/Store/StoreService.cs index 9d41b36..0e82bb0 100644 --- a/Distribution/Features/Store/StoreService.cs +++ b/Distribution/Features/Store/StoreService.cs @@ -253,4 +253,92 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe orders.Sum(o => o.TotalAmount), orders.Count == 0 ? null : orders.Max(o => o.CreatedAt)); } + + public async Task GetAnalyticsAsync(Guid id, int months, int overdueDays) + { + var store = await repo.GetByIdAsync(id); + if (store is null) return null; + if (!await HasStoreScopeAsync(id)) return null; + + months = Math.Clamp(months, 1, 36); + overdueDays = Math.Clamp(overdueDays, 0, 365); + var now = DateTime.Now; + var from = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1)); + + var invoices = (await repo.GetInvoicesForAnalyticsAsync(id)) + .Select(i => + { + var date = i.FinalizedAt ?? i.CreatedAt; + var paid = i.Payments.Where(p => p.IsActive).Sum(p => p.Amount); + var balance = Math.Max(0, i.TotalAmount - paid); + var due = date.AddDays(overdueDays); + var daysOverdue = balance > 0 && now > due ? (int)(now - due).TotalDays : 0; + return (Invoice: i, Date: date, Due: due, Paid: paid, Balance: balance, DaysOverdue: daysOverdue); + }) + .ToList(); + + var inRange = invoices.Where(x => x.Date >= from).ToList(); + var totalSales = inRange.Sum(x => x.Invoice.TotalAmount); + var overdue = invoices.Where(x => x.DaysOverdue > 0).ToList(); + + var summary = new StoreAnalyticsSummary( + totalSales, + inRange.Sum(x => x.Paid), + invoices.Sum(x => x.Balance), + overdue.Sum(x => x.Balance), + overdue.Count, + inRange.Count, + inRange.Count == 0 ? 0 : Math.Round(totalSales / inRange.Count, 2), + invoices.Count == 0 ? null : invoices.Max(x => x.Date), + store.CreditLimit, + store.CreditLimit > 0 ? Math.Round(store.OutstandingBalance / store.CreditLimit * 100, 1) : 0); + + var monthly = Enumerable.Range(0, months) + .Select(n => from.AddMonths(n)) + .Select(m => new StoreMonthlySales( + m.ToString("yyyy-MM"), + inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Invoice.TotalAmount), + inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Paid), + invoices + .Where(x => x.Date < m.AddMonths(1)) + .Sum(x => Math.Max(0, x.Invoice.TotalAmount - x.Invoice.Payments.Where(p => p.IsActive && p.CollectedAt < m.AddMonths(1)).Sum(p => p.Amount))))) + .ToList(); + + var topProducts = inRange + .SelectMany(x => x.Invoice.ShopPO.Items.Select(it => (it, x.Date))) + .GroupBy(t => t.it.FK_ItemId) + .Select(g => new StoreTopProduct( + g.Key, + g.First().it.Item?.Name ?? "Unknown", + g.Sum(t => t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity), + g.Sum(t => (t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity) * t.it.UnitPrice), + g.Max(t => t.Date))) + .OrderByDescending(p => p.Revenue) + .Take(10) + .ToList(); + + var unpaid = invoices.Where(x => x.Balance > 0).ToList(); + StoreAgeingBucket Bucket(string label, Func match) + { + var items = unpaid.Where(x => match(x.DaysOverdue)).ToList(); + return new StoreAgeingBucket(label, items.Sum(x => x.Balance), items.Count); + } + var ageing = new List + { + Bucket("Current", d => d <= 0), + Bucket("1-30", d => d is >= 1 and <= 30), + Bucket("31-60", d => d is >= 31 and <= 60), + Bucket("61-90", d => d is >= 61 and <= 90), + Bucket("90+", d => d > 90) + }; + + var list = invoices + .OrderByDescending(x => x.Date) + .Select(x => new StoreAnalyticsInvoice( + x.Invoice.Id, x.Date, x.Due, x.Invoice.TotalAmount, x.Paid, x.Balance, + x.Invoice.PaymentStatus.ToString(), x.DaysOverdue)) + .ToList(); + + return new StoreAnalyticsResponse(id, overdueDays, summary, monthly, topProducts, ageing, list); + } }