using Distribution.Features.Category; using Distribution.Features.Item; using Distribution.Features.Vehicle; using Distribution.Features.Warehouse; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Stock; public class StockService( IStockRepository repo, IItemRepository itemRepo, ICategoryRepository categoryRepo, IWarehouseRepository warehouseRepo, IVehicleRepository vehicleRepo, IUnitOfWork uow) { private static StockBalanceResponse MapBalance(StockBalance b, string locationName) => new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt, locationName); // Sentinel id used for the "External" side of a movement (customer, supplier, write-off, adjustment // source/sink) that has no real Warehouse/Vehicle record — every movement must always show both a // From and a To, even when one side is outside the tracked system. public static readonly Guid ExternalLocationId = Guid.Empty; private static string ExternalLocationLabel(MovementType type, bool isFrom) => type switch { MovementType.Sale => "Customer", MovementType.CustomerReturn => "Customer", MovementType.PurchaseReceipt => "Supplier", MovementType.PurchaseReturn => "Supplier", MovementType.Damage => "Written Off", MovementType.Adjustment => "Stock Adjustment", _ => isFrom ? "External Source" : "External Destination", }; 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); private async Task MapMovementAsync(StockMovement m) { var locations = new List<(Guid, LocationType)>(); if (m.FromLocationId is Guid fromId && m.FromLocationType is LocationType fromType && fromType != LocationType.External) locations.Add((fromId, fromType)); if (m.ToLocationId is Guid toId && m.ToLocationType is LocationType toType && toType != LocationType.External) locations.Add((toId, toType)); var names = await ResolveLocationNamesAsync(locations); return MapMovement(m, ResolveMovementSideName(m, names, isFrom: true), ResolveMovementSideName(m, names, isFrom: false)); } private static string? ResolveMovementSideName(StockMovement m, Dictionary names, bool isFrom) { var id = isFrom ? m.FromLocationId : m.ToLocationId; var type = isFrom ? m.FromLocationType : m.ToLocationType; if (id is not Guid locId || type is not LocationType locType) return null; return locType == LocationType.External ? ExternalLocationLabel(m.Type, isFrom) : names.GetValueOrDefault(locId, "—"); } private async Task> ResolveLocationNamesAsync(IEnumerable<(Guid Id, LocationType Type)> locations) { var warehouseIds = locations.Where(l => l.Type == LocationType.Warehouse).Select(l => l.Id).Distinct().ToList(); var vehicleIds = locations.Where(l => l.Type == LocationType.Vehicle).Select(l => l.Id).Distinct().ToList(); var names = new Dictionary(); foreach (var id in warehouseIds) { var warehouse = await warehouseRepo.GetByIdAsync(id); names[id] = warehouse?.Name ?? "Unknown Warehouse"; } foreach (var id in vehicleIds) { var vehicle = await vehicleRepo.GetByIdAsync(id); names[id] = vehicle?.RegNumber ?? "Unknown Vehicle"; } return names; } // ── Queries ──────────────────────────────────────────────────────────── public async Task> GetBalancesAsync( Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize) { var items = await repo.GetBalancesAsync(locationId, locationType, itemId, page, pageSize); var total = await repo.GetBalancesCountAsync(locationId, locationType, itemId); var names = await ResolveLocationNamesAsync(items.Select(b => (b.LocationId, b.LocationType))); return new PagedResponse(page, pageSize, total, items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); } public async Task GetItemTotalAsync(Guid itemId) { var item = await itemRepo.GetByIdAsync(itemId); if (item is null || !item.IsActive) return null; var balances = await repo.GetBalancesByItemAsync(itemId); var total = balances.Sum(b => b.Quantity); var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType))); return new StockTotalResponse(itemId, item.Name, total, balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); } public async Task> GetMovementsAsync( 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 total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to); var locations = new List<(Guid, LocationType)>(); foreach (var m in items) { if (m.FromLocationId is Guid fromId && m.FromLocationType is LocationType fromType && fromType != LocationType.External) locations.Add((fromId, fromType)); if (m.ToLocationId is Guid toId && m.ToLocationType is LocationType toType && toType != LocationType.External) locations.Add((toId, toType)); } var names = await ResolveLocationNamesAsync(locations); return new PagedResponse(page, pageSize, total, items.Select(m => MapMovement( m, ResolveMovementSideName(m, names, isFrom: true), ResolveMovementSideName(m, names, isFrom: false))).ToList()); } // ── Writes ───────────────────────────────────────────────────────────── public async Task 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 }; await uow.BeginAsync(ct); try { await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity); await repo.AddMovementAsync(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } public async Task 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 }; await uow.BeginAsync(ct); try { await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity); await repo.AddMovementAsync(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } public async Task RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default) { var item = await itemRepo.GetByIdAsync(request.ItemId) ?? throw new InvalidOperationException("Item not found."); var category = await categoryRepo.GetByIdAsync(item.FK_CategoryId) ?? throw new InvalidOperationException("Category not found."); 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 }; await uow.BeginAsync(ct); try { await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); await repo.AddMovementAsync(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } public async Task 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 }; await uow.BeginAsync(ct); try { if (balance is null) { await repo.AddBalanceAsync(new StockBalance { FK_ItemId = request.ItemId, LocationId = request.LocationId, LocationType = request.LocationType, Quantity = request.Quantity }); } else { balance.Quantity += request.Quantity; balance.UpdatedAt = DateTime.Now; repo.UpdateBalance(balance); } await repo.AddMovementAsync(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } // Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type. public async Task UpdateMovementReasonAsync(Guid movementId, UpdateMovementReasonRequest request, CancellationToken ct = default) { var movement = await repo.GetMovementByIdAsync(movementId) ?? throw new InvalidOperationException("Movement not found."); movement.ReasonCode = request.ReasonCode; repo.UpdateMovement(movement); await uow.SaveChangesAsync(ct); return await MapMovementAsync(movement); } // ── Helpers ──────────────────────────────────────────────────────────── private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity) { 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); } private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity) { var balance = await repo.GetBalanceAsync(itemId, locationId); if (balance is null) { await repo.AddBalanceAsync(new StockBalance { FK_ItemId = itemId, LocationId = locationId, LocationType = locationType, Quantity = quantity }); } else { balance.Quantity += quantity; balance.UpdatedAt = DateTime.Now; 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); } }