Update dashboard
This commit is contained in:
@@ -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<IActionResult> 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<IActionResult> 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<TrendPoint> Trend,
|
||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> 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<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
|
||||
@@ -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<SoldLine> 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<SaleReturnItem> 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<SalesTotals> 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<List<PeriodAmount>> 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<List<PeriodAmount>> 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<List<GroupAggregate>> 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<List<GroupAggregate>> 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<List<GroupAggregate>> 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<StockBalance> 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<List<CategoryValueRow>> 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<List<WarehouseStockRow>> 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<List<(DateTime Date, decimal Received, decimal Issued)>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SalesSummaryResponse> 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<InventorySummaryResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Distribution.Features.DashboardReport;
|
||||
|
||||
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||
|
||||
public interface IDashboardReportRepository
|
||||
{
|
||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
|
||||
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
|
||||
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
|
||||
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
|
||||
}
|
||||
@@ -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<IItemRepository, ItemRepository>();
|
||||
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
||||
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
||||
builder.Services.AddScoped<ProductReportService>();
|
||||
builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository>();
|
||||
builder.Services.AddScoped<DashboardReportService>();
|
||||
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
||||
builder.Services.AddScoped<StockReportService>();
|
||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||
|
||||
Reference in New Issue
Block a user