Add stock report features r

This commit is contained in:
2026-10-16 15:41:00 +05:30
parent c626f56cdb
commit d1d2d55352
16 changed files with 1291 additions and 136 deletions
@@ -7,6 +7,8 @@ public interface IStockRepository
// Balance // Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId); Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId); 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>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize); Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId); Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
@@ -19,14 +21,21 @@ public interface IStockRepository
Task AddMovementAsync(StockMovement movement); Task AddMovementAsync(StockMovement movement);
Task<StockMovement?> GetMovementByIdAsync(Guid id); Task<StockMovement?> GetMovementByIdAsync(Guid id);
void UpdateMovement(StockMovement movement); void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize); Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to); 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). // Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber); Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
Task AddItemBatchAsync(ItemBatch batch); Task AddItemBatchAsync(ItemBatch batch);
// Serial numbers // Serial numbers
Task<ItemSerialNumber?> GetSerialAsync(Guid serialId);
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber); 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> /// <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); Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
+19 -1
View File
@@ -52,10 +52,28 @@ public class StockController(StockService service, ICurrentUserService currentUs
[FromQuery] MovementType? type, [FromQuery] MovementType? type,
[FromQuery] DateTime? from, [FromQuery] DateTime? from,
[FromQuery] DateTime? to, [FromQuery] DateTime? to,
[FromQuery] Guid? referenceId,
[FromQuery] Guid? batchId,
[FromQuery] Guid? serialId,
[FromQuery] Guid? createdBy,
[FromQuery] string? reasonCode,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20) [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); return Ok(result);
} }
+26 -1
View File
@@ -45,7 +45,12 @@ public record StockMovementResponse(
DateTime CreatedAt, DateTime CreatedAt,
Guid CreatedBy, Guid CreatedBy,
string? FromLocationName, string? FromLocationName,
string? ToLocationName); string? ToLocationName,
Guid? BatchId = null,
string? BatchNumber = null,
DateTime? BatchExpiryDate = null,
Guid? SerialId = null,
string? SerialNumber = null);
// Write requests // Write requests
public record TransferRequest( public record TransferRequest(
@@ -82,3 +87,23 @@ public record AdjustmentRequest(
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded // 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. // (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
public record UpdateMovementReasonRequest(string ReasonCode); 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);
+234
View File
@@ -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;
}
}
}
+97 -27
View File
@@ -7,16 +7,39 @@ namespace Distribution.Features.Stock;
public class StockRepository(AppDBContext db) : IStockRepository public class StockRepository(AppDBContext db) : IStockRepository
{ {
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) => public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances GetBalanceAsync(itemId, locationId, null, null);
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) => // Looks at rows added earlier in the same unit of work first — they aren't in the database yet,
db.StockBalances // 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) .Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId .FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId); && 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) => public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
db.StockBalances db.StockBalances
@@ -69,41 +92,88 @@ public class StockRepository(AppDBContext db) : IStockRepository
await db.StockMovements.AddAsync(movement); await db.StockMovements.AddAsync(movement);
public Task<StockMovement?> GetMovementByIdAsync(Guid id) => 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) => public void UpdateMovement(StockMovement movement) =>
db.StockMovements.Update(movement); db.StockMovements.Update(movement);
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) private IQueryable<StockMovement> FilterMovements(MovementFilter f)
{
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)
{ {
var query = db.StockMovements.AsQueryable(); var query = db.StockMovements.AsQueryable();
if (locationId.HasValue) if (f.LocationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value); query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value); if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value); if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value); if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value); if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value);
return query.CountAsync(); 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) => public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber); db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
public async Task AddItemBatchAsync(ItemBatch batch) => public async Task AddItemBatchAsync(ItemBatch batch) =>
await db.ItemBatches.AddAsync(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) => public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber); db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
+103 -104
View File
@@ -13,6 +13,7 @@ public class StockService(
ICategoryRepository categoryRepo, ICategoryRepository categoryRepo,
IWarehouseRepository warehouseRepo, IWarehouseRepository warehouseRepo,
IVehicleRepository vehicleRepo, IVehicleRepository vehicleRepo,
StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) => 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) => private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType, new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode, 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) private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
{ {
@@ -112,11 +115,16 @@ public class StockService(
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
} }
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync( public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{ {
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize); var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, 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)>(); var locations = new List<(Guid, LocationType)>();
foreach (var m in items) foreach (var m in items)
@@ -137,60 +145,42 @@ public class StockService(
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default) public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
{ {
await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity); var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
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
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity); movements = await mover.MoveAsync(request.ItemId, item.Name,
await repo.AddMovementAsync(movement); request.FromLocationId, request.FromLocationType,
request.ToLocationId, request.ToLocationType,
request.Quantity, MovementType.Transfer, request.ReferenceId, userId);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } 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) public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
{ {
await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity); var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
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
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity); movements = await mover.MoveAsync(request.ItemId, item.Name,
await repo.AddMovementAsync(movement); request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
request.Quantity, MovementType.Damage, null, userId, request.ReasonCode);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } 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) public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
@@ -204,75 +194,89 @@ public class StockService(
if (!category.AllowCustomerReturn) if (!category.AllowCustomerReturn)
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'."); throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
var movement = new StockMovement var tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate;
{ if (tracked && request.ReferenceId is null)
FK_ItemId = request.ItemId, throw new InvalidOperationException(
FromLocationId = ExternalLocationId, $"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on.");
FromLocationType = LocationType.External,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
Type = MovementType.CustomerReturn,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); if (tracked)
await repo.AddMovementAsync(movement); {
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); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } 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) public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
{ {
var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId); var item = await itemRepo.GetByIdAsync(request.ItemId)
if (balance is not null && balance.Quantity + request.Quantity < 0) ?? throw new InvalidOperationException("Item not found.");
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
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
if (balance is null) if (request.Quantity < 0)
{ {
await repo.AddBalanceAsync(new StockBalance movements = await mover.MoveAsync(request.ItemId, item.Name,
{ request.LocationId, request.LocationType,
FK_ItemId = request.ItemId, ExternalLocationId, LocationType.External,
LocationId = request.LocationId, -request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
LocationType = request.LocationType,
Quantity = request.Quantity
});
} }
else else
{ {
balance.Quantity += request.Quantity; // A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN.
balance.UpdatedAt = DateTime.Now; if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate)
repo.UpdateBalance(balance); 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); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } 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. // 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."); ?? throw new InvalidOperationException("Movement not found.");
movement.ReasonCode = request.ReasonCode; 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); return await MapMovementAsync(movement);
} }
// ── Helpers ──────────────────────────────────────────────────────────── // ── 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); var first = movements[0];
if (balance is null || balance.Quantity < quantity) first.Item ??= item;
throw new InvalidOperationException("Insufficient stock at the specified location."); return (await MapMovementAsync(first)) with { Quantity = totalQuantity };
}
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);
} }
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity) private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
@@ -326,10 +331,4 @@ public class StockService(
repo.UpdateBalance(balance); 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);
}
} }
@@ -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<bool> IsWarehouseAsync(Guid locationId);
Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays);
Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize);
Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize);
}
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
@@ -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);
@@ -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<StockBalance> 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<bool> IsWarehouseAsync(Guid locationId) =>
db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId);
public async Task<OverviewAggregate> 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<LedgerAggregate> 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<Func<StockMovement, bool>> 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<Dictionary<Guid, decimal>> SumByItem(
IQueryable<StockMovement> q, Expression<Func<StockMovement, bool>> 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<ExpiryAggregate> 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);
}
}
@@ -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<bool> IncludesWarehouses(Guid? locationId) =>
!locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value);
public async Task<StockOverview> 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<PagedResponse<StockLedgerRow>> 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<StockLedgerRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<StockExpiryRow>> 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<StockExpiryRow>(page, pageSize, total, mapped);
}
}
@@ -0,0 +1,21 @@
namespace Distribution.Features.Vehicle;
public interface IVehicleDashboardRepository
{
Task<List<VehicleBalanceRow>> GetStockOnBoardAsync(Guid vehicleId);
Task<List<VehicleMovementRow>> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<List<VehicleRideRow>> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<VehicleRideRow?> GetOpenRideAsync(Guid vehicleId);
Task<List<VehicleTaskRow>> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<List<VehicleCollectionRow>> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<VehicleReturnTotals> GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<List<VehicleDriverRow>> GetDriversAsync(IEnumerable<Guid> driverProfileIds);
Task<VehicleNamedRef?> GetWarehouseRefAsync(Guid? warehouseId);
Task<VehicleNamedRef?> GetMobileWarehouseAsync(Guid vehicleId);
Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids);
Task<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> ids);
}
@@ -4,7 +4,7 @@ namespace Distribution.Features.Vehicle;
[ApiController] [ApiController]
[Route("api/vehicles")] [Route("api/vehicles")]
public class VehicleController(VehicleService service) : ControllerBase public class VehicleController(VehicleService service, VehicleDashboardService dashboardService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll() => public async Task<IActionResult> GetAll() =>
@@ -17,6 +17,15 @@ public class VehicleController(VehicleService service) : ControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[HttpGet("{id:guid}/dashboard")]
public async Task<IActionResult> 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] [HttpPost]
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
{ {
@@ -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<VehicleDashboardStockRow> StockOnBoard,
List<VehicleDashboardRide> Rides,
List<VehicleDashboardDailyDelivery> DailyDeliveries,
List<VehicleDashboardDailyMovement> DailyMovement,
List<VehicleDashboardMovementType> MovementByType,
List<VehicleDashboardLedgerRow> Ledger,
List<VehicleDashboardCancelReason> CancelReasons,
List<VehicleDashboardCollection> CollectionsByMethod,
VehicleDashboardReturns Returns,
List<VehicleDashboardTopStore> TopStores,
List<VehicleDashboardDriver> Drivers);
@@ -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<List<VehicleBalanceRow>> 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<List<VehicleMovementRow>> 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<List<VehicleRideRow>> 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<VehicleRideRow?> 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<List<VehicleTaskRow>> 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<List<VehicleCollectionRow>> 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<VehicleReturnTotals> 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<List<VehicleDriverRow>> GetDriversAsync(IEnumerable<Guid> 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<VehicleNamedRef?> GetWarehouseRefAsync(Guid? warehouseId) =>
warehouseId is null
? Task.FromResult<VehicleNamedRef?>(null)
: db.Warehouses.AsNoTracking()
.Where(w => w.Id == warehouseId)
.Select(w => new VehicleNamedRef(w.Id, w.Name))
.FirstOrDefaultAsync();
public Task<VehicleNamedRef?> GetMobileWarehouseAsync(Guid vehicleId) =>
db.Warehouses.AsNoTracking()
.Where(w => w.VehicleId == vehicleId)
.Select(w => new VehicleNamedRef(w.Id, w.Name))
.FirstOrDefaultAsync();
public async Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids)
{
var idList = ids.Distinct().ToList();
var names = new Dictionary<Guid, string>();
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<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> 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);
}
}
@@ -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;
}
/// <summary>Unlike the standard vehicle lookup, this also serves inactive vehicles so their history stays viewable.</summary>
public async Task<VehicleDashboardResponse?> 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<VehicleDashboardDailyDelivery>();
var dailyMovement = new List<VehicleDashboardDailyMovement>();
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<Guid, string> 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",
};
}
}