Fix dashoard issues #85
@@ -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<TrendPoint> Trend,
|
||||
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||
|
||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||
|
||||
@@ -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<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
|
||||
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<decimal> 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<ReceivablesTotals> 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<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||
|
||||
@@ -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<SalesSummaryResponse> 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(),
|
||||
|
||||
@@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||
public interface IDashboardReportRepository
|
||||
{
|
||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||
|
||||
Reference in New Issue
Block a user