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, StockMover mover, 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, m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate, m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber); 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> GetLowStockAsync(Guid? locationId) { var balances = await repo.GetLowStockAsync(locationId); var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType))); return balances.Select(b => new LowStockItemResponse( b.FK_ItemId, b.Item.Name, b.LocationId, names.GetValueOrDefault(b.LocationId, "—"), b.Quantity, b.Item.ReorderLevel!.Value)).ToList(); } /// The next unused sequence number for generating serials like "<prefix>0001" for an item. public Task GetNextSerialSequenceAsync(Guid itemId, string prefix) => repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty); 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 GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to) { var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to; return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive); } public async Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize) { var items = await repo.GetMovementsAsync(filter, page, pageSize); var total = await repo.GetMovementsCountAsync(filter); var locations = new List<(Guid, LocationType)>(); foreach (var m in items) { 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) { var item = await itemRepo.GetByIdAsync(request.ItemId) ?? throw new InvalidOperationException("Item not found."); List movements; await uow.BeginAsync(ct); try { movements = await mover.MoveAsync(request.ItemId, item.Name, request.FromLocationId, request.FromLocationType, request.ToLocationId, request.ToLocationType, request.Quantity, MovementType.Transfer, request.ReferenceId, userId); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementsAsync(movements, item, request.Quantity); } public async Task RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default) { var item = await itemRepo.GetByIdAsync(request.ItemId) ?? throw new InvalidOperationException("Item not found."); List movements; await uow.BeginAsync(ct); try { movements = await mover.MoveAsync(request.ItemId, item.Name, request.LocationId, request.LocationType, ExternalLocationId, LocationType.External, request.Quantity, MovementType.Damage, null, userId, request.ReasonCode); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementsAsync(movements, item, request.Quantity); } 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 tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate; if (tracked && request.ReferenceId is null) throw new InvalidOperationException( $"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on."); List movements; await uow.BeginAsync(ct); try { if (tracked) { movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity, request.ReferenceId!.Value, userId); } else { var movement = new StockMovement { FK_ItemId = request.ItemId, FromLocationId = ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = request.VehicleId, ToLocationType = LocationType.Vehicle, Quantity = request.Quantity, Type = MovementType.CustomerReturn, ReferenceId = request.ReferenceId, CreatedBy = userId }; await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); await repo.AddMovementAsync(movement); movements = [movement]; } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementsAsync(movements, item, request.Quantity); } public async Task AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default) { var item = await itemRepo.GetByIdAsync(request.ItemId) ?? throw new InvalidOperationException("Item not found."); List movements; await uow.BeginAsync(ct); try { if (request.Quantity < 0) { movements = await mover.MoveAsync(request.ItemId, item.Name, request.LocationId, request.LocationType, ExternalLocationId, LocationType.External, -request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode); } else { // A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN. if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate) throw new InvalidOperationException( $"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment."); var movement = new StockMovement { FK_ItemId = request.ItemId, FromLocationId = ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = request.LocationId, ToLocationType = request.LocationType, Quantity = request.Quantity, Type = MovementType.Adjustment, ReasonCode = request.ReasonCode, CreatedBy = userId }; await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity); await repo.AddMovementAsync(movement); movements = [movement]; } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } 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. 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; await uow.BeginAsync(ct); try { repo.UpdateMovement(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } // ── Helpers ──────────────────────────────────────────────────────────── /// /// 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. /// private async Task MapMovementsAsync(List movements, Models.Item item, decimal totalQuantity) { var first = movements[0]; first.Item ??= item; return (await MapMovementAsync(first)) with { Quantity = totalQuantity }; } private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity) { 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); } } }