using Distribution.Features.Ride; using Distribution.Features.Stock; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.LoadingEvent; public class LoadingSheetService( ILoadingSheetRepository repo, IRideRepository rideRepo, IStockRepository stockRepo, IUnitOfWork uow) { public async Task> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) { var total = await repo.GetCountAsync(rideId, status); var items = await repo.GetAllAsync(rideId, status, page, pageSize); return new PagedResponse(page, pageSize, total, items.Select(ToResponse).ToList()); } public async Task GetByIdAsync(Guid id) { var sheet = await repo.GetByIdAsync(id); return sheet is null ? null : ToResponse(sheet); } public async Task CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(request.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); if (ride.Status != RideStatus.Open) throw new InvalidOperationException("A loading sheet can only be created for an Open ride"); var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId); if (existing is not null) throw new InvalidOperationException("This ride already has an active loading sheet"); var sheet = new LoadingSheet { FK_RideId = ride.Id, FK_SourceWarehouseId = ride.FK_SourceWarehouseId, Status = LoadingSheetStatus.Pending }; if (ride.RideType == RideType.Delivery) { var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId); foreach (var (itemId, qty) in orderItems) sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty }); } await uow.BeginAsync(ct); try { await repo.AddAsync(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(sheet.Id); return ToResponse(saved!); } public async Task SetItemsAsync(Guid id, SetItemsRequest request, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.Pending) throw new InvalidOperationException("Items can only be changed while the sheet is Pending"); await uow.BeginAsync(ct); try { repo.RemoveItems(sheet.Items); await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem { FK_LoadingSheetId = sheet.Id, FK_ItemId = i.FK_ItemId, QuantityDispatched = i.Quantity })); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(sheet.Id); return ToResponse(saved!); } public async Task ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.Pending) throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status"); if (!sheet.Items.Any(i => i.QuantityDispatched > 0)) throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero"); sheet.Status = LoadingSheetStatus.DispatchConfirmed; sheet.DispatchConfirmedAt = DateTime.Now; sheet.DispatchConfirmedBy = userId; await uow.BeginAsync(ct); try { repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(sheet); } public async Task SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.DispatchConfirmed) throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed"); foreach (var input in request.Items) { var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId) ?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet"); item.QuantityReceived = input.QuantityReceived; } await uow.BeginAsync(ct); try { repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(sheet); } public async Task ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.DispatchConfirmed) throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status"); if (sheet.Items.Any(i => !i.QuantityReceived.HasValue)) throw new InvalidOperationException("All items must have a received quantity before confirming receipt"); var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched); await uow.BeginAsync(ct); try { if (hasDiscrepancy) { sheet.Status = LoadingSheetStatus.DiscrepancyFlagged; } else { sheet.Status = LoadingSheetStatus.ReceiptConfirmed; sheet.ReceiptConfirmedAt = DateTime.Now; sheet.ReceiptConfirmedBy = userId; var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList(); await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); ride.Status = RideStatus.Active; ride.ActivatedAt = DateTime.Now; ride.Meterstart = request.Meterstart; rideRepo.Update(ride); } repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(sheet); } public async Task ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged) throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status"); foreach (var input in request.Items) { var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId) ?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet"); if (input.QuantityResolved < 0) throw new InvalidOperationException("Resolved quantity cannot be negative"); item.QuantityResolved = input.QuantityResolved; } if (sheet.Items.Any(i => !i.QuantityResolved.HasValue)) throw new InvalidOperationException("All items must have a resolved quantity"); var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); sheet.Status = LoadingSheetStatus.Resolved; sheet.ReceiptConfirmedAt = DateTime.Now; sheet.ReceiptConfirmedBy = userId; var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList(); await uow.BeginAsync(ct); try { await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); ride.Status = RideStatus.Active; ride.ActivatedAt = DateTime.Now; ride.Meterstart = request.Meterstart; rideRepo.Update(ride); repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(sheet); } public async Task PatchReceiptAsync(Guid id, List items, Guid userId, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.DispatchConfirmed) throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status"); foreach (var input in items) { var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId) ?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet"); sheetItem.QuantityReceived = input.QuantityReceived; } var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched); await uow.BeginAsync(ct); try { if (hasDiscrepancy) { sheet.Status = LoadingSheetStatus.DiscrepancyFlagged; } else { sheet.Status = LoadingSheetStatus.ReceiptConfirmed; sheet.ReceiptConfirmedAt = DateTime.Now; sheet.ReceiptConfirmedBy = userId; var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList(); await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); ride.Status = RideStatus.Active; ride.ActivatedAt = DateTime.Now; ride.Meterstart = 0; rideRepo.Update(ride); } repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(sheet); } public async Task PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged) throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status"); foreach (var item in sheet.Items) { if (!item.QuantityReceived.HasValue) throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from"); item.QuantityResolved = item.QuantityReceived.Value; } var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); sheet.Status = LoadingSheetStatus.Resolved; sheet.ReceiptConfirmedAt = DateTime.Now; sheet.ReceiptConfirmedBy = userId; var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList(); await uow.BeginAsync(ct); try { await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); ride.Status = RideStatus.Active; ride.ActivatedAt = DateTime.Now; ride.Meterstart = 0; rideRepo.Update(ride); repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(sheet); } private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId) { foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0)) { var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId) ?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}"); if (warehouseBalance.Quantity < qty) throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}"); warehouseBalance.Quantity -= qty; warehouseBalance.UpdatedAt = DateTime.Now; stockRepo.UpdateBalance(warehouseBalance); var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId); if (vehicleBalance is null) { await stockRepo.AddBalanceAsync(new StockBalance { FK_ItemId = itemId, LocationId = vehicleId, LocationType = LocationType.Vehicle, Quantity = qty }); } else { vehicleBalance.Quantity += qty; vehicleBalance.UpdatedAt = DateTime.Now; stockRepo.UpdateBalance(vehicleBalance); } await stockRepo.AddMovementAsync(new StockMovement { FK_ItemId = itemId, FromLocationId = sheet.FK_SourceWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = vehicleId, ToLocationType = LocationType.Vehicle, Quantity = qty, Type = MovementType.Load, ReferenceId = sheet.Id, CreatedBy = userId }); } } private static LoadingSheetResponse ToResponse(LoadingSheet s) => new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId, s.SourceWarehouse?.Name ?? string.Empty, s.Status, s.DispatchConfirmedAt, s.DispatchConfirmedBy, s.ReceiptConfirmedAt, s.ReceiptConfirmedBy, s.CreatedAt, s.Items.Select(i => new LoadingSheetItemResponse( i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList()); }