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; } } }