Add product reports

This commit is contained in:
2026-10-07 10:41:04 +05:30
parent 92a311e1cc
commit ff6f3d3218
11 changed files with 649 additions and 0 deletions
@@ -0,0 +1,18 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record SalesAggregate(Guid ItemId, string Sku, string Name, decimal? CostPrice,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled, decimal SalesValue);
public record ReturnAggregate(Guid ItemId, string Sku, string Name, ReturnReasonCode Reason,
decimal Qty, decimal Value);
public interface IProductReportRepository
{
Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize);
Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize);
Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f);
Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds);
Task<decimal> GetTotalSalesAsync(ProductReportFilter f);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ProductReport;
[ApiController]
[Route("api/reports/products")]
public class ProductReportController(ProductReportService service) : ControllerBase
{
private IActionResult? Check(ProductReportFilter f, int page, int pageSize)
{
var error = ProductReportService.Validate(f, page, pageSize);
return error is null ? null : BadRequest(new { message = error });
}
[HttpGet("stock")]
public async Task<IActionResult> Stock(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? warehouseId, [FromQuery] bool activeOnly = true, [FromQuery] bool lowStockOnly = false,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, warehouseId, null, null, null,
null, null, activeOnly, lowStockOnly, search);
return Check(f, page, pageSize) ?? Ok(await service.GetStockAsync(f, page, pageSize));
}
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetSalesAsync(f, page, pageSize));
}
[HttpGet("returns")]
public async Task<IActionResult> Returns(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetReturnsAsync(f, page, pageSize));
}
[HttpGet("summary")]
public async Task<IActionResult> Summary(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var f = new ProductReportFilter(brandId, categoryId, null, warehouseId, null, null, null,
from, to, true, false, null);
return Check(f, 1, 1) ?? Ok(await service.GetSummaryAsync(f));
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record ProductStockRow(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string? SubCategory, string Uom,
decimal? SalePrice, decimal? CostPrice, decimal? ReorderLevel,
decimal WarehouseQty, decimal VehicleQty, decimal TotalQty, decimal StockValue,
bool IsLowStock, bool IsActive);
public record ProductSalesRow(
Guid ItemId, string Sku, string Name,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled,
decimal SalesValue, decimal? CostValue, decimal? Margin, decimal? MarginPercent);
public record ProductReturnRow(
Guid ItemId, string Sku, string Name,
decimal ReturnedQty, decimal ReturnedValue, decimal SoldQty, decimal? ReturnPercent,
Dictionary<ReturnReasonCode, decimal> QtyByReason);
public record ProductReportSummary(int ItemCount, decimal TotalQty, decimal TotalValue, decimal TotalSales);
public record ProductReportFilter(
Guid? BrandId, Guid? CategoryId, Guid? SubCategoryId, Guid? WarehouseId,
Guid? RegionId, Guid? RouteId, Guid? StoreId,
DateTime? From, DateTime? To, bool ActiveOnly, bool LowStockOnly, string? Search);
@@ -0,0 +1,156 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ProductReport;
public class ProductReportRepository(AppDBContext db) : IProductReportRepository
{
public async Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var balances = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
balances = balances.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
var items = db.Items.AsNoTracking().AsQueryable();
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) items = items.Where(i => i.FK_SubCategoryId == f.SubCategoryId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
}
var withQty = items.Select(i => new
{
Item = i,
Wh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Warehouse)
.Sum(b => b.Quantity),
Veh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Vehicle)
.Sum(b => b.Quantity),
});
if (f.LowStockOnly)
withQty = withQty.Where(x => x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel);
var total = await withQty.CountAsync();
var page_ = await withQty
.OrderBy(x => x.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.Item.Id, x.Item.Sku, x.Item.Name,
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
Category = x.Item.Category.Name,
SubCategory = x.Item.SubCategory != null ? x.Item.SubCategory.Name : null,
Uom = x.Item.BaseUom.Name,
x.Item.SalePrice, x.Item.CostPrice, x.Item.ReorderLevel, x.Item.IsActive,
x.Wh, x.Veh,
})
.ToListAsync();
var rows = page_.Select(r => new ProductStockRow(
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.SubCategory, r.Uom,
r.SalePrice, r.CostPrice, r.ReorderLevel,
r.Wh, r.Veh, r.Wh + r.Veh, (r.Wh + r.Veh) * (r.CostPrice ?? 0m),
r.ReorderLevel != null && r.Wh <= r.ReorderLevel,
r.IsActive)).ToList();
return (rows, total);
}
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
{
var lines =
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
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
select li;
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
lines = lines.Where(l => l.Item.Name.Contains(s) || l.Item.Sku.Contains(s));
}
return lines;
}
public async Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var query = SoldLines(f)
.GroupBy(l => new { l.FK_ItemId, l.Item.Sku, l.Item.Name, l.Item.CostPrice })
.Select(g => new
{
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
});
var total = await query.CountAsync();
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
// Distinct stores per item is counted separately: MySQL can't run the correlated
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
var ids = page_.Select(r => r.FK_ItemId).ToList();
var storePairs = await SoldLines(f)
.Where(l => ids.Contains(l.FK_ItemId))
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
.Distinct()
.ToListAsync();
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
return (rows, total);
}
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
{
var ids = itemIds.ToList();
return await SoldLines(f with { Search = null })
.Where(l => ids.Contains(l.FK_ItemId))
.GroupBy(l => l.FK_ItemId)
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
.ToDictionaryAsync(x => x.Key, x => x.Qty);
}
public Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f)
{
var q = db.SaleReturnItems.AsNoTracking().AsQueryable();
if (f.From.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt >= f.From.Value);
if (f.To.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt < f.To.Value.Date.AddDays(1));
if (f.BrandId.HasValue) q = q.Where(r => r.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) q = q.Where(r => r.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
q = q.Where(r => r.Item.Name.Contains(s) || r.Item.Sku.Contains(s));
}
return q.GroupBy(r => new { r.FK_ItemId, r.Item.Sku, r.Item.Name, r.ReasonCode })
.Select(g => new ReturnAggregate(g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.ReasonCode,
g.Sum(x => x.Quantity), g.Sum(x => x.Quantity * x.UnitPrice)))
.ToListAsync();
}
}
@@ -0,0 +1,72 @@
using Distribution.Features.Stock;
namespace Distribution.Features.ProductReport;
public class ProductReportService(IProductReportRepository repo)
{
public const int MaxRangeDays = 366;
public static string? Validate(ProductReportFilter f, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (f.From.HasValue && f.To.HasValue)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To.Value - f.From.Value).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
}
return null;
}
public async Task<PagedResponse<ProductStockRow>> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetStockAsync(f, page, pageSize);
return new PagedResponse<ProductStockRow>(page, pageSize, total, rows);
}
public async Task<PagedResponse<ProductSalesRow>> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetSalesAsync(f, page, pageSize);
var mapped = rows.Select(r =>
{
decimal? cost = r.CostPrice.HasValue ? r.CostPrice.Value * r.QtyFulfilled : null;
decimal? margin = cost.HasValue ? r.SalesValue - cost.Value : null;
decimal? pct = margin.HasValue && r.SalesValue != 0 ? Math.Round(margin.Value / r.SalesValue * 100, 2) : null;
return new ProductSalesRow(r.ItemId, r.Sku, r.Name, r.Orders, r.Stores, r.QtyOrdered, r.QtyFulfilled,
r.SalesValue, cost, margin, pct);
}).ToList();
return new PagedResponse<ProductSalesRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<ProductReturnRow>> GetReturnsAsync(ProductReportFilter f, int page, int pageSize)
{
var agg = await repo.GetReturnsAsync(f);
var grouped = agg.GroupBy(a => new { a.ItemId, a.Sku, a.Name })
.Select(g => new
{
g.Key,
Qty = g.Sum(x => x.Qty),
Value = g.Sum(x => x.Value),
ByReason = g.ToDictionary(x => x.Reason, x => x.Qty),
})
.OrderByDescending(x => x.Value).ThenBy(x => x.Key.Name)
.ToList();
var pageRows = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var sold = await repo.GetSoldQtyAsync(f, pageRows.Select(r => r.Key.ItemId));
var rows = pageRows.Select(r =>
{
var soldQty = sold.GetValueOrDefault(r.Key.ItemId);
decimal? pct = soldQty > 0 ? Math.Round(r.Qty / soldQty * 100, 2) : null;
return new ProductReturnRow(r.Key.ItemId, r.Key.Sku, r.Key.Name, r.Qty, r.Value, soldQty, pct, r.ByReason);
}).ToList();
return new PagedResponse<ProductReturnRow>(page, pageSize, grouped.Count, rows);
}
public async Task<ProductReportSummary> GetSummaryAsync(ProductReportFilter f)
{
var (rows, total) = await repo.GetStockAsync(f, 1, int.MaxValue);
var totalSales = await repo.GetTotalSalesAsync(f);
return new ProductReportSummary(total, rows.Sum(r => r.TotalQty), rows.Sum(r => r.StockValue), totalSales);
}
}