namespace Distribution.Features.DashboardReport; public class DashboardReportService(IDashboardReportRepository repo) { public const int MaxRangeDays = 366; 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) { var end = (to ?? DateTime.Now).Date; var start = (from ?? end.AddDays(-29)).Date; return new DashboardFilter(start, end, regionId, categoryId, warehouseId); } public static string? Validate(DashboardFilter 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; } private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays; private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM"); private static SalesKpis ToKpis(SalesTotals t) { var net = t.GrossSales - t.ReturnValue - t.Discounts; var gp = t.CostedSales - t.CostOfSales; return new SalesKpis( 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; var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) }; var daily = Daily(f); 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); var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m)) .Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount))) .GroupBy(x => Bucket(x.Date, daily)) .OrderBy(g => g.Key) .Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns))) .ToList(); static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount); 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(), (await repo.GetSalesByRepAsync(f)).Select(Map).ToList()); } public async Task GetInventorySummaryAsync(DashboardFilter f) { var daily = Daily(f); var movements = (await repo.GetDailyMovementsAsync(f)) .GroupBy(m => Bucket(m.Date, daily)) .OrderBy(g => g.Key) .Select(g => { var rec = g.Sum(x => x.Received); var iss = g.Sum(x => x.Issued); return new MovementPoint(g.Key, rec, iss, rec - iss); }).ToList(); return new InventorySummaryResponse( f.From, f.To, daily ? "day" : "month", await repo.GetStockValueByCategoryAsync(f), await repo.GetStockByWarehouseAsync(f), movements); } }