203 lines
9.8 KiB
C#
203 lines
9.8 KiB
C#
using Distribution.Data;
|
|
using Distribution.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Distribution.Features.Stock;
|
|
|
|
public class StockRepository(AppDBContext db) : IStockRepository
|
|
{
|
|
public Task<StockBalance?> 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<StockBalance?> 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<List<StockBalance>> 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<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
|
|
db.StockBalances
|
|
.Include(b => b.Item)
|
|
.Where(b => b.LocationId == locationId && b.LocationType == locationType)
|
|
.ToListAsync();
|
|
|
|
public Task<List<StockBalance>> 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<int> 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<List<StockBalance>> GetBalancesByItemAsync(Guid itemId) =>
|
|
db.StockBalances
|
|
.Include(b => b.Item)
|
|
.Where(b => b.FK_ItemId == itemId)
|
|
.ToListAsync();
|
|
|
|
public Task<List<StockBalance>> 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<StockMovement?> 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<StockMovement> 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<List<StockMovement>> 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<int> GetMovementsCountAsync(MovementFilter filter) =>
|
|
FilterMovements(filter).CountAsync();
|
|
|
|
public async Task<MovementSummary> 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<List<StockMovement>> 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<Guid?> 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<ItemBatch?> 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<ItemSerialNumber?> GetSerialAsync(Guid serialId) =>
|
|
db.ItemSerialNumbers.FirstOrDefaultAsync(s => s.Id == serialId);
|
|
|
|
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
|
|
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
|
|
|
|
public async Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix)
|
|
{
|
|
// Only serials of the form <prefix><digits> 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);
|
|
}
|