using Distribution.Features.Category; using Distribution.Features.Item; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Stock; public class StockService(IStockRepository repo, IItemRepository itemRepo, ICategoryRepository categoryRepo, IUnitOfWork uow) { private static StockBalanceResponse MapBalance(StockBalance b) => new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt); private static StockMovementResponse MapMovement(StockMovement m) => 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); // ── 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); return new PagedResponse(page, pageSize, total, items.Select(MapBalance).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); return new StockTotalResponse(itemId, item.Name, total, balances.Select(MapBalance).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); return new PagedResponse(page, pageSize, total, items.Select(MapMovement).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 MapMovement(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, 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 MapMovement(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, 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 MapMovement(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 : null, FromLocationType = request.Quantity < 0 ? request.LocationType : null, ToLocationId = request.Quantity > 0 ? request.LocationId : null, ToLocationType = request.Quantity > 0 ? request.LocationType : null, 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 MapMovement(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); } }