using Distribution.Data; using Distribution.Models; using Microsoft.EntityFrameworkCore; namespace Distribution.Features.Stock; public class StockRepository(AppDBContext db) : IStockRepository { public Task GetBalanceAsync(Guid itemId, Guid locationId) => GetBalanceAsync(itemId, locationId, null, null); // 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 .Include(b => b.Item) .Where(b => b.LocationId == locationId && b.LocationType == locationType) .ToListAsync(); public Task> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize) { var query = db.StockBalances.Include(b => b.Item).AsQueryable(); if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value); if (locationType.HasValue) query = query.Where(b => b.LocationType == locationType.Value); if (itemId.HasValue) query = query.Where(b => b.FK_ItemId == itemId.Value); return query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); } public Task GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId) { var query = db.StockBalances.AsQueryable(); if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value); if (locationType.HasValue) query = query.Where(b => b.LocationType == locationType.Value); if (itemId.HasValue) query = query.Where(b => b.FK_ItemId == itemId.Value); return query.CountAsync(); } public Task> GetBalancesByItemAsync(Guid itemId) => db.StockBalances .Include(b => b.Item) .Where(b => b.FK_ItemId == itemId) .ToListAsync(); public Task> GetLowStockAsync(Guid? locationId) { var query = db.StockBalances .Include(b => b.Item) .Where(b => b.LocationType == LocationType.Warehouse && b.Item.ReorderLevel != null && b.Quantity < b.Item.ReorderLevel); if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value); return query.OrderBy(b => b.Quantity).ToListAsync(); } public async Task AddBalanceAsync(StockBalance balance) => await db.StockBalances.AddAsync(balance); public void UpdateBalance(StockBalance balance) => db.StockBalances.Update(balance); public async Task AddMovementAsync(StockMovement movement) => await db.StockMovements.AddAsync(movement); public Task GetMovementByIdAsync(Guid 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); private IQueryable FilterMovements(MovementFilter f) { var query = db.StockMovements.AsQueryable(); 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); public async Task GetNextSerialSequenceAsync(Guid itemId, string prefix) { // Only serials of the form count; anything else (a supplier's own serial that // happens to start with the prefix) is ignored for numbering. var serials = await db.ItemSerialNumbers .AsNoTracking() .Where(s => s.FK_ItemId == itemId && s.SerialNumber.StartsWith(prefix)) .Select(s => s.SerialNumber) .ToListAsync(); var max = 0L; foreach (var serial in serials) { var suffix = serial[prefix.Length..]; if (suffix.Length is > 0 and <= 9 && suffix.All(char.IsDigit) && long.TryParse(suffix, out var n)) max = Math.Max(max, n); } return (int)max + 1; } public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) => await db.ItemSerialNumbers.AddAsync(serial); }