Add stock report features r
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
namespace Distribution.Features.StockReport;
|
||||
|
||||
public record LedgerAggregate(
|
||||
Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom,
|
||||
decimal? CostPrice, decimal? ReorderLevel,
|
||||
decimal Opening, decimal In, decimal Out, decimal Current, decimal WarehouseQty);
|
||||
|
||||
public record ExpiryAggregate(
|
||||
Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate,
|
||||
decimal Quantity, decimal? CostPrice, Guid LocationId, string LocationType, string LocationName);
|
||||
|
||||
public record OverviewAggregate(
|
||||
int ActiveItems, int InStockItems, int LowStockItems, decimal TotalQty, decimal TotalValue,
|
||||
int ExpiringSoonBatches, int ExpiredBatches);
|
||||
|
||||
public interface IStockReportRepository
|
||||
{
|
||||
Task<bool> IsWarehouseAsync(Guid locationId);
|
||||
Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays);
|
||||
Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize);
|
||||
Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.StockReport;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reports/stock")]
|
||||
public class StockReportController(StockReportService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
public async Task<IActionResult> Overview([FromQuery] Guid? locationId, [FromQuery] int expiryDays = 30)
|
||||
{
|
||||
var error = StockReportService.ValidateExpiry(expiryDays, 1, 1);
|
||||
return error is null ? Ok(await service.GetOverviewAsync(locationId, expiryDays)) : BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
[HttpGet("ledger")]
|
||||
public async Task<IActionResult> Ledger(
|
||||
[FromQuery] Guid? locationId, [FromQuery] Guid? categoryId, [FromQuery] Guid? brandId,
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] string? status = null,
|
||||
[FromQuery] bool activeOnly = true, [FromQuery] string? search = null,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
{
|
||||
var end = (to ?? DateTime.Now).Date;
|
||||
var start = (from ?? end.AddDays(-30)).Date;
|
||||
var f = new StockReportFilter(locationId, categoryId, brandId, search, start, end, status, activeOnly);
|
||||
var error = StockReportService.Validate(f, page, pageSize);
|
||||
return error is null ? Ok(await service.GetLedgerAsync(f, page, pageSize)) : BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
[HttpGet("expiry")]
|
||||
public async Task<IActionResult> Expiry(
|
||||
[FromQuery] Guid? locationId, [FromQuery] string? search = null, [FromQuery] int days = 30,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
{
|
||||
var error = StockReportService.ValidateExpiry(days, page, pageSize);
|
||||
return error is null
|
||||
? Ok(await service.GetExpiryAsync(locationId, search, days, page, pageSize))
|
||||
: BadRequest(new { message = error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Distribution.Features.StockReport;
|
||||
|
||||
public static class StockStatus
|
||||
{
|
||||
public const string Ok = "OK";
|
||||
public const string Low = "Low";
|
||||
public const string Out = "Out";
|
||||
}
|
||||
|
||||
public record StockReportFilter(
|
||||
Guid? LocationId, Guid? CategoryId, Guid? BrandId, string? Search,
|
||||
DateTime From, DateTime To, string? Status, bool ActiveOnly);
|
||||
|
||||
public record StockOverview(
|
||||
int TotalSkus, decimal TotalQty, decimal TotalValue,
|
||||
int LowStockCount, int OutOfStockCount, int ExpiringSoonCount, int ExpiredCount);
|
||||
|
||||
public record StockLedgerRow(
|
||||
Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom,
|
||||
decimal OpeningQty, decimal QtyIn, decimal QtyOut, decimal ClosingQty, decimal CurrentQty,
|
||||
decimal? CostPrice, decimal StockValue, decimal? ReorderLevel, string Status);
|
||||
|
||||
public record StockExpiryRow(
|
||||
Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate, int DaysLeft,
|
||||
decimal Quantity, decimal? CostPrice, decimal Value, Guid LocationId, string LocationType, string LocationName);
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Linq.Expressions;
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.StockReport;
|
||||
|
||||
public class StockReportRepository(AppDBContext db) : IStockReportRepository
|
||||
{
|
||||
// One location when picked, otherwise every internal location (warehouses + vehicles; External is not tracked).
|
||||
private IQueryable<StockBalance> ScopedBalances(Guid? locationId)
|
||||
{
|
||||
var q = db.StockBalances.AsNoTracking().AsQueryable();
|
||||
return locationId.HasValue
|
||||
? q.Where(b => b.LocationId == locationId.Value)
|
||||
: q.Where(b => b.LocationType == LocationType.Warehouse || b.LocationType == LocationType.Vehicle);
|
||||
}
|
||||
|
||||
public Task<bool> IsWarehouseAsync(Guid locationId) =>
|
||||
db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId);
|
||||
|
||||
public async Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays)
|
||||
{
|
||||
var balances = ScopedBalances(locationId);
|
||||
var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse);
|
||||
|
||||
var perItem = db.Items.AsNoTracking().Where(i => i.IsActive).Select(i => new
|
||||
{
|
||||
i.ReorderLevel,
|
||||
i.CostPrice,
|
||||
Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||
Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||
});
|
||||
|
||||
var activeItems = await perItem.CountAsync();
|
||||
var inStockItems = await perItem.CountAsync(x => x.Qty > 0);
|
||||
var lowItems = includeWarehouseLow
|
||||
? await perItem.CountAsync(x => x.Qty > 0 && x.ReorderLevel != null && x.Wh <= x.ReorderLevel)
|
||||
: 0;
|
||||
var totalQty = await perItem.SumAsync(x => (decimal?)x.Qty) ?? 0m;
|
||||
var totalValue = await perItem.SumAsync(x => (decimal?)(x.Qty * (x.CostPrice ?? 0m))) ?? 0m;
|
||||
|
||||
var soonEnd = today.AddDays(expiryDays + 1);
|
||||
var batchBalances = balances.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0
|
||||
&& b.ItemBatch!.ExpiryDate != null);
|
||||
var expired = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate < today)
|
||||
.Select(b => b.FK_ItemBatchId).Distinct().CountAsync();
|
||||
var soon = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate >= today && b.ItemBatch.ExpiryDate < soonEnd)
|
||||
.Select(b => b.FK_ItemBatchId).Distinct().CountAsync();
|
||||
|
||||
return new OverviewAggregate(activeItems, inStockItems, lowItems, totalQty, totalValue, soon, expired);
|
||||
}
|
||||
|
||||
public async Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(
|
||||
StockReportFilter f, bool includeWarehouseLow, int page, int pageSize)
|
||||
{
|
||||
var balances = ScopedBalances(f.LocationId);
|
||||
var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse);
|
||||
|
||||
var items = db.Items.AsNoTracking().AsQueryable();
|
||||
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
|
||||
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.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,
|
||||
Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||
Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
|
||||
});
|
||||
|
||||
if (f.Status == "out")
|
||||
withQty = withQty.Where(x => x.Qty <= 0);
|
||||
else if (f.Status == "low")
|
||||
withQty = includeWarehouseLow
|
||||
? withQty.Where(x => x.Qty > 0 && x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel)
|
||||
: withQty.Where(x => false);
|
||||
|
||||
var total = await withQty.CountAsync();
|
||||
var pageRows = 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,
|
||||
Uom = x.Item.BaseUom.Name,
|
||||
x.Item.CostPrice, x.Item.ReorderLevel,
|
||||
x.Qty, x.Wh,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ids = pageRows.Select(r => r.Id).ToList();
|
||||
var toExclusive = f.To.TimeOfDay == TimeSpan.Zero ? f.To.AddDays(1) : f.To;
|
||||
var from = f.From;
|
||||
|
||||
Expression<Func<StockMovement, bool>> isIn, isOut;
|
||||
if (f.LocationId.HasValue)
|
||||
{
|
||||
var loc = f.LocationId.Value;
|
||||
isIn = m => m.ToLocationId == loc;
|
||||
isOut = m => m.FromLocationId == loc;
|
||||
}
|
||||
else
|
||||
{
|
||||
// All locations: only movements that cross the boundary of the internal network count;
|
||||
// warehouse <-> vehicle transfers net to zero and are left out of in/out.
|
||||
isIn = m => m.ToLocationId != null && m.ToLocationType != LocationType.External
|
||||
&& (m.FromLocationId == null || m.FromLocationType == LocationType.External);
|
||||
isOut = m => m.FromLocationId != null && m.FromLocationType != LocationType.External
|
||||
&& (m.ToLocationId == null || m.ToLocationType == LocationType.External);
|
||||
}
|
||||
|
||||
var moves = db.StockMovements.AsNoTracking().Where(m => ids.Contains(m.FK_ItemId) && m.CreatedAt < toExclusive);
|
||||
var before = moves.Where(m => m.CreatedAt < from);
|
||||
var inRange = moves.Where(m => m.CreatedAt >= from);
|
||||
|
||||
var openingIn = await SumByItem(before, isIn);
|
||||
var openingOut = await SumByItem(before, isOut);
|
||||
var rangeIn = await SumByItem(inRange, isIn);
|
||||
var rangeOut = await SumByItem(inRange, isOut);
|
||||
|
||||
var rows = pageRows.Select(r => new LedgerAggregate(
|
||||
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.Uom, r.CostPrice, r.ReorderLevel,
|
||||
openingIn.GetValueOrDefault(r.Id) - openingOut.GetValueOrDefault(r.Id),
|
||||
rangeIn.GetValueOrDefault(r.Id), rangeOut.GetValueOrDefault(r.Id),
|
||||
r.Qty, r.Wh)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Guid, decimal>> SumByItem(
|
||||
IQueryable<StockMovement> q, Expression<Func<StockMovement, bool>> predicate) =>
|
||||
await q.Where(predicate)
|
||||
.GroupBy(m => m.FK_ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(m => m.Quantity) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty);
|
||||
|
||||
public async Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(
|
||||
Guid? locationId, string? search, DateTime cutoffExclusive, int page, int pageSize)
|
||||
{
|
||||
var q = ScopedBalances(locationId)
|
||||
.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0
|
||||
&& b.ItemBatch!.ExpiryDate != null && b.ItemBatch.ExpiryDate < cutoffExclusive);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.Trim();
|
||||
q = q.Where(b => b.Item.Name.Contains(s) || b.Item.Sku.Contains(s));
|
||||
}
|
||||
|
||||
var total = await q.CountAsync();
|
||||
var page_ = await q
|
||||
.OrderBy(b => b.ItemBatch!.ExpiryDate).ThenBy(b => b.Item.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(b => new
|
||||
{
|
||||
b.FK_ItemId, b.Item.Sku, b.Item.Name,
|
||||
BatchId = b.FK_ItemBatchId!.Value,
|
||||
b.ItemBatch!.BatchNumber,
|
||||
ExpiryDate = b.ItemBatch.ExpiryDate!.Value,
|
||||
b.Quantity, b.Item.CostPrice, b.LocationId, b.LocationType,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var locIds = page_.Select(r => r.LocationId).Distinct().ToList();
|
||||
var names = await db.Warehouses.AsNoTracking().Where(w => locIds.Contains(w.Id))
|
||||
.Select(w => new { w.Id, w.Name }).ToDictionaryAsync(w => w.Id, w => w.Name);
|
||||
var plates = await db.Vehicles.AsNoTracking().Where(v => locIds.Contains(v.Id))
|
||||
.Select(v => new { v.Id, v.NumberPlate }).ToDictionaryAsync(v => v.Id, v => v.NumberPlate);
|
||||
|
||||
var rows = page_.Select(r => new ExpiryAggregate(
|
||||
r.FK_ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate,
|
||||
r.Quantity, r.CostPrice, r.LocationId, r.LocationType.ToString(),
|
||||
names.GetValueOrDefault(r.LocationId) ?? plates.GetValueOrDefault(r.LocationId) ?? "—")).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Distribution.Features.Stock;
|
||||
|
||||
namespace Distribution.Features.StockReport;
|
||||
|
||||
public class StockReportService(IStockReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
public const int MaxExpiryDays = 3650;
|
||||
|
||||
public static string? Validate(StockReportFilter 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.To < f.From) return "'to' must not be before 'from'.";
|
||||
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||
if (f.Status is not (null or "low" or "out")) return "status must be 'low' or 'out'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateExpiry(int days, int page, int pageSize)
|
||||
{
|
||||
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
|
||||
if (days < 0 || days > MaxExpiryDays) return $"days must be between 0 and {MaxExpiryDays}.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Low-stock is judged on warehouse quantity, so it only applies when the scope includes warehouses.
|
||||
private async Task<bool> IncludesWarehouses(Guid? locationId) =>
|
||||
!locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value);
|
||||
|
||||
public async Task<StockOverview> GetOverviewAsync(Guid? locationId, int expiryDays)
|
||||
{
|
||||
var agg = await repo.GetOverviewAsync(locationId, await IncludesWarehouses(locationId), DateTime.Now.Date, expiryDays);
|
||||
return new StockOverview(agg.InStockItems, agg.TotalQty, agg.TotalValue, agg.LowStockItems,
|
||||
agg.ActiveItems - agg.InStockItems, agg.ExpiringSoonBatches, agg.ExpiredBatches);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRow>> GetLedgerAsync(StockReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var includeLow = await IncludesWarehouses(f.LocationId);
|
||||
var (rows, total) = await repo.GetLedgerAsync(f, includeLow, page, pageSize);
|
||||
var mapped = rows.Select(r =>
|
||||
{
|
||||
var status = r.Current <= 0 ? StockStatus.Out
|
||||
: includeLow && r.ReorderLevel != null && r.WarehouseQty <= r.ReorderLevel ? StockStatus.Low
|
||||
: StockStatus.Ok;
|
||||
return new StockLedgerRow(r.ItemId, r.Sku, r.Name, r.Brand, r.Category, r.Uom,
|
||||
r.Opening, r.In, r.Out, r.Opening + r.In - r.Out, r.Current,
|
||||
r.CostPrice, r.Current * (r.CostPrice ?? 0m), r.ReorderLevel, status);
|
||||
}).ToList();
|
||||
return new PagedResponse<StockLedgerRow>(page, pageSize, total, mapped);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<StockExpiryRow>> GetExpiryAsync(
|
||||
Guid? locationId, string? search, int days, int page, int pageSize)
|
||||
{
|
||||
var today = DateTime.Now.Date;
|
||||
var (rows, total) = await repo.GetExpiryAsync(locationId, search, today.AddDays(days + 1), page, pageSize);
|
||||
var mapped = rows.Select(r => new StockExpiryRow(
|
||||
r.ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate,
|
||||
(int)(r.ExpiryDate.Date - today).TotalDays,
|
||||
r.Quantity, r.CostPrice, r.Quantity * (r.CostPrice ?? 0m),
|
||||
r.LocationId, r.LocationType, r.LocationName)).ToList();
|
||||
return new PagedResponse<StockExpiryRow>(page, pageSize, total, mapped);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user