From 09d86835a29e72744c418470b59ac5c98a9d20e4 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:44:35 +0530 Subject: [PATCH] Implement Warehouse Dashboard features --- .../IWarehouseDashboardRepository.cs | 23 ++ .../Features/Warehouse/WarehouseController.cs | 12 +- .../Warehouse/WarehouseDashboardDTOs.cs | 105 ++++++++ .../Warehouse/WarehouseDashboardRepository.cs | 160 ++++++++++++ .../Warehouse/WarehouseDashboardService.cs | 229 ++++++++++++++++++ Distribution/Program.cs | 8 + 6 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs create mode 100644 Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs create mode 100644 Distribution/Features/Warehouse/WarehouseDashboardRepository.cs create mode 100644 Distribution/Features/Warehouse/WarehouseDashboardService.cs diff --git a/Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs b/Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs new file mode 100644 index 0000000..4075787 --- /dev/null +++ b/Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs @@ -0,0 +1,23 @@ +namespace Distribution.Features.Warehouse; + +public interface IWarehouseDashboardRepository +{ + Task> GetBalanceRowsAsync(Guid warehouseId); + Task> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive); + Task> GetLastOutboundAsync(Guid warehouseId); + + Task GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive); + Task> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take); + + Task> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take); + Task GetPendingDispatchUnitsAsync(Guid warehouseId); + + Task> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId); + Task> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId); + + Task GetOpenClaimCountAsync(Guid warehouseId); + Task> GetOpenClaimsAsync(Guid warehouseId, int take); + + Task> GetLocationNamesAsync(IEnumerable ids); + Task> GetUserNamesAsync(IEnumerable ids); +} diff --git a/Distribution/Features/Warehouse/WarehouseController.cs b/Distribution/Features/Warehouse/WarehouseController.cs index 043dfbd..bb0daf0 100644 --- a/Distribution/Features/Warehouse/WarehouseController.cs +++ b/Distribution/Features/Warehouse/WarehouseController.cs @@ -4,7 +4,7 @@ namespace Distribution.Features.Warehouse; [ApiController] [Route("api/warehouses")] -public class WarehouseController(WarehouseService service) : ControllerBase +public class WarehouseController(WarehouseService service, WarehouseDashboardService dashboardService) : ControllerBase { [HttpGet] public async Task GetAll() => @@ -17,6 +17,16 @@ public class WarehouseController(WarehouseService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpGet("{id:guid}/dashboard")] + public async Task Dashboard( + Guid id, [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int deadStockDays = 30) + { + var error = WarehouseDashboardService.Validate(from, to, deadStockDays); + if (error is not null) return BadRequest(new { message = error }); + var result = await dashboardService.GetAsync(id, from, to, deadStockDays); + return result is null ? NotFound() : Ok(result); + } + [HttpPost] public async Task Create(CreateWarehouseRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs b/Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs new file mode 100644 index 0000000..847a3a6 --- /dev/null +++ b/Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs @@ -0,0 +1,105 @@ +using Distribution.Models; + +namespace Distribution.Features.Warehouse; + +// ── Repository projections ───────────────────────────────────────────────── + +public record WarehouseBalanceRow( + Guid ItemId, string Sku, string Name, string Category, string? Brand, string Uom, + decimal? ReorderLevel, decimal? CostPrice, decimal? SalePrice, + Guid? BatchId, string? BatchNumber, DateTime? ExpiryDate, + decimal Quantity, DateTime UpdatedAt); + +public record WarehouseMovementRow( + Guid Id, Guid ItemId, string ItemName, string Sku, + Guid? FromLocationId, LocationType? FromLocationType, + Guid? ToLocationId, LocationType? ToLocationType, + decimal Quantity, MovementType Type, string? ReasonCode, Guid? ReferenceId, + DateTime CreatedAt, Guid CreatedBy); + +public record WarehouseLastOutboundRow(Guid ItemId, DateTime LastOutboundAt); + +public record WarehouseGrnTotals(int Count, int Accepted, int Rejected); + +public record WarehouseVehicleStockRow( + Guid VehicleId, string RegNumber, string NumberPlate, bool IsLinked, decimal Units, int Skus); + +// ── Response ─────────────────────────────────────────────────────────────── + +public record DashboardPeriod(DateTime From, DateTime To); + +public record DashboardKpis( + decimal StockValueCost, decimal StockValueSale, decimal TotalUnits, int SkuCount, + int LowStockCount, int OutOfStockCount, int NearExpiryCount, int ExpiredCount, + decimal UnitsIn, decimal UnitsOut); + +public record DashboardBatch(Guid BatchId, string? BatchNumber, DateTime? ExpiryDate, decimal Quantity); + +public record DashboardStockRow( + Guid ItemId, string Sku, string Name, string Category, string? Brand, string Uom, + decimal Quantity, decimal? ReorderLevel, string Status, + decimal CostValue, decimal SaleValue, DateTime UpdatedAt, List Batches); + +public record DashboardCategoryStock(string Category, decimal Units, decimal Value); + +public record DashboardExpiringBatch( + Guid ItemId, string ItemName, string? BatchNumber, DateTime ExpiryDate, decimal Quantity, string Status); + +public record DashboardDailyPoint(string Date, decimal In, decimal Out); + +public record DashboardMovementTypeRow(MovementType Type, string Direction, decimal Units, int Count); + +public record DashboardLedgerRow( + Guid Id, DateTime CreatedAt, Guid ItemId, string ItemName, string Sku, + MovementType Type, string Direction, decimal Quantity, + string FromName, string ToName, Guid? ReferenceId, string? ReferenceKind, + string? ReasonCode, string CreatedByName); + +public record DashboardGrnRow( + Guid Id, DateTime ReceivedAt, Guid? PurchaseOrderId, string? SupplierName, + int AcceptedQuantity, int RejectedQuantity, decimal CostValue); + +public record DashboardInbound( + int GrnCount, int AcceptedUnits, int RejectedUnits, decimal RejectionRatePercent, List RecentGrns); + +public record DashboardLoadingSheetRow( + Guid Id, Guid RideId, string VehiclePlate, LoadingSheetStatus Status, + DateTime PopulatedAt, DateTime? ConfirmedAt, int Lines, decimal Units); + +public record DashboardOutbound( + decimal UnitsDispatched, decimal PendingDispatchUnits, List RecentLoadingSheets); + +public record DashboardVehicle( + Guid VehicleId, string RegNumber, string NumberPlate, bool IsLinked, decimal Units, int Skus); + +public record DashboardClaimRow(Guid Id, DateTime CreatedAt, string? SupplierName, string Details, decimal Units); + +public record DashboardLosses( + decimal PurchaseReturnUnits, decimal DamageUnits, decimal NegativeAdjustmentUnits, + decimal PositiveAdjustmentUnits, int OpenClaimCount, List OpenClaims); + +public record DashboardTopMover(Guid ItemId, string Sku, string Name, decimal UnitsOut); + +public record DashboardDeadStock( + Guid ItemId, string Sku, string Name, decimal Quantity, decimal CostValue, DateTime? LastOutboundAt); + +public record DashboardLinkedVehicle(Guid Id, string RegNumber, string NumberPlate, bool IsPrimary); + +public record WarehouseDashboardResponse( + WarehouseResponse Warehouse, + List LinkedVehicles, + DashboardPeriod Period, + int DeadStockDays, + DashboardKpis Kpis, + List Stock, + List StockByCategory, + List ExpiringBatches, + List DailyMovement, + List MovementByType, + List Ledger, + DashboardInbound Inbound, + DashboardOutbound Outbound, + List Vehicles, + DashboardLosses Losses, + List TopMovers, + List DeadStock); diff --git a/Distribution/Features/Warehouse/WarehouseDashboardRepository.cs b/Distribution/Features/Warehouse/WarehouseDashboardRepository.cs new file mode 100644 index 0000000..1eb169d --- /dev/null +++ b/Distribution/Features/Warehouse/WarehouseDashboardRepository.cs @@ -0,0 +1,160 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Warehouse; + +public class WarehouseDashboardRepository(AppDBContext db) : IWarehouseDashboardRepository +{ + public Task> GetBalanceRowsAsync(Guid warehouseId) => + db.StockBalances.AsNoTracking() + .Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == warehouseId) + .Select(b => new WarehouseBalanceRow( + b.FK_ItemId, b.Item.Sku, b.Item.Name, b.Item.Category.Name, + b.Item.Brand != null ? b.Item.Brand.Name : null, b.Item.BaseUom.Name, + b.Item.ReorderLevel, b.Item.CostPrice, b.Item.SalePrice, + b.FK_ItemBatchId, + b.ItemBatch != null ? b.ItemBatch.BatchNumber : null, + b.ItemBatch != null ? b.ItemBatch.ExpiryDate : null, + b.Quantity, b.UpdatedAt)) + .ToListAsync(); + + public Task> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive) => + db.StockMovements.AsNoTracking() + .Where(m => (m.FromLocationId == warehouseId || m.ToLocationId == warehouseId) + && m.CreatedAt >= from && m.CreatedAt < toExclusive) + .OrderByDescending(m => m.CreatedAt) + .Select(m => new WarehouseMovementRow( + m.Id, m.FK_ItemId, m.Item.Name, m.Item.Sku, + m.FromLocationId, m.FromLocationType, m.ToLocationId, m.ToLocationType, + m.Quantity, m.Type, m.ReasonCode, m.ReferenceId, m.CreatedAt, m.CreatedBy)) + .ToListAsync(); + + public Task> GetLastOutboundAsync(Guid warehouseId) => + db.StockMovements.AsNoTracking() + .Where(m => m.FromLocationId == warehouseId && m.ToLocationId != warehouseId) + .GroupBy(m => m.FK_ItemId) + .Select(g => new WarehouseLastOutboundRow(g.Key, g.Max(x => x.CreatedAt))) + .ToListAsync(); + + public async Task GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive) + { + var receipts = db.GoodsReceipts.AsNoTracking() + .Where(g => g.FK_WarehouseId == warehouseId && g.ReceivedAt >= from && g.ReceivedAt < toExclusive); + var count = await receipts.CountAsync(); + var accepted = await receipts.SelectMany(g => g.Items).SumAsync(i => (int?)i.AcceptedQuantity) ?? 0; + var rejected = await receipts.SelectMany(g => g.Items).SumAsync(i => (int?)i.RejectedQuantity) ?? 0; + return new WarehouseGrnTotals(count, accepted, rejected); + } + + public Task> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take) => + db.GoodsReceipts.AsNoTracking() + .Where(g => g.FK_WarehouseId == warehouseId && g.ReceivedAt >= from && g.ReceivedAt < toExclusive) + .OrderByDescending(g => g.ReceivedAt) + .Take(take) + .Select(g => new DashboardGrnRow( + g.Id, g.ReceivedAt, g.FK_PurchaseOrderId, + g.PurchaseOrder != null ? g.PurchaseOrder.Supplier.Name : (g.Supplier != null ? g.Supplier.Name : null), + g.Items.Sum(i => i.AcceptedQuantity), + g.Items.Sum(i => i.RejectedQuantity), + g.Items.Sum(i => i.AcceptedQuantity * i.UnitCost))) + .ToListAsync(); + + public Task> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take) => + db.LoadingSheets.AsNoTracking() + .Where(s => s.PopulatedAt >= from && s.PopulatedAt < toExclusive + && s.Items.Any(i => i.FK_WarehouseId == warehouseId)) + .OrderByDescending(s => s.PopulatedAt) + .Take(take) + .Select(s => new DashboardLoadingSheetRow( + s.Id, s.FK_RideId, s.Ride.Vehicle.NumberPlate, s.Status, s.PopulatedAt, s.ConfirmedAt, + s.Items.Count(i => i.FK_WarehouseId == warehouseId), + s.Items.Where(i => i.FK_WarehouseId == warehouseId).Sum(i => i.QuantityDispatched))) + .ToListAsync(); + + public async Task GetPendingDispatchUnitsAsync(Guid warehouseId) => + await db.LoadingSheetItems.AsNoTracking() + .Where(i => i.FK_WarehouseId == warehouseId && i.LoadingSheet.Status == LoadingSheetStatus.Planned) + .SumAsync(i => (decimal?)i.QuantityDispatched) ?? 0m; + + public async Task> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId) + { + var loadedFromHere = db.LoadingSheetItems + .Where(i => i.FK_WarehouseId == warehouseId) + .Select(i => i.LoadingSheet.Ride.FK_VehicleId); + + var vehicles = await db.Vehicles.AsNoTracking() + .Where(v => v.IsActive + && (v.DefaultWarehouseId == warehouseId + || (primaryVehicleId != null && v.Id == primaryVehicleId) + || loadedFromHere.Contains(v.Id))) + .Select(v => new + { + v.Id, v.RegNumber, v.NumberPlate, + IsLinked = v.DefaultWarehouseId == warehouseId || (primaryVehicleId != null && v.Id == primaryVehicleId), + }) + .ToListAsync(); + + var ids = vehicles.Select(v => v.Id).ToList(); + var balances = await db.StockBalances.AsNoTracking() + .Where(b => b.LocationType == LocationType.Vehicle && ids.Contains(b.LocationId) && b.Quantity > 0) + .GroupBy(b => b.LocationId) + .Select(g => new { VehicleId = g.Key, Units = g.Sum(x => x.Quantity), Skus = g.Select(x => x.FK_ItemId).Distinct().Count() }) + .ToListAsync(); + + return vehicles + .Select(v => + { + var bal = balances.FirstOrDefault(b => b.VehicleId == v.Id); + return new WarehouseVehicleStockRow(v.Id, v.RegNumber, v.NumberPlate, v.IsLinked, bal?.Units ?? 0m, bal?.Skus ?? 0); + }) + .OrderByDescending(v => v.Units) + .ToList(); + } + + public Task> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId) => + db.Vehicles.AsNoTracking() + .Where(v => v.IsActive + && (v.DefaultWarehouseId == warehouseId || (primaryVehicleId != null && v.Id == primaryVehicleId))) + .Select(v => new DashboardLinkedVehicle( + v.Id, v.RegNumber, v.NumberPlate, primaryVehicleId != null && v.Id == primaryVehicleId)) + .ToListAsync(); + + public Task GetOpenClaimCountAsync(Guid warehouseId) => + db.SupplierClaims.AsNoTracking() + .CountAsync(c => c.Status == SupplierClaimStatus.Open + && c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId); + + public Task> GetOpenClaimsAsync(Guid warehouseId, int take) => + db.SupplierClaims.AsNoTracking() + .Where(c => c.Status == SupplierClaimStatus.Open + && c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId) + .OrderByDescending(c => c.CreatedAt) + .Take(take) + .Select(c => new DashboardClaimRow( + c.Id, c.CreatedAt, c.PurchaseOrder.Supplier.Name, c.Details, + c.Items.Sum(i => i.Quantity))) + .ToListAsync(); + + public async Task> GetLocationNamesAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + var names = new Dictionary(); + if (idList.Count == 0) return names; + + foreach (var w in await db.Warehouses.AsNoTracking().Where(w => idList.Contains(w.Id)).Select(w => new { w.Id, w.Name }).ToListAsync()) + names[w.Id] = w.Name; + foreach (var v in await db.Vehicles.AsNoTracking().Where(v => idList.Contains(v.Id)).Select(v => new { v.Id, v.RegNumber }).ToListAsync()) + names[v.Id] = v.RegNumber; + return names; + } + + public async Task> GetUserNamesAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + if (idList.Count == 0) return []; + return await db.Users.AsNoTracking() + .Where(u => idList.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName); + } +} diff --git a/Distribution/Features/Warehouse/WarehouseDashboardService.cs b/Distribution/Features/Warehouse/WarehouseDashboardService.cs new file mode 100644 index 0000000..f67fc31 --- /dev/null +++ b/Distribution/Features/Warehouse/WarehouseDashboardService.cs @@ -0,0 +1,229 @@ +using Distribution.Models; + +namespace Distribution.Features.Warehouse; + +public class WarehouseDashboardService(IWarehouseRepository warehouseRepo, IWarehouseDashboardRepository repo) +{ + private const int NearExpiryDays = 30; + private const int LedgerSize = 50; + private const int RecentDocumentCount = 8; + private const int TopListSize = 5; + private const int DeadStockListSize = 10; + private const int MaxPeriodDays = 366; + + public static string? Validate(DateTime? from, DateTime? to, int deadStockDays) + { + if (deadStockDays < 1 || deadStockDays > 365) return "deadStockDays must be between 1 and 365."; + if (from.HasValue && to.HasValue) + { + if (from.Value.Date > to.Value.Date) return "'from' must not be after 'to'."; + if ((to.Value.Date - from.Value.Date).TotalDays >= MaxPeriodDays) return $"The period cannot exceed {MaxPeriodDays} days."; + } + return null; + } + + /// Unlike the standard warehouse lookup, this also serves inactive warehouses so their history stays viewable. + public async Task GetAsync(Guid id, DateTime? from, DateTime? to, int deadStockDays) + { + var warehouse = await warehouseRepo.GetByIdAsync(id); + if (warehouse is null) return null; + + var now = DateTime.Now; + var today = now.Date; + var toDate = (to ?? today).Date; + var fromDate = (from ?? toDate.AddDays(-29)).Date; + var toExclusive = toDate.AddDays(1); + + var balances = await repo.GetBalanceRowsAsync(id); + var movements = (await repo.GetMovementsAsync(id, fromDate, toExclusive)) + .Select(m => (Row: m, Direction: DirectionOf(m, id))) + .Where(x => x.Direction is not null) + .ToList(); + var lastOutbound = (await repo.GetLastOutboundAsync(id)).ToDictionary(r => r.ItemId, r => r.LastOutboundAt); + + // ── Stock on hand ────────────────────────────────────────────────── + var stock = balances + .GroupBy(b => b.ItemId) + .Select(g => BuildStockRow(g.ToList())) + .OrderBy(r => r.Name) + .ToList(); + + var stockByCategory = stock + .Where(r => r.Quantity > 0) + .GroupBy(r => r.Category) + .Select(g => new DashboardCategoryStock(g.Key, g.Sum(r => r.Quantity), g.Sum(r => r.CostValue))) + .OrderByDescending(c => c.Value) + .ToList(); + + var itemNames = stock.ToDictionary(r => r.ItemId, r => r.Name); + var expiring = balances + .Where(b => b.Quantity > 0 && b.ExpiryDate.HasValue && b.BatchId.HasValue + && b.ExpiryDate.Value.Date <= today.AddDays(NearExpiryDays)) + .GroupBy(b => (b.ItemId, b.BatchId)) + .Select(g => + { + var first = g.First(); + var expiry = first.ExpiryDate!.Value; + return new DashboardExpiringBatch( + first.ItemId, itemNames.GetValueOrDefault(first.ItemId, first.Name), first.BatchNumber, + expiry, g.Sum(x => x.Quantity), expiry.Date < today ? "Expired" : "NearExpiry"); + }) + .OrderBy(b => b.ExpiryDate) + .ToList(); + + // ── Movements ────────────────────────────────────────────────────── + var unitsIn = movements.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity); + var unitsOut = movements.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity); + + var perDay = movements + .GroupBy(x => x.Row.CreatedAt.Date) + .ToDictionary(g => g.Key, g => ( + In: g.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity), + Out: g.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity))); + var daily = new List(); + for (var d = fromDate; d <= toDate; d = d.AddDays(1)) + { + perDay.TryGetValue(d, out var p); + daily.Add(new DashboardDailyPoint(d.ToString("yyyy-MM-dd"), p.In, p.Out)); + } + + var byType = movements + .GroupBy(x => (x.Row.Type, x.Direction)) + .Select(g => new DashboardMovementTypeRow(g.Key.Type, g.Key.Direction!, g.Sum(x => x.Row.Quantity), g.Count())) + .OrderByDescending(r => r.Units) + .ToList(); + + var ledgerSource = movements.Take(LedgerSize).ToList(); + var locationIds = ledgerSource + .SelectMany(x => new[] + { + (x.Row.FromLocationId, x.Row.FromLocationType), + (x.Row.ToLocationId, x.Row.ToLocationType), + }) + .Where(l => l.Item1.HasValue && l.Item2 is LocationType t && t != LocationType.External) + .Select(l => l.Item1!.Value); + var locationNames = await repo.GetLocationNamesAsync(locationIds); + var userNames = await repo.GetUserNamesAsync(ledgerSource.Select(x => x.Row.CreatedBy)); + + var ledger = ledgerSource.Select(x => new DashboardLedgerRow( + x.Row.Id, x.Row.CreatedAt, x.Row.ItemId, x.Row.ItemName, x.Row.Sku, + x.Row.Type, x.Direction!, x.Row.Quantity, + SideName(x.Row.Type, x.Row.FromLocationId, x.Row.FromLocationType, locationNames, isFrom: true), + SideName(x.Row.Type, x.Row.ToLocationId, x.Row.ToLocationType, locationNames, isFrom: false), + x.Row.ReferenceId, ReferenceKind(x.Row.Type), + x.Row.ReasonCode, userNames.GetValueOrDefault(x.Row.CreatedBy, "—"))).ToList(); + + // ── Inbound (GRN) ────────────────────────────────────────────────── + var grnTotals = await repo.GetGrnTotalsAsync(id, fromDate, toExclusive); + var received = grnTotals.Accepted + grnTotals.Rejected; + var inbound = new DashboardInbound( + grnTotals.Count, grnTotals.Accepted, grnTotals.Rejected, + received == 0 ? 0m : Math.Round(grnTotals.Rejected * 100m / received, 1), + await repo.GetRecentGrnsAsync(id, fromDate, toExclusive, RecentDocumentCount)); + + // ── Outbound (loading sheets) ────────────────────────────────────── + var outbound = new DashboardOutbound( + movements.Where(x => x.Row.Type == MovementType.Load && x.Direction == "Out").Sum(x => x.Row.Quantity), + await repo.GetPendingDispatchUnitsAsync(id), + await repo.GetRecentLoadingSheetsAsync(id, fromDate, toExclusive, RecentDocumentCount)); + + // ── Vehicles ─────────────────────────────────────────────────────── + var vehicleStock = (await repo.GetVehicleStockAsync(id, warehouse.VehicleId)) + .Select(v => new DashboardVehicle(v.VehicleId, v.RegNumber, v.NumberPlate, v.IsLinked, v.Units, v.Skus)) + .ToList(); + var linkedVehicles = await repo.GetDefaultVehiclesAsync(id, warehouse.VehicleId); + + // ── Returns and losses ───────────────────────────────────────────── + decimal Units(MovementType type, string direction) => + movements.Where(x => x.Row.Type == type && x.Direction == direction).Sum(x => x.Row.Quantity); + var losses = new DashboardLosses( + Units(MovementType.PurchaseReturn, "Out"), Units(MovementType.Damage, "Out"), + Units(MovementType.Adjustment, "Out"), Units(MovementType.Adjustment, "In"), + await repo.GetOpenClaimCountAsync(id), + await repo.GetOpenClaimsAsync(id, RecentDocumentCount)); + + // ── Top movers and dead stock ────────────────────────────────────── + var topMovers = movements + .Where(x => x.Row.Type == MovementType.Load && x.Direction == "Out") + .GroupBy(x => x.Row.ItemId) + .Select(g => new DashboardTopMover(g.Key, g.First().Row.Sku, g.First().Row.ItemName, g.Sum(x => x.Row.Quantity))) + .OrderByDescending(m => m.UnitsOut) + .Take(TopListSize) + .ToList(); + + var deadCutoff = now.AddDays(-deadStockDays); + var deadStock = stock + .Where(r => r.Quantity > 0 && r.UpdatedAt < deadCutoff + && (!lastOutbound.TryGetValue(r.ItemId, out var last) || last < deadCutoff)) + .OrderByDescending(r => r.CostValue) + .Take(DeadStockListSize) + .Select(r => new DashboardDeadStock( + r.ItemId, r.Sku, r.Name, r.Quantity, r.CostValue, + lastOutbound.TryGetValue(r.ItemId, out var last) ? last : null)) + .ToList(); + + var kpis = new DashboardKpis( + stock.Sum(r => r.CostValue), stock.Sum(r => r.SaleValue), + stock.Where(r => r.Quantity > 0).Sum(r => r.Quantity), stock.Count(r => r.Quantity > 0), + stock.Count(r => r.Status == "Low"), stock.Count(r => r.Status == "Out"), + expiring.Count(b => b.Status == "NearExpiry"), expiring.Count(b => b.Status == "Expired"), + unitsIn, unitsOut); + + return new WarehouseDashboardResponse( + new WarehouseResponse(warehouse.Id, warehouse.Name, warehouse.Location, warehouse.Type, + warehouse.VehicleId, warehouse.IsActive, warehouse.CreatedAt), + linkedVehicles, new DashboardPeriod(fromDate, toDate), deadStockDays, kpis, + stock, stockByCategory, expiring, daily, byType, ledger, + inbound, outbound, vehicleStock, losses, topMovers, deadStock); + } + + private static DashboardStockRow BuildStockRow(List rows) + { + var first = rows[0]; + var quantity = rows.Sum(r => r.Quantity); + var billable = Math.Max(quantity, 0m); + var status = quantity <= 0 ? "Out" + : first.ReorderLevel.HasValue && quantity < first.ReorderLevel.Value ? "Low" + : "OK"; + var batches = rows + .Where(r => r.BatchId.HasValue && r.Quantity > 0) + .GroupBy(r => r.BatchId!.Value) + .Select(g => new DashboardBatch(g.Key, g.First().BatchNumber, g.First().ExpiryDate, g.Sum(r => r.Quantity))) + .OrderBy(b => b.ExpiryDate ?? DateTime.MaxValue) + .ToList(); + + return new DashboardStockRow( + first.ItemId, first.Sku, first.Name, first.Category, first.Brand, first.Uom, + quantity, first.ReorderLevel, status, + billable * (first.CostPrice ?? 0m), billable * (first.SalePrice ?? 0m), + rows.Max(r => r.UpdatedAt), batches); + } + + private static string? DirectionOf(WarehouseMovementRow m, Guid warehouseId) => + m.ToLocationId == warehouseId && m.FromLocationId != warehouseId ? "In" + : m.FromLocationId == warehouseId && m.ToLocationId != warehouseId ? "Out" + : null; + + private static string? ReferenceKind(MovementType type) => type switch + { + MovementType.PurchaseReceipt => "GRN", + MovementType.Load => "Loading Sheet", + MovementType.PurchaseReturn => "Purchase Return", + _ => null, + }; + + private static string SideName( + MovementType type, Guid? id, LocationType? locationType, Dictionary names, bool isFrom) + { + if (id is not Guid locId || locationType is not LocationType lt) return "—"; + if (lt != LocationType.External) return names.GetValueOrDefault(locId, "—"); + return type switch + { + MovementType.Sale or MovementType.CustomerReturn => "Customer", + MovementType.PurchaseReceipt or MovementType.PurchaseReturn => "Supplier", + MovementType.Damage => "Written Off", + MovementType.Adjustment => "Stock Adjustment", + _ => isFrom ? "External Source" : "External Destination", + }; + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 19dc98e..44c9a40 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -32,6 +32,7 @@ using Distribution.Features.User; using Distribution.Features.Vehicle; using Distribution.Features.ProductReport; using Distribution.Features.Stock; +using Distribution.Features.StockReport; using Distribution.Features.SysConfig; using Distribution.Features.Warehouse; using Distribution.Infra.Authorization; @@ -51,8 +52,10 @@ 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(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -65,6 +68,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(); @@ -85,8 +90,10 @@ 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(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -98,6 +105,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();