using Distribution.Features.SysConfig;
using Distribution.Models;
namespace Distribution.Features.Stock;
///
/// Applies a stock movement end to end: picks which batch/serial balances to take from (FEFO / FIFO),
/// updates the source and destination balances, keeps serial status/location in step, and writes one
/// StockMovement per slice so the ledger always carries the batch/serial that actually moved.
/// Tracks changes only — the calling service owns the unit of work.
///
public class StockMover(IStockRepository repo, SysConfigService sysConfig)
{
private static readonly Guid External = StockService.ExternalLocationId;
private readonly record struct StockKey(Guid LocationId, Guid? BatchId, Guid? SerialId);
public async Task> MoveAsync(
Guid itemId, string itemName,
Guid fromId, LocationType fromType,
Guid toId, LocationType toType,
decimal quantity, MovementType type,
Guid? referenceId, Guid userId, string? reasonCode = null)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be greater than zero.");
var balances = await repo.GetAllocatableBalancesAsync(itemId, fromId);
var available = balances.Sum(b => b.Quantity);
if (available < quantity)
throw new InvalidOperationException(
$"Insufficient stock for '{itemName}': available {available}, required {quantity}");
var created = new List();
var remaining = quantity;
foreach (var balance in balances)
{
if (remaining <= 0) break;
var take = Math.Min(balance.Quantity, remaining);
balance.Quantity -= take;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
if (toType != LocationType.External)
await CreditAsync(itemId, toId, toType, balance.FK_ItemBatchId, balance.FK_ItemSerialNumberId, take);
if (balance.ItemSerialNumber is not null)
await ApplySerialAsync(balance.ItemSerialNumber, balance.Item, toId, toType, type);
var movement = new StockMovement
{
FK_ItemId = itemId,
FromLocationId = fromId,
FromLocationType = fromType,
ToLocationId = toId,
ToLocationType = toType,
FK_ItemBatchId = balance.FK_ItemBatchId,
FK_ItemSerialNumberId = balance.FK_ItemSerialNumberId,
Quantity = take,
Type = type,
ReasonCode = reasonCode,
ReferenceId = referenceId,
CreatedBy = userId
};
await repo.AddMovementAsync(movement);
created.Add(movement);
remaining -= take;
}
return created;
}
///
/// Puts returned goods back on the vehicle that delivered them, restoring the same batches/serials that
/// were sold on that ShopPO. Quantity is capped at what was delivered and not yet returned.
///
public async Task> ReturnToVehicleAsync(
Guid itemId, string itemName, decimal quantity, Guid shopPoId, Guid userId, string? reasonCode = null)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be greater than zero.");
var sold = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.Sale);
if (sold.Count == 0)
throw new InvalidOperationException($"'{itemName}' was not delivered on this order, so it cannot be returned.");
var returned = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.CustomerReturn);
var outstanding = new Dictionary();
foreach (var m in sold)
{
var key = new StockKey(m.FromLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
outstanding[key] = outstanding.GetValueOrDefault(key) + m.Quantity;
}
foreach (var m in returned)
{
var key = new StockKey(m.ToLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
outstanding[key] = outstanding.GetValueOrDefault(key) - m.Quantity;
}
var returnable = outstanding.Where(kv => kv.Value > 0).Sum(kv => kv.Value);
if (quantity > returnable)
throw new InvalidOperationException(
$"Return quantity for '{itemName}' ({quantity}) exceeds the delivered-but-not-yet-returned quantity ({returnable}).");
var created = new List();
var remaining = quantity;
foreach (var (key, qty) in outstanding.Where(kv => kv.Value > 0))
{
if (remaining <= 0) break;
var take = Math.Min(qty, remaining);
await CreditAsync(itemId, key.LocationId, LocationType.Vehicle, key.BatchId, key.SerialId, take);
if (key.SerialId is Guid serialId)
{
var serial = await repo.GetSerialAsync(serialId);
if (serial is not null)
await ApplySerialAsync(serial, null, key.LocationId, LocationType.Vehicle, MovementType.CustomerReturn);
}
var movement = new StockMovement
{
FK_ItemId = itemId,
FromLocationId = External,
FromLocationType = LocationType.External,
ToLocationId = key.LocationId,
ToLocationType = LocationType.Vehicle,
FK_ItemBatchId = key.BatchId,
FK_ItemSerialNumberId = key.SerialId,
Quantity = take,
Type = MovementType.CustomerReturn,
ReasonCode = reasonCode,
ReferenceId = shopPoId,
CreatedBy = userId
};
await repo.AddMovementAsync(movement);
created.Add(movement);
remaining -= take;
}
return created;
}
///
/// Sends everything still on the vehicle back to the warehouse it was loaded from.
///
public async Task UnloadVehicleAsync(Guid vehicleId, Guid referenceId, Guid userId, string reasonCode)
{
var onVehicle = (await repo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle))
.Where(b => b.Quantity > 0)
.GroupBy(b => b.FK_ItemId);
foreach (var group in onVehicle)
{
var item = group.First().Item;
var warehouseId = await repo.GetLastWarehouseSourceAsync(vehicleId, group.Key)
?? throw new InvalidOperationException(
$"Cannot unload '{item.Name}': no source warehouse found for the stock on this vehicle.");
await MoveAsync(group.Key, item.Name, vehicleId, LocationType.Vehicle,
warehouseId, LocationType.Warehouse, group.Sum(b => b.Quantity),
MovementType.Transfer, referenceId, userId, reasonCode);
}
}
private async Task CreditAsync(Guid itemId, Guid locationId, LocationType locationType,
Guid? batchId, Guid? serialId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId, batchId, serialId);
if (balance is null)
{
await repo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = locationId,
LocationType = locationType,
FK_ItemBatchId = batchId,
FK_ItemSerialNumberId = serialId,
Quantity = quantity
});
return;
}
balance.Quantity += quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
}
private async Task ApplySerialAsync(ItemSerialNumber serial, Models.Item? item, Guid toId, LocationType toType, MovementType type)
{
switch (type)
{
case MovementType.Sale:
serial.Status = SerialStatus.Sold;
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
// Warranty clock starts at sale, not receipt.
if (item is { HasWarranty: true } && await sysConfig.GetWarrantyTrackingEnabledAsync())
{
var days = serial.WarrantyPeriodDaysOverride ?? await sysConfig.GetDefaultWarrantyPeriodDaysAsync();
serial.WarrantyExpiresAt = DateTime.Now.AddDays(days);
}
break;
case MovementType.Damage:
serial.Status = SerialStatus.Damaged;
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
break;
case MovementType.CustomerReturn:
serial.Status = SerialStatus.Returned;
serial.CurrentLocationId = toId;
serial.CurrentLocationType = toType;
break;
default:
if (toType == LocationType.External)
{
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
}
else
{
if (type == MovementType.Load) serial.Status = SerialStatus.InStock;
serial.CurrentLocationId = toId;
serial.CurrentLocationType = toType;
}
break;
}
}
}