using Distribution.Models; namespace Distribution.Features.Vehicle; public class VehicleDashboardService(IVehicleRepository vehicleRepo, IVehicleDashboardRepository repo) { private const int LedgerSize = 50; private const int RideListSize = 15; private const int TopListSize = 5; private const int MaxPeriodDays = 366; public static string? Validate(DateTime? from, DateTime? to) { 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 vehicle lookup, this also serves inactive vehicles so their history stays viewable. public async Task GetAsync(Guid id, DateTime? from, DateTime? to) { var vehicle = await vehicleRepo.GetByIdAsync(id); if (vehicle is null) return null; var toDate = (to ?? DateTime.Now).Date; var fromDate = (from ?? toDate.AddDays(-29)).Date; var toExclusive = toDate.AddDays(1); var balances = await repo.GetStockOnBoardAsync(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 rides = await repo.GetRidesAsync(id, fromDate, toExclusive); var tasks = await repo.GetTasksAsync(id, fromDate, toExclusive); var collections = await repo.GetCollectionsAsync(id, fromDate, toExclusive); var returnTotals = await repo.GetReturnTotalsAsync(id, fromDate, toExclusive); var openRide = await repo.GetOpenRideAsync(id); // ── Stock on board ───────────────────────────────────────────────── var stock = balances .GroupBy(b => b.ItemId) .Select(g => { var f = g.First(); var qty = g.Sum(x => x.Quantity); return new VehicleDashboardStockRow( f.ItemId, f.Sku, f.Name, f.Category, f.Uom, qty, qty * (f.SalePrice ?? 0m), g.Max(x => x.UpdatedAt)); }) .OrderBy(r => r.Name) .ToList(); // ── Rides and deliveries ─────────────────────────────────────────── static decimal Km(VehicleRideRow r) => r.Status == RideStatus.Closed && r.MeterEnd > r.MeterStart ? r.MeterEnd - r.MeterStart : 0m; var rideRows = rides.Take(RideListSize) .Select(r => new VehicleDashboardRide( r.Id, r.Status, r.DriverName, r.StartedAt, r.ClosedAt, r.CreatedAt, Km(r), r.Tasks, r.Delivered, r.Failed, r.CancelType, r.CancelReason)) .ToList(); bool IsDelivered(VehicleTaskRow t) => t.Status is DeliveryTaskStatus.Delivered or DeliveryTaskStatus.PartiallyDelivered; var delivered = tasks.Count(IsDelivered); var failed = tasks.Count(t => t.Status == DeliveryTaskStatus.Failed); var pending = tasks.Count(t => t.Status == DeliveryTaskStatus.Pending); var deliveredValue = tasks.Where(IsDelivered).Sum(t => t.DeliveredValue); var dailyDeliveries = new List(); var dailyMovement = new List(); var tasksByDay = tasks.GroupBy(t => t.Date.Date).ToDictionary(g => g.Key); var movesByDay = movements.GroupBy(x => x.Row.CreatedAt.Date).ToDictionary(g => g.Key); for (var d = fromDate; d <= toDate; d = d.AddDays(1)) { var key = d.ToString("yyyy-MM-dd"); tasksByDay.TryGetValue(d, out var dayTasks); movesByDay.TryGetValue(d, out var dayMoves); dailyDeliveries.Add(new VehicleDashboardDailyDelivery( key, dayTasks?.Count(IsDelivered) ?? 0, dayTasks?.Count(t => t.Status == DeliveryTaskStatus.Failed) ?? 0)); dailyMovement.Add(new VehicleDashboardDailyMovement( key, dayMoves?.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity) ?? 0m, dayMoves?.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity) ?? 0m)); } var cancelReasons = rides .Where(r => r.Status == RideStatus.Cancelled && r.CancelType.HasValue) .GroupBy(r => r.CancelType!.Value) .Select(g => new VehicleDashboardCancelReason(g.Key, g.Count())) .OrderByDescending(c => c.Count) .ToList(); var topStores = tasks .Where(IsDelivered) .GroupBy(t => (t.StoreId, t.StoreName)) .Select(g => new VehicleDashboardTopStore(g.Key.StoreId, g.Key.StoreName, g.Count(), g.Sum(t => t.DeliveredValue))) .OrderByDescending(s => s.Value) .Take(TopListSize) .ToList(); // ── Movements ────────────────────────────────────────────────────── var byType = movements .GroupBy(x => (x.Row.Type, x.Direction)) .Select(g => new VehicleDashboardMovementType(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 VehicleDashboardLedgerRow( 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(); // ── Drivers ──────────────────────────────────────────────────────── var driverProfiles = (await repo.GetDriversAsync(rides.Select(r => r.DriverProfileId))) .ToDictionary(d => d.Id); var drivers = rides .GroupBy(r => r.DriverProfileId) .Where(g => driverProfiles.ContainsKey(g.Key)) .Select(g => { var d = driverProfiles[g.Key]; return new VehicleDashboardDriver( d.Id, d.Name, d.LicenseNumber, d.LicenseExpiry, d.MedicalClearanceExpiry, d.IsActive, g.Count(), g.Sum(Km), g.Max(r => r.StartedAt ?? r.CreatedAt)); }) .OrderByDescending(d => d.Rides) .ToList(); // ── Assemble ─────────────────────────────────────────────────────── var settled = delivered + failed; var kpis = new VehicleDashboardKpis( rides.Count, rides.Count(r => r.Status == RideStatus.Closed), rides.Count(r => r.Status == RideStatus.Cancelled), rides.Sum(Km), tasks.Count, delivered, failed, pending, settled == 0 ? 0m : Math.Round(delivered * 100m / settled, 1), deliveredValue, collections.Sum(c => c.Amount), stock.Sum(r => r.Quantity), stock.Sum(r => r.Value), movements.Where(x => x.Row.Type == MovementType.Load && x.Direction == "In").Sum(x => x.Row.Quantity), movements.Where(x => x.Row.Type == MovementType.Sale && x.Direction == "Out").Sum(x => x.Row.Quantity)); var customerReturnUnits = movements .Where(x => x.Row.Type == MovementType.CustomerReturn && x.Direction == "In") .Sum(x => x.Row.Quantity); return new VehicleDashboardResponse( new VehicleResponse(vehicle.Id, vehicle.RegNumber, vehicle.NumberPlate, vehicle.Type, vehicle.IsActive, vehicle.CreatedAt), await repo.GetWarehouseRefAsync(vehicle.DefaultWarehouseId), await repo.GetMobileWarehouseAsync(id), openRide is null ? null : new VehicleDashboardCurrentRide( openRide.Id, openRide.DriverName, openRide.StartedAt, openRide.MeterStart, openRide.Tasks, openRide.Delivered, openRide.Failed), new VehicleDashboardPeriod(fromDate, toDate), kpis, stock, rideRows, dailyDeliveries, dailyMovement, byType, ledger, cancelReasons, collections.Select(c => new VehicleDashboardCollection(c.Method, c.Amount, c.Count)) .OrderByDescending(c => c.Amount).ToList(), new VehicleDashboardReturns(returnTotals.ReturnCount, returnTotals.CreditValue, returnTotals.Units, customerReturnUnits), topStores, drivers); } private static string? DirectionOf(VehicleMovementRow m, Guid vehicleId) => m.ToLocationId == vehicleId && m.FromLocationId != vehicleId ? "In" : m.FromLocationId == vehicleId && m.ToLocationId != vehicleId ? "Out" : null; private static string? ReferenceKind(MovementType type) => type switch { MovementType.Load => "Loading Sheet", MovementType.Sale => "Order", MovementType.CustomerReturn => "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", }; } }