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", }; } }