diff --git a/Distribution/Features/Stock/IStockRepository.cs b/Distribution/Features/Stock/IStockRepository.cs index 57854ec..f12aba1 100644 --- a/Distribution/Features/Stock/IStockRepository.cs +++ b/Distribution/Features/Stock/IStockRepository.cs @@ -7,6 +7,8 @@ public interface IStockRepository // Balance Task GetBalanceAsync(Guid itemId, Guid locationId); Task GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId); + /// Positive balances for the item at the location in pick order: batches by earliest expiry, serials oldest first, untracked last. + Task> GetAllocatableBalancesAsync(Guid itemId, Guid locationId); Task> GetBalancesByLocationAsync(Guid locationId, LocationType locationType); Task> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize); Task GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId); @@ -19,14 +21,21 @@ public interface IStockRepository Task AddMovementAsync(StockMovement movement); Task GetMovementByIdAsync(Guid id); void UpdateMovement(StockMovement movement); - Task> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize); - Task GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to); + Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize); + Task GetMovementsCountAsync(MovementFilter filter); + /// Opening balance, quantity in/out within [from, toExclusive), closing balance, and the live balance to reconcile against. + Task GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive); + + Task> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type); + /// The warehouse the item most recently arrived at from, or null if it never did. + Task GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId); // Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null). Task FindBatchAsync(Guid itemId, string batchNumber); Task AddItemBatchAsync(ItemBatch batch); // Serial numbers + Task GetSerialAsync(Guid serialId); Task SerialNumberExistsAsync(Guid itemId, string serialNumber); /// One past the highest numeric suffix among the item's existing serials that start with (1 if none). Task GetNextSerialSequenceAsync(Guid itemId, string prefix); diff --git a/Distribution/Features/Stock/StockController.cs b/Distribution/Features/Stock/StockController.cs index d9af4fd..91d449f 100644 --- a/Distribution/Features/Stock/StockController.cs +++ b/Distribution/Features/Stock/StockController.cs @@ -52,10 +52,28 @@ public class StockController(StockService service, ICurrentUserService currentUs [FromQuery] MovementType? type, [FromQuery] DateTime? from, [FromQuery] DateTime? to, + [FromQuery] Guid? referenceId, + [FromQuery] Guid? batchId, + [FromQuery] Guid? serialId, + [FromQuery] Guid? createdBy, + [FromQuery] string? reasonCode, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var result = await service.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize); + var filter = new MovementFilter(locationId, itemId, type, from, to, referenceId, batchId, serialId, createdBy, reasonCode); + var result = await service.GetMovementsAsync(filter, page, pageSize); + return Ok(result); + } + + /// Opening / in / out / closing quantity for one item at one location over a date range. + [HttpGet("movements/summary")] + public async Task GetMovementSummary( + [FromQuery] Guid itemId, + [FromQuery] Guid locationId, + [FromQuery] DateTime from, + [FromQuery] DateTime to) + { + var result = await service.GetMovementSummaryAsync(itemId, locationId, from, to); return Ok(result); } diff --git a/Distribution/Features/Stock/StockDTOs.cs b/Distribution/Features/Stock/StockDTOs.cs index a6c79c2..9af95a3 100644 --- a/Distribution/Features/Stock/StockDTOs.cs +++ b/Distribution/Features/Stock/StockDTOs.cs @@ -45,7 +45,12 @@ public record StockMovementResponse( DateTime CreatedAt, Guid CreatedBy, string? FromLocationName, - string? ToLocationName); + string? ToLocationName, + Guid? BatchId = null, + string? BatchNumber = null, + DateTime? BatchExpiryDate = null, + Guid? SerialId = null, + string? SerialNumber = null); // Write requests public record TransferRequest( @@ -82,3 +87,23 @@ public record AdjustmentRequest( // Movements are an immutable ledger — quantity, locations, and type can never change once recorded // (the running stock balance is the sum of all movements). Only the free-text reason/note is editable. public record UpdateMovementReasonRequest(string ReasonCode); + +// Movement report filters +public record MovementFilter( + Guid? LocationId = null, + Guid? ItemId = null, + MovementType? Type = null, + DateTime? From = null, + DateTime? To = null, + Guid? ReferenceId = null, + Guid? BatchId = null, + Guid? SerialId = null, + Guid? CreatedBy = null, + string? ReasonCode = null); + +public record MovementSummary( + decimal OpeningQuantity, + decimal QuantityIn, + decimal QuantityOut, + decimal ClosingQuantity, + decimal CurrentBalance); diff --git a/Distribution/Features/Stock/StockMover.cs b/Distribution/Features/Stock/StockMover.cs new file mode 100644 index 0000000..6047702 --- /dev/null +++ b/Distribution/Features/Stock/StockMover.cs @@ -0,0 +1,234 @@ +using Distribution.Features.SysConfig; +using Distribution.Models; + +namespace Distribution.Features.Stock; + +/// +/// Applies a stock movement end to end: picks which batch/serial balances to take from (FEFO / FIFO), +/// updates the source and destination balances, keeps serial status/location in step, and writes one +/// StockMovement per slice so the ledger always carries the batch/serial that actually moved. +/// Tracks changes only — the calling service owns the unit of work. +/// +public class StockMover(IStockRepository repo, SysConfigService sysConfig) +{ + private static readonly Guid External = StockService.ExternalLocationId; + + private readonly record struct StockKey(Guid LocationId, Guid? BatchId, Guid? SerialId); + + public async Task> MoveAsync( + Guid itemId, string itemName, + Guid fromId, LocationType fromType, + Guid toId, LocationType toType, + decimal quantity, MovementType type, + Guid? referenceId, Guid userId, string? reasonCode = null) + { + if (quantity <= 0) + throw new InvalidOperationException("Quantity must be greater than zero."); + + var balances = await repo.GetAllocatableBalancesAsync(itemId, fromId); + var available = balances.Sum(b => b.Quantity); + if (available < quantity) + throw new InvalidOperationException( + $"Insufficient stock for '{itemName}': available {available}, required {quantity}"); + + var created = new List(); + var remaining = quantity; + foreach (var balance in balances) + { + if (remaining <= 0) break; + var take = Math.Min(balance.Quantity, remaining); + + balance.Quantity -= take; + balance.UpdatedAt = DateTime.Now; + repo.UpdateBalance(balance); + + if (toType != LocationType.External) + await CreditAsync(itemId, toId, toType, balance.FK_ItemBatchId, balance.FK_ItemSerialNumberId, take); + + if (balance.ItemSerialNumber is not null) + await ApplySerialAsync(balance.ItemSerialNumber, balance.Item, toId, toType, type); + + var movement = new StockMovement + { + FK_ItemId = itemId, + FromLocationId = fromId, + FromLocationType = fromType, + ToLocationId = toId, + ToLocationType = toType, + FK_ItemBatchId = balance.FK_ItemBatchId, + FK_ItemSerialNumberId = balance.FK_ItemSerialNumberId, + Quantity = take, + Type = type, + ReasonCode = reasonCode, + ReferenceId = referenceId, + CreatedBy = userId + }; + await repo.AddMovementAsync(movement); + created.Add(movement); + + remaining -= take; + } + return created; + } + + /// + /// Puts returned goods back on the vehicle that delivered them, restoring the same batches/serials that + /// were sold on that ShopPO. Quantity is capped at what was delivered and not yet returned. + /// + public async Task> ReturnToVehicleAsync( + Guid itemId, string itemName, decimal quantity, Guid shopPoId, Guid userId, string? reasonCode = null) + { + if (quantity <= 0) + throw new InvalidOperationException("Quantity must be greater than zero."); + + var sold = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.Sale); + if (sold.Count == 0) + throw new InvalidOperationException($"'{itemName}' was not delivered on this order, so it cannot be returned."); + + var returned = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.CustomerReturn); + + var outstanding = new Dictionary(); + foreach (var m in sold) + { + var key = new StockKey(m.FromLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId); + outstanding[key] = outstanding.GetValueOrDefault(key) + m.Quantity; + } + foreach (var m in returned) + { + var key = new StockKey(m.ToLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId); + outstanding[key] = outstanding.GetValueOrDefault(key) - m.Quantity; + } + + var returnable = outstanding.Where(kv => kv.Value > 0).Sum(kv => kv.Value); + if (quantity > returnable) + throw new InvalidOperationException( + $"Return quantity for '{itemName}' ({quantity}) exceeds the delivered-but-not-yet-returned quantity ({returnable})."); + + var created = new List(); + var remaining = quantity; + foreach (var (key, qty) in outstanding.Where(kv => kv.Value > 0)) + { + if (remaining <= 0) break; + var take = Math.Min(qty, remaining); + + await CreditAsync(itemId, key.LocationId, LocationType.Vehicle, key.BatchId, key.SerialId, take); + + if (key.SerialId is Guid serialId) + { + var serial = await repo.GetSerialAsync(serialId); + if (serial is not null) + await ApplySerialAsync(serial, null, key.LocationId, LocationType.Vehicle, MovementType.CustomerReturn); + } + + var movement = new StockMovement + { + FK_ItemId = itemId, + FromLocationId = External, + FromLocationType = LocationType.External, + ToLocationId = key.LocationId, + ToLocationType = LocationType.Vehicle, + FK_ItemBatchId = key.BatchId, + FK_ItemSerialNumberId = key.SerialId, + Quantity = take, + Type = MovementType.CustomerReturn, + ReasonCode = reasonCode, + ReferenceId = shopPoId, + CreatedBy = userId + }; + await repo.AddMovementAsync(movement); + created.Add(movement); + + remaining -= take; + } + return created; + } + + /// + /// Sends everything still on the vehicle back to the warehouse it was loaded from. + /// + public async Task UnloadVehicleAsync(Guid vehicleId, Guid referenceId, Guid userId, string reasonCode) + { + var onVehicle = (await repo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle)) + .Where(b => b.Quantity > 0) + .GroupBy(b => b.FK_ItemId); + + foreach (var group in onVehicle) + { + var item = group.First().Item; + var warehouseId = await repo.GetLastWarehouseSourceAsync(vehicleId, group.Key) + ?? throw new InvalidOperationException( + $"Cannot unload '{item.Name}': no source warehouse found for the stock on this vehicle."); + + await MoveAsync(group.Key, item.Name, vehicleId, LocationType.Vehicle, + warehouseId, LocationType.Warehouse, group.Sum(b => b.Quantity), + MovementType.Transfer, referenceId, userId, reasonCode); + } + } + + private async Task CreditAsync(Guid itemId, Guid locationId, LocationType locationType, + Guid? batchId, Guid? serialId, decimal quantity) + { + var balance = await repo.GetBalanceAsync(itemId, locationId, batchId, serialId); + if (balance is null) + { + await repo.AddBalanceAsync(new StockBalance + { + FK_ItemId = itemId, + LocationId = locationId, + LocationType = locationType, + FK_ItemBatchId = batchId, + FK_ItemSerialNumberId = serialId, + Quantity = quantity + }); + return; + } + + balance.Quantity += quantity; + balance.UpdatedAt = DateTime.Now; + repo.UpdateBalance(balance); + } + + private async Task ApplySerialAsync(ItemSerialNumber serial, Models.Item? item, Guid toId, LocationType toType, MovementType type) + { + switch (type) + { + case MovementType.Sale: + serial.Status = SerialStatus.Sold; + serial.CurrentLocationId = null; + serial.CurrentLocationType = null; + // Warranty clock starts at sale, not receipt. + if (item is { HasWarranty: true } && await sysConfig.GetWarrantyTrackingEnabledAsync()) + { + var days = serial.WarrantyPeriodDaysOverride ?? await sysConfig.GetDefaultWarrantyPeriodDaysAsync(); + serial.WarrantyExpiresAt = DateTime.Now.AddDays(days); + } + break; + + case MovementType.Damage: + serial.Status = SerialStatus.Damaged; + serial.CurrentLocationId = null; + serial.CurrentLocationType = null; + break; + + case MovementType.CustomerReturn: + serial.Status = SerialStatus.Returned; + serial.CurrentLocationId = toId; + serial.CurrentLocationType = toType; + break; + + default: + if (toType == LocationType.External) + { + serial.CurrentLocationId = null; + serial.CurrentLocationType = null; + } + else + { + if (type == MovementType.Load) serial.Status = SerialStatus.InStock; + serial.CurrentLocationId = toId; + serial.CurrentLocationType = toType; + } + break; + } + } +} diff --git a/Distribution/Features/Stock/StockRepository.cs b/Distribution/Features/Stock/StockRepository.cs index 4130c59..13c62a0 100644 --- a/Distribution/Features/Stock/StockRepository.cs +++ b/Distribution/Features/Stock/StockRepository.cs @@ -7,16 +7,39 @@ namespace Distribution.Features.Stock; public class StockRepository(AppDBContext db) : IStockRepository { public Task GetBalanceAsync(Guid itemId, Guid locationId) => - db.StockBalances - .Include(b => b.Item) - .FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId - && b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null); + GetBalanceAsync(itemId, locationId, null, null); - public Task GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) => - db.StockBalances + // Looks at rows added earlier in the same unit of work first — they aren't in the database yet, + // so a second movement for the same (item, location, batch, serial) would otherwise insert a duplicate. + public async Task GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) + { + var pending = db.StockBalances.Local.FirstOrDefault(b => b.FK_ItemId == itemId && b.LocationId == locationId + && b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId); + if (pending is not null) return pending; + + return await db.StockBalances .Include(b => b.Item) .FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId && b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId); + } + + public async Task> GetAllocatableBalancesAsync(Guid itemId, Guid locationId) + { + var balances = await db.StockBalances + .Include(b => b.Item) + .Include(b => b.ItemBatch) + .Include(b => b.ItemSerialNumber) + .Where(b => b.FK_ItemId == itemId && b.LocationId == locationId && b.Quantity > 0) + .ToListAsync(); + + // Tracked stock first: earliest expiry (FEFO), then oldest received serial (FIFO); untracked rows last. + return balances + .OrderBy(b => b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null ? 1 : 0) + .ThenBy(b => b.ItemBatch?.ExpiryDate ?? DateTime.MaxValue) + .ThenBy(b => b.ItemSerialNumber?.ReceivedAt ?? DateTime.MaxValue) + .ThenBy(b => b.UpdatedAt) + .ToList(); + } public Task> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) => db.StockBalances @@ -69,41 +92,88 @@ public class StockRepository(AppDBContext db) : IStockRepository await db.StockMovements.AddAsync(movement); public Task GetMovementByIdAsync(Guid id) => - db.StockMovements.Include(m => m.Item).FirstOrDefaultAsync(m => m.Id == id); + db.StockMovements.Include(m => m.Item).Include(m => m.ItemBatch).Include(m => m.ItemSerialNumber).FirstOrDefaultAsync(m => m.Id == id); public void UpdateMovement(StockMovement movement) => db.StockMovements.Update(movement); - public Task> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) - { - var query = db.StockMovements.Include(m => m.Item).AsQueryable(); - if (locationId.HasValue) - query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value); - if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value); - if (type.HasValue) query = query.Where(m => m.Type == type.Value); - if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value); - if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value); - return query.OrderByDescending(m => m.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); - } - - public Task GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to) + private IQueryable FilterMovements(MovementFilter f) { var query = db.StockMovements.AsQueryable(); - if (locationId.HasValue) - query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value); - if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value); - if (type.HasValue) query = query.Where(m => m.Type == type.Value); - if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value); - if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value); - return query.CountAsync(); + if (f.LocationId.HasValue) + query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value); + if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value); + if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value); + if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value); + if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value); + if (f.SerialId.HasValue) query = query.Where(m => m.FK_ItemSerialNumberId == f.SerialId.Value); + if (f.CreatedBy.HasValue) query = query.Where(m => m.CreatedBy == f.CreatedBy.Value); + if (!string.IsNullOrWhiteSpace(f.ReasonCode)) query = query.Where(m => m.ReasonCode == f.ReasonCode); + if (f.From.HasValue) query = query.Where(m => m.CreatedAt >= f.From.Value); + if (f.To.HasValue) + { + // A bare date (midnight) means "through the end of that day". + var to = f.To.Value; + var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to; + query = query.Where(m => m.CreatedAt < toExclusive); + } + return query; } + public Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize) => + FilterMovements(filter) + .Include(m => m.Item) + .Include(m => m.ItemBatch) + .Include(m => m.ItemSerialNumber) + .OrderByDescending(m => m.CreatedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + public Task GetMovementsCountAsync(MovementFilter filter) => + FilterMovements(filter).CountAsync(); + + public async Task GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive) + { + var rows = db.StockMovements.AsNoTracking() + .Where(m => m.FK_ItemId == itemId && (m.FromLocationId == locationId || m.ToLocationId == locationId)); + + var before = await rows.Where(m => m.CreatedAt < from) + .GroupBy(_ => 1) + .Select(g => g.Sum(m => m.ToLocationId == locationId ? m.Quantity : 0) + - g.Sum(m => m.FromLocationId == locationId ? m.Quantity : 0)) + .FirstOrDefaultAsync(); + var inRange = rows.Where(m => m.CreatedAt >= from && m.CreatedAt < toExclusive); + var received = await inRange.Where(m => m.ToLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0; + var issued = await inRange.Where(m => m.FromLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0; + var current = await db.StockBalances.AsNoTracking() + .Where(b => b.FK_ItemId == itemId && b.LocationId == locationId) + .SumAsync(b => (decimal?)b.Quantity) ?? 0; + + return new MovementSummary(before, received, issued, before + received - issued, current); + } + + public Task> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type) => + db.StockMovements.AsNoTracking() + .Where(m => m.ReferenceId == referenceId && m.FK_ItemId == itemId && m.Type == type) + .ToListAsync(); + + public Task GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId) => + db.StockMovements.AsNoTracking() + .Where(m => m.ToLocationId == vehicleId && m.FK_ItemId == itemId + && m.FromLocationType == LocationType.Warehouse) + .OrderByDescending(m => m.CreatedAt) + .Select(m => m.FromLocationId) + .FirstOrDefaultAsync(); + public Task FindBatchAsync(Guid itemId, string batchNumber) => db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber); public async Task AddItemBatchAsync(ItemBatch batch) => await db.ItemBatches.AddAsync(batch); + public Task GetSerialAsync(Guid serialId) => + db.ItemSerialNumbers.FirstOrDefaultAsync(s => s.Id == serialId); + public Task SerialNumberExistsAsync(Guid itemId, string serialNumber) => db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber); diff --git a/Distribution/Features/Stock/StockService.cs b/Distribution/Features/Stock/StockService.cs index dd29feb..176d5c5 100644 --- a/Distribution/Features/Stock/StockService.cs +++ b/Distribution/Features/Stock/StockService.cs @@ -13,6 +13,7 @@ public class StockService( ICategoryRepository categoryRepo, IWarehouseRepository warehouseRepo, IVehicleRepository vehicleRepo, + StockMover mover, IUnitOfWork uow) { private static StockBalanceResponse MapBalance(StockBalance b, string locationName) => @@ -37,7 +38,9 @@ public class StockService( private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) => new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType, m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode, - m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName); + m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName, + m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate, + m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber); private async Task MapMovementAsync(StockMovement m) { @@ -112,11 +115,16 @@ public class StockService( balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); } - public async Task> GetMovementsAsync( - Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) + public async Task GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to) { - var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize); - var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to); + var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to; + return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive); + } + + public async Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize) + { + var items = await repo.GetMovementsAsync(filter, page, pageSize); + var total = await repo.GetMovementsCountAsync(filter); var locations = new List<(Guid, LocationType)>(); foreach (var m in items) @@ -137,60 +145,42 @@ public class StockService( public async Task TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default) { - await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity); - - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = request.FromLocationId, - FromLocationType = request.FromLocationType, - ToLocationId = request.ToLocationId, - ToLocationType = request.ToLocationType, - Quantity = request.Quantity, - Type = MovementType.Transfer, - ReferenceId = request.ReferenceId, - CreatedBy = userId - }; + var item = await itemRepo.GetByIdAsync(request.ItemId) + ?? throw new InvalidOperationException("Item not found."); + List movements; await uow.BeginAsync(ct); try { - await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity); - await repo.AddMovementAsync(movement); + movements = await mover.MoveAsync(request.ItemId, item.Name, + request.FromLocationId, request.FromLocationType, + request.ToLocationId, request.ToLocationType, + request.Quantity, MovementType.Transfer, request.ReferenceId, userId); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, request.Quantity); } public async Task RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default) { - await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity); - - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = request.LocationId, - FromLocationType = request.LocationType, - ToLocationId = ExternalLocationId, - ToLocationType = LocationType.External, - Quantity = request.Quantity, - Type = MovementType.Damage, - ReasonCode = request.ReasonCode, - CreatedBy = userId - }; + var item = await itemRepo.GetByIdAsync(request.ItemId) + ?? throw new InvalidOperationException("Item not found."); + List movements; await uow.BeginAsync(ct); try { - await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity); - await repo.AddMovementAsync(movement); + movements = await mover.MoveAsync(request.ItemId, item.Name, + request.LocationId, request.LocationType, + ExternalLocationId, LocationType.External, + request.Quantity, MovementType.Damage, null, userId, request.ReasonCode); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, request.Quantity); } public async Task RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default) @@ -204,75 +194,89 @@ public class StockService( if (!category.AllowCustomerReturn) throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'."); - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = ExternalLocationId, - FromLocationType = LocationType.External, - ToLocationId = request.VehicleId, - ToLocationType = LocationType.Vehicle, - Quantity = request.Quantity, - Type = MovementType.CustomerReturn, - ReferenceId = request.ReferenceId, - CreatedBy = userId - }; + var tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate; + if (tracked && request.ReferenceId is null) + throw new InvalidOperationException( + $"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on."); + List movements; await uow.BeginAsync(ct); try { - await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); - await repo.AddMovementAsync(movement); + if (tracked) + { + movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity, + request.ReferenceId!.Value, userId); + } + else + { + var movement = new StockMovement + { + FK_ItemId = request.ItemId, + FromLocationId = ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = request.VehicleId, + ToLocationType = LocationType.Vehicle, + Quantity = request.Quantity, + Type = MovementType.CustomerReturn, + ReferenceId = request.ReferenceId, + CreatedBy = userId + }; + await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); + await repo.AddMovementAsync(movement); + movements = [movement]; + } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, request.Quantity); } public async Task AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default) { - var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId); - if (balance is not null && balance.Quantity + request.Quantity < 0) - throw new InvalidOperationException("Adjustment would result in negative stock."); - - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId, - FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External, - ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId, - ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External, - Quantity = Math.Abs(request.Quantity), - Type = MovementType.Adjustment, - ReasonCode = request.ReasonCode, - CreatedBy = userId - }; + var item = await itemRepo.GetByIdAsync(request.ItemId) + ?? throw new InvalidOperationException("Item not found."); + List movements; await uow.BeginAsync(ct); try { - if (balance is null) + if (request.Quantity < 0) { - await repo.AddBalanceAsync(new StockBalance - { - FK_ItemId = request.ItemId, - LocationId = request.LocationId, - LocationType = request.LocationType, - Quantity = request.Quantity - }); + movements = await mover.MoveAsync(request.ItemId, item.Name, + request.LocationId, request.LocationType, + ExternalLocationId, LocationType.External, + -request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode); } else { - balance.Quantity += request.Quantity; - balance.UpdatedAt = DateTime.Now; - repo.UpdateBalance(balance); + // A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN. + if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate) + throw new InvalidOperationException( + $"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment."); + + var movement = new StockMovement + { + FK_ItemId = request.ItemId, + FromLocationId = ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = request.LocationId, + ToLocationType = request.LocationType, + Quantity = request.Quantity, + Type = MovementType.Adjustment, + ReasonCode = request.ReasonCode, + CreatedBy = userId + }; + await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity); + await repo.AddMovementAsync(movement); + movements = [movement]; } - await repo.AddMovementAsync(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, Math.Abs(request.Quantity)); } // Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type. @@ -282,28 +286,29 @@ public class StockService( ?? throw new InvalidOperationException("Movement not found."); movement.ReasonCode = request.ReasonCode; - repo.UpdateMovement(movement); - await uow.SaveChangesAsync(ct); + + await uow.BeginAsync(ct); + try + { + repo.UpdateMovement(movement); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } // ── Helpers ──────────────────────────────────────────────────────────── - private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity) + /// + /// One logical operation can produce several movements (one per batch/serial slice). The caller gets a + /// single response for the operation: the first slice's locations, with the total quantity moved. + /// + private async Task MapMovementsAsync(List movements, Models.Item item, decimal totalQuantity) { - var balance = await repo.GetBalanceAsync(itemId, locationId); - if (balance is null || balance.Quantity < quantity) - throw new InvalidOperationException("Insufficient stock at the specified location."); - } - - private async Task DebitBalanceAsync(Guid itemId, Guid locationId, decimal quantity) - { - var balance = await repo.GetBalanceAsync(itemId, locationId) - ?? throw new InvalidOperationException("No stock balance found."); - balance.Quantity -= quantity; - balance.UpdatedAt = DateTime.Now; - repo.UpdateBalance(balance); + var first = movements[0]; + first.Item ??= item; + return (await MapMovementAsync(first)) with { Quantity = totalQuantity }; } private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity) @@ -326,10 +331,4 @@ public class StockService( repo.UpdateBalance(balance); } } - - private async Task ApplyTransferAsync(Guid itemId, Guid fromLocationId, Guid toLocationId, LocationType toLocationType, decimal quantity) - { - await DebitBalanceAsync(itemId, fromLocationId, quantity); - await CreditBalanceAsync(itemId, toLocationId, toLocationType, quantity); - } } diff --git a/Distribution/Features/StockReport/IStockReportRepository.cs b/Distribution/Features/StockReport/IStockReportRepository.cs new file mode 100644 index 0000000..f0a4376 --- /dev/null +++ b/Distribution/Features/StockReport/IStockReportRepository.cs @@ -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 IsWarehouseAsync(Guid locationId); + Task GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays); + Task<(List Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize); + Task<(List Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize); +} diff --git a/Distribution/Features/StockReport/StockReportController.cs b/Distribution/Features/StockReport/StockReportController.cs new file mode 100644 index 0000000..2064d56 --- /dev/null +++ b/Distribution/Features/StockReport/StockReportController.cs @@ -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 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 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 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 }); + } +} diff --git a/Distribution/Features/StockReport/StockReportDTOs.cs b/Distribution/Features/StockReport/StockReportDTOs.cs new file mode 100644 index 0000000..2384a3b --- /dev/null +++ b/Distribution/Features/StockReport/StockReportDTOs.cs @@ -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); diff --git a/Distribution/Features/StockReport/StockReportRepository.cs b/Distribution/Features/StockReport/StockReportRepository.cs new file mode 100644 index 0000000..887fde8 --- /dev/null +++ b/Distribution/Features/StockReport/StockReportRepository.cs @@ -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 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 IsWarehouseAsync(Guid locationId) => + db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId); + + public async Task 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 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> 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> SumByItem( + IQueryable q, Expression> 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 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); + } +} diff --git a/Distribution/Features/StockReport/StockReportService.cs b/Distribution/Features/StockReport/StockReportService.cs new file mode 100644 index 0000000..8cb9866 --- /dev/null +++ b/Distribution/Features/StockReport/StockReportService.cs @@ -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 IncludesWarehouses(Guid? locationId) => + !locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value); + + public async Task 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> 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(page, pageSize, total, mapped); + } + + public async Task> 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(page, pageSize, total, mapped); + } +} diff --git a/Distribution/Features/Vehicle/IVehicleDashboardRepository.cs b/Distribution/Features/Vehicle/IVehicleDashboardRepository.cs new file mode 100644 index 0000000..264a26b --- /dev/null +++ b/Distribution/Features/Vehicle/IVehicleDashboardRepository.cs @@ -0,0 +1,21 @@ +namespace Distribution.Features.Vehicle; + +public interface IVehicleDashboardRepository +{ + Task> GetStockOnBoardAsync(Guid vehicleId); + Task> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + + Task> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + Task GetOpenRideAsync(Guid vehicleId); + Task> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + + Task> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + Task GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + + Task> GetDriversAsync(IEnumerable driverProfileIds); + Task GetWarehouseRefAsync(Guid? warehouseId); + Task GetMobileWarehouseAsync(Guid vehicleId); + + Task> GetLocationNamesAsync(IEnumerable ids); + Task> GetUserNamesAsync(IEnumerable ids); +} diff --git a/Distribution/Features/Vehicle/VehicleController.cs b/Distribution/Features/Vehicle/VehicleController.cs index d19dd1a..899e286 100644 --- a/Distribution/Features/Vehicle/VehicleController.cs +++ b/Distribution/Features/Vehicle/VehicleController.cs @@ -4,7 +4,7 @@ namespace Distribution.Features.Vehicle; [ApiController] [Route("api/vehicles")] -public class VehicleController(VehicleService service) : ControllerBase +public class VehicleController(VehicleService service, VehicleDashboardService dashboardService) : ControllerBase { [HttpGet] public async Task GetAll() => @@ -17,6 +17,15 @@ public class VehicleController(VehicleService 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) + { + var error = VehicleDashboardService.Validate(from, to); + if (error is not null) return BadRequest(new { message = error }); + var result = await dashboardService.GetAsync(id, from, to); + return result is null ? NotFound() : Ok(result); + } + [HttpPost] public async Task Create(CreateVehicleRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Vehicle/VehicleDashboardDTOs.cs b/Distribution/Features/Vehicle/VehicleDashboardDTOs.cs new file mode 100644 index 0000000..fbff798 --- /dev/null +++ b/Distribution/Features/Vehicle/VehicleDashboardDTOs.cs @@ -0,0 +1,100 @@ +using Distribution.Models; + +namespace Distribution.Features.Vehicle; + +// ── Repository projections ───────────────────────────────────────────────── + +public record VehicleBalanceRow( + Guid ItemId, string Sku, string Name, string Category, string Uom, + decimal? SalePrice, decimal Quantity, DateTime UpdatedAt); + +public record VehicleMovementRow( + 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 VehicleRideRow( + Guid Id, RideStatus Status, Guid DriverProfileId, string DriverName, + int MeterStart, int MeterEnd, DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt, + CancelRequestType? CancelType, string? CancelReason, + int Tasks, int Delivered, int Failed); + +public record VehicleTaskRow( + Guid RideId, DeliveryTaskStatus Status, Guid StoreId, string StoreName, + decimal DeliveredValue, DateTime Date); + +public record VehicleCollectionRow(PaymentMethod Method, decimal Amount, int Count); + +public record VehicleReturnTotals(int ReturnCount, decimal CreditValue, decimal Units); + +public record VehicleDriverRow( + Guid Id, string Name, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry, bool IsActive); + +public record VehicleNamedRef(Guid Id, string Name); + +// ── Response ─────────────────────────────────────────────────────────────── + +public record VehicleDashboardPeriod(DateTime From, DateTime To); + +public record VehicleDashboardKpis( + int Rides, int ClosedRides, int CancelledRides, decimal DistanceKm, + int Deliveries, int DeliveredCount, int FailedCount, int PendingCount, decimal SuccessRatePercent, + decimal DeliveredValue, decimal Collections, + decimal StockOnBoardUnits, decimal StockOnBoardValue, + decimal UnitsLoaded, decimal UnitsSold); + +public record VehicleDashboardStockRow( + Guid ItemId, string Sku, string Name, string Category, string Uom, + decimal Quantity, decimal Value, DateTime UpdatedAt); + +public record VehicleDashboardRide( + Guid Id, RideStatus Status, string DriverName, DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt, + decimal DistanceKm, int Tasks, int Delivered, int Failed, CancelRequestType? CancelType, string? CancelReason); + +public record VehicleDashboardDailyDelivery(string Date, int Delivered, int Failed); + +public record VehicleDashboardDailyMovement(string Date, decimal In, decimal Out); + +public record VehicleDashboardMovementType(MovementType Type, string Direction, decimal Units, int Count); + +public record VehicleDashboardLedgerRow( + 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 VehicleDashboardCancelReason(CancelRequestType Type, int Count); + +public record VehicleDashboardCollection(PaymentMethod Method, decimal Amount, int Count); + +public record VehicleDashboardReturns(int ReturnCount, decimal CreditValue, decimal ReturnedUnits, decimal CustomerReturnUnitsOnVehicle); + +public record VehicleDashboardTopStore(Guid StoreId, string StoreName, int Deliveries, decimal Value); + +public record VehicleDashboardDriver( + Guid Id, string Name, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry, + bool IsActive, int Rides, decimal DistanceKm, DateTime? LastRideAt); + +public record VehicleDashboardCurrentRide( + Guid Id, string DriverName, DateTime? StartedAt, int MeterStart, int Tasks, int Delivered, int Failed); + +public record VehicleDashboardResponse( + VehicleResponse Vehicle, + VehicleNamedRef? DefaultWarehouse, + VehicleNamedRef? MobileWarehouse, + VehicleDashboardCurrentRide? CurrentRide, + VehicleDashboardPeriod Period, + VehicleDashboardKpis Kpis, + List StockOnBoard, + List Rides, + List DailyDeliveries, + List DailyMovement, + List MovementByType, + List Ledger, + List CancelReasons, + List CollectionsByMethod, + VehicleDashboardReturns Returns, + List TopStores, + List Drivers); diff --git a/Distribution/Features/Vehicle/VehicleDashboardRepository.cs b/Distribution/Features/Vehicle/VehicleDashboardRepository.cs new file mode 100644 index 0000000..1e3b6b1 --- /dev/null +++ b/Distribution/Features/Vehicle/VehicleDashboardRepository.cs @@ -0,0 +1,130 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Vehicle; + +public class VehicleDashboardRepository(AppDBContext db) : IVehicleDashboardRepository +{ + public Task> GetStockOnBoardAsync(Guid vehicleId) => + db.StockBalances.AsNoTracking() + .Where(b => b.LocationType == LocationType.Vehicle && b.LocationId == vehicleId && b.Quantity > 0) + .Select(b => new VehicleBalanceRow( + b.FK_ItemId, b.Item.Sku, b.Item.Name, b.Item.Category.Name, b.Item.BaseUom.Name, + b.Item.SalePrice, b.Quantity, b.UpdatedAt)) + .ToListAsync(); + + public Task> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.StockMovements.AsNoTracking() + .Where(m => (m.FromLocationId == vehicleId || m.ToLocationId == vehicleId) + && m.CreatedAt >= from && m.CreatedAt < toExclusive) + .OrderByDescending(m => m.CreatedAt) + .Select(m => new VehicleMovementRow( + 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> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.DeliveryRuns.AsNoTracking() + .Where(r => r.FK_VehicleId == vehicleId + && (r.StartedAt ?? r.CreatedAt) >= from && (r.StartedAt ?? r.CreatedAt) < toExclusive) + .OrderByDescending(r => r.StartedAt ?? r.CreatedAt) + .Select(r => new VehicleRideRow( + r.Id, r.Status, r.FK_DriverProfileId, r.DriverProfile.User.FullName, + r.Meterstart, r.Meterend, r.StartedAt, r.ClosedAt, r.CreatedAt, + r.CancelTypeCode, r.CancelReason, + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id + && (t.Status == DeliveryTaskStatus.Delivered || t.Status == DeliveryTaskStatus.PartiallyDelivered)), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id && t.Status == DeliveryTaskStatus.Failed))) + .ToListAsync(); + + public Task GetOpenRideAsync(Guid vehicleId) => + db.DeliveryRuns.AsNoTracking() + .Where(r => r.FK_VehicleId == vehicleId && r.Status == RideStatus.Open) + .Select(r => new VehicleRideRow( + r.Id, r.Status, r.FK_DriverProfileId, r.DriverProfile.User.FullName, + r.Meterstart, r.Meterend, r.StartedAt, r.ClosedAt, r.CreatedAt, + r.CancelTypeCode, r.CancelReason, + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id + && (t.Status == DeliveryTaskStatus.Delivered || t.Status == DeliveryTaskStatus.PartiallyDelivered)), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id && t.Status == DeliveryTaskStatus.Failed))) + .FirstOrDefaultAsync(); + + public Task> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.DeliveryTasks.AsNoTracking() + .Where(t => t.Ride.FK_VehicleId == vehicleId + && (t.Ride.StartedAt ?? t.Ride.CreatedAt) >= from && (t.Ride.StartedAt ?? t.Ride.CreatedAt) < toExclusive) + .Select(t => new VehicleTaskRow( + t.FK_RideId, t.Status, t.ShopPO.FK_StoreId, t.ShopPO.Store.StoreName, + t.ShopPO.Items.Sum(i => i.QuantityFulfilled * i.UnitPrice), + t.Ride.StartedAt ?? t.Ride.CreatedAt)) + .ToListAsync(); + + public Task> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.Payments.AsNoTracking() + .Where(p => p.CollectedAt >= from && p.CollectedAt < toExclusive + && db.DeliveryTasks.Any(t => t.Ride.FK_VehicleId == vehicleId && t.FK_ShopPOId == p.Invoice.FK_ShopPOId)) + .GroupBy(p => p.Method) + .Select(g => new VehicleCollectionRow(g.Key, g.Sum(p => p.Amount), g.Count())) + .ToListAsync(); + + public async Task GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) + { + var returns = db.SaleReturns.AsNoTracking() + .Where(r => r.CreatedAt >= from && r.CreatedAt < toExclusive + && db.DeliveryTasks.Any(t => t.Ride.FK_VehicleId == vehicleId && t.FK_ShopPOId == r.FK_ShopPOId)); + var count = await returns.CountAsync(); + var credit = await returns.SumAsync(r => (decimal?)r.TotalCreditAmount) ?? 0m; + var units = await returns.SelectMany(r => r.Items).SumAsync(i => (decimal?)i.Quantity) ?? 0m; + return new VehicleReturnTotals(count, credit, units); + } + + public Task> GetDriversAsync(IEnumerable driverProfileIds) + { + var ids = driverProfileIds.Distinct().ToList(); + return db.DriverProfiles.AsNoTracking() + .Where(d => ids.Contains(d.Id)) + .Select(d => new VehicleDriverRow( + d.Id, d.User.FullName, d.LicenseNumber, d.LicenseExpiry, d.MedicalClearanceExpiry, d.IsActive)) + .ToListAsync(); + } + + public Task GetWarehouseRefAsync(Guid? warehouseId) => + warehouseId is null + ? Task.FromResult(null) + : db.Warehouses.AsNoTracking() + .Where(w => w.Id == warehouseId) + .Select(w => new VehicleNamedRef(w.Id, w.Name)) + .FirstOrDefaultAsync(); + + public Task GetMobileWarehouseAsync(Guid vehicleId) => + db.Warehouses.AsNoTracking() + .Where(w => w.VehicleId == vehicleId) + .Select(w => new VehicleNamedRef(w.Id, w.Name)) + .FirstOrDefaultAsync(); + + 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/Vehicle/VehicleDashboardService.cs b/Distribution/Features/Vehicle/VehicleDashboardService.cs new file mode 100644 index 0000000..40977aa --- /dev/null +++ b/Distribution/Features/Vehicle/VehicleDashboardService.cs @@ -0,0 +1,206 @@ +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", + }; + } +}