Add stock report features r
This commit is contained in:
@@ -7,6 +7,8 @@ public interface IStockRepository
|
||||
// Balance
|
||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
|
||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
|
||||
/// <summary>Positive balances for the item at the location in pick order: batches by earliest expiry, serials oldest first, untracked last.</summary>
|
||||
Task<List<StockBalance>> GetAllocatableBalancesAsync(Guid itemId, Guid locationId);
|
||||
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
|
||||
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
|
||||
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
||||
@@ -19,14 +21,21 @@ public interface IStockRepository
|
||||
Task AddMovementAsync(StockMovement movement);
|
||||
Task<StockMovement?> GetMovementByIdAsync(Guid id);
|
||||
void UpdateMovement(StockMovement movement);
|
||||
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
|
||||
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
|
||||
Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize);
|
||||
Task<int> GetMovementsCountAsync(MovementFilter filter);
|
||||
/// <summary>Opening balance, quantity in/out within [from, toExclusive), closing balance, and the live balance to reconcile against.</summary>
|
||||
Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive);
|
||||
|
||||
Task<List<StockMovement>> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type);
|
||||
/// <summary>The warehouse the item most recently arrived at <paramref name="vehicleId"/> from, or null if it never did.</summary>
|
||||
Task<Guid?> GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId);
|
||||
|
||||
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
|
||||
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
|
||||
Task AddItemBatchAsync(ItemBatch batch);
|
||||
|
||||
// Serial numbers
|
||||
Task<ItemSerialNumber?> GetSerialAsync(Guid serialId);
|
||||
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
|
||||
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
|
||||
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Opening / in / out / closing quantity for one item at one location over a date range.</summary>
|
||||
[HttpGet("movements/summary")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<List<StockMovement>> 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<StockMovement>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<List<StockMovement>> 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<StockKey, decimal>();
|
||||
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<StockMovement>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends everything still on the vehicle back to the warehouse it was loaded from.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,39 @@ namespace Distribution.Features.Stock;
|
||||
public class StockRepository(AppDBContext db) : IStockRepository
|
||||
{
|
||||
public Task<StockBalance?> 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<StockBalance?> 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<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
|
||||
@@ -69,41 +92,88 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
||||
await db.StockMovements.AddAsync(movement);
|
||||
|
||||
public Task<StockMovement?> 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<List<StockMovement>> 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<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to)
|
||||
private IQueryable<StockMovement> 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<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);
|
||||
|
||||
|
||||
@@ -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<StockMovementResponse> MapMovementAsync(StockMovement m)
|
||||
{
|
||||
@@ -112,11 +115,16 @@ public class StockService(
|
||||
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
|
||||
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
|
||||
public async Task<MovementSummary> 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<PagedResponse<StockMovementResponse>> 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<StockMovementResponse> 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<StockMovement> 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<StockMovementResponse> 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<StockMovement> 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<StockMovementResponse> 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<StockMovement> 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<StockMovementResponse> 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<StockMovement> 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)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<StockMovementResponse> MapMovementsAsync(List<StockMovement> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user