diff --git a/Distribution/Features/DashboardReport/DashboardReportController.cs b/Distribution/Features/DashboardReport/DashboardReportController.cs new file mode 100644 index 0000000..219bc7c --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.DashboardReport; + +[ApiController] +[Route("api/reports/dashboard")] +public class DashboardReportController(DashboardReportService service) : ControllerBase +{ + [HttpGet("sales")] + public async Task Sales( + [FromQuery] DateTime? from, [FromQuery] DateTime? to, + [FromQuery] Guid? regionId, [FromQuery] Guid? categoryId) + { + var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null); + var error = DashboardReportService.Validate(f); + return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error }); + } + + [HttpGet("inventory")] + public async Task Inventory( + [FromQuery] DateTime? from, [FromQuery] DateTime? to, + [FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId) + { + var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId); + var error = DashboardReportService.Validate(f); + return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error }); + } +} diff --git a/Distribution/Features/DashboardReport/DashboardReportDTOs.cs b/Distribution/Features/DashboardReport/DashboardReportDTOs.cs new file mode 100644 index 0000000..5b034f1 --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportDTOs.cs @@ -0,0 +1,32 @@ +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 SalesKpis( + decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue, + decimal GrossProfit, decimal? GrossProfitMarginPercent); + +public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo); + +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, + List ByCategory, List ByRegion, List ByRep); + +public record PeriodAmount(DateTime Date, decimal Amount); + +public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net); + +public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value); + +public record CategoryValueRow(string Category, decimal Quantity, decimal Value); + +public record InventorySummaryResponse( + DateTime From, DateTime To, string Granularity, + List ValueByCategory, List ByWarehouse, List MovementTrend); diff --git a/Distribution/Features/DashboardReport/DashboardReportRepository.cs b/Distribution/Features/DashboardReport/DashboardReportRepository.cs new file mode 100644 index 0000000..1b2bb32 --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportRepository.cs @@ -0,0 +1,171 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.DashboardReport; + +public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository +{ + private sealed class SoldLine + { + public DateTime Date { get; set; } + public Guid PoId { get; set; } + public decimal Value { get; set; } + public decimal? Cost { get; set; } + public Guid CategoryId { get; set; } + public string CategoryName { get; set; } = ""; + public Guid RegionId { get; set; } + public string RegionName { get; set; } = ""; + 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). + 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 + 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, + 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, + }; + 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); + return q; + } + + private IQueryable ReturnLines(DashboardFilter f) + { + var start = f.From.Date; + var end = f.To.Date.AddDays(1); + var q = db.SaleReturnItems.AsNoTracking() + .Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end); + 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; + } + + public async Task GetSalesTotalsAsync(DashboardFilter f) + { + var lines = SoldLines(f); + var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m; + var orders = await lines.Select(l => l.PoId).Distinct().CountAsync(); + var costed = lines.Where(l => l.Cost != null); + 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); + } + + public async Task> GetDailySalesAsync(DashboardFilter f) => + (await SoldLines(f).GroupBy(l => l.Date) + .Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) }) + .ToListAsync()) + .Select(x => new PeriodAmount(x.Date, x.Amount)).ToList(); + + public async Task> GetDailyReturnsAsync(DashboardFilter f) => + (await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date) + .Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) }) + .ToListAsync()) + .Select(x => new PeriodAmount(x.Date, x.Amount)).ToList(); + + public async Task> GetSalesByCategoryAsync(DashboardFilter f) => + (await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName }) + .Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) }) + .OrderByDescending(x => x.Amount).ToListAsync()) + .Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList(); + + public async Task> GetSalesByRegionAsync(DashboardFilter f) => + (await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName }) + .Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) }) + .OrderByDescending(x => x.Amount).ToListAsync()) + .Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList(); + + public async Task> GetSalesByRepAsync(DashboardFilter f) + { + var rows = await SoldLines(f).GroupBy(l => l.RepId) + .Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) }) + .OrderByDescending(x => x.Amount).Take(20).ToListAsync(); + var ids = rows.Select(r => r.RepId).ToList(); + var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName); + return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList(); + } + + private IQueryable Balances(DashboardFilter f) + { + var q = db.StockBalances.AsNoTracking().AsQueryable(); + if (f.WarehouseId.HasValue) + q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value); + if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value); + return q; + } + + // Value = quantity x cost price; items without a cost price contribute quantity only. + public async Task> GetStockValueByCategoryAsync(DashboardFilter f) => + (await Balances(f).GroupBy(b => b.Item.Category.Name) + .Select(g => new + { + Category = g.Key, + Quantity = g.Sum(x => x.Quantity), + Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)), + }) + .OrderByDescending(x => x.Value).ToListAsync()) + .Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList(); + + public async Task> GetStockByWarehouseAsync(DashboardFilter f) + { + var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse) + .GroupBy(b => b.LocationId) + .Select(g => new + { + Id = g.Key, + Quantity = g.Sum(x => x.Quantity), + Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)), + }) + .ToListAsync(); + var ids = rows.Select(r => r.Id).ToList(); + var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id)) + .ToDictionaryAsync(w => w.Id, w => w.Name); + return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value)) + .OrderByDescending(r => r.Value).ToList(); + } + + // Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages. + // Transfers, loads and adjustments are internal/signed and excluded from this trend. + public async Task> GetDailyMovementsAsync(DashboardFilter f) + { + var start = f.From.Date; + var end = f.To.Date.AddDays(1); + var q = db.StockMovements.AsNoTracking() + .Where(m => m.CreatedAt >= start && m.CreatedAt < end + && (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn + || m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage)); + if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value); + if (f.WarehouseId.HasValue) + q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value); + + var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type }) + .Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) }) + .ToListAsync(); + return rows.GroupBy(r => r.Date) + .Select(g => (g.Key, + g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty), + g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty))) + .ToList(); + } +} diff --git a/Distribution/Features/DashboardReport/DashboardReportService.cs b/Distribution/Features/DashboardReport/DashboardReportService.cs new file mode 100644 index 0000000..e393d48 --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportService.cs @@ -0,0 +1,83 @@ +namespace Distribution.Features.DashboardReport; + +public class DashboardReportService(IDashboardReportRepository repo) +{ + public const int MaxRangeDays = 366; + private const int DailyGranularityMaxDays = 62; + + 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; + var gp = t.CostedSales - t.CostOfSales; + return new SalesKpis( + t.GrossSales, t.ReturnValue, 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); + } + + 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 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), + 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); + } +} diff --git a/Distribution/Features/DashboardReport/IDashboardReportRepository.cs b/Distribution/Features/DashboardReport/IDashboardReportRepository.cs new file mode 100644 index 0000000..56c81fb --- /dev/null +++ b/Distribution/Features/DashboardReport/IDashboardReportRepository.cs @@ -0,0 +1,16 @@ +namespace Distribution.Features.DashboardReport; + +public record GroupAggregate(Guid? Key, string Name, decimal Amount); + +public interface IDashboardReportRepository +{ + Task GetSalesTotalsAsync(DashboardFilter f); + Task> GetDailySalesAsync(DashboardFilter f); + Task> GetDailyReturnsAsync(DashboardFilter f); + Task> GetSalesByCategoryAsync(DashboardFilter f); + Task> GetSalesByRegionAsync(DashboardFilter f); + Task> GetSalesByRepAsync(DashboardFilter f); + Task> GetStockValueByCategoryAsync(DashboardFilter f); + Task> GetStockByWarehouseAsync(DashboardFilter f); + Task> GetDailyMovementsAsync(DashboardFilter f); +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index e59efe5..ca74100 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -30,6 +30,7 @@ using Distribution.Features.Sync; using Distribution.Features.Uom; using Distribution.Features.User; using Distribution.Features.Vehicle; +using Distribution.Features.DashboardReport; using Distribution.Features.ProductReport; using Distribution.Features.Stock; using Distribution.Features.StockReport; @@ -71,6 +72,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();