loadBookingCreateDto($this->bookingSessionService, $request); } catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) { return $this->createBookingCreateFailureResponse($exception, false); } // Validate step access if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 2)) { return $redirect; } // Enrich with fresh availability data $this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel); // Resync the room selections with the refreshed travel before any participant count is derived // from them, and send the customer back to step 1 when a room they picked is gone. if (null !== $redirect = $this->guardAgainstUnavailableRoomSelection($request, $bookingCreateDto)) { return $redirect; } // Ensure correct number of participants first, then prepopulate the applicant if needed. if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) { return $redirect; } $this->ensureCorrectNumberOfParticipants($bookingCreateDto); $user = $this->getUser(); if ($user instanceof User && isset($bookingCreateDto->participants[0]) && $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) { $bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser( $user, $bookingCreateDto->participants[0] ); } // Validate room assignments against current selection (handles back-navigation from step 2 to step 1) $this->roomAssignmentService->validateAndResetInvalidAssignments($bookingCreateDto); // Auto-assign rooms if needed $this->roomAssignmentService->assignRoomsIfNeeded($bookingCreateDto); // Preselect mandatory services $this->bookingService->preselectDefaultServices($bookingCreateDto); $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); // Save BookingDto to session $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Create validation form $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto); $form->handleRequest($request); // Handle form submission if (true === $form->isSubmitted() && true === $form->isValid()) { // All participants validated successfully, update current step $bookingCreateDto->currentStep = 3; $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Proceed to Step 3 return $this->redirectToRoute('app_booking_create_step_3'); } $context = $this->createContextFactory->createWithParticipantCards($bookingCreateDto, $form->isSubmitted()); $templateData = [ 'form' => $form->createView(), 'bookingCreateContext' => $context, ]; return $this->render('booking/create/step_2.html.twig', $templateData); } /** * Sends the customer back to step 1 when the refreshed travel data no longer covers their rooms. * * Deriving the participant count from a room that has disappeared would silently fall back to a * capacity of one and render too few participants, and a quantity the contingent no longer covers * reaches the BusPro submit unchecked, because no later step re-reads room availability. The * selections are resynced and saved before every exit, so step 1 and the neighbouring guard both * see a collection that matches the rooms the template renders: leaving a stale entry behind would * fail step 1's own validation on a field it never shows. */ private function guardAgainstUnavailableRoomSelection(Request $request, BookingDto $bookingCreateDto): ?RedirectResponse { $adjustments = $this->bookingService->reconcileRoomSelections($bookingCreateDto); $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); if ([] === $bookingCreateDto->travel->getAvailableRooms()) { $this->handleApiError( $this->logger, 'Travel has no bookable rooms left while a booking is in progress.', $this->createRoomContext($bookingCreateDto, $adjustments), 'Für diese Reise sind leider keine Zimmer mehr verfügbar.' ); return $this->redirectToRoute('app_booking_create_error'); } if ([] === $adjustments) { return null; } $hasDroppedRoom = false; foreach ($adjustments as $adjustment) { $hasDroppedRoom = $hasDroppedRoom || $adjustment->isDropped(); } $this->handleApiError( $this->logger, 'Room selection exceeds what the current travel data still offers.', $this->createRoomContext($bookingCreateDto, $adjustments), true === $hasDroppedRoom ? 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.' : 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.' ); return $this->redirectToRoute('app_booking_create_step_1'); } /** * @param list $adjustments * * @return array */ private function createRoomContext(BookingDto $bookingCreateDto, array $adjustments): array { $rooms = []; foreach ($adjustments as $adjustment) { $rooms[] = [ 'roomId' => $adjustment->roomId, 'label' => $adjustment->label, 'requestedQuantity' => $adjustment->requestedQuantity, 'bookableQuantity' => $adjustment->bookableQuantity, ]; } return [ 'travelId' => $bookingCreateDto->travel->id, 'travelCode' => $bookingCreateDto->travel->code, 'hotelId' => $bookingCreateDto->hotelId, 'rooms' => $rooms, ]; } /** * Rejects a room selection that resolves to no participants at all. * * A selected room with a quantity always yields at least one participant, so a zero count means the * travel data carries no room occupancy. Rendering step 2 anyway would show an empty participant list * that still validates and lets the booking continue to step 3. */ private function guardAgainstEmptyParticipantList(BookingDto $bookingCreateDto): ?RedirectResponse { $selectedRooms = $bookingCreateDto->getSelectedRooms(); if ([] === $selectedRooms) { return null; } if (0 < $this->calculateParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel)) { return null; } $rooms = []; foreach ($selectedRooms as $roomSelection) { $rooms[] = [ 'roomId' => $roomSelection->id, 'quantity' => $roomSelection->quantity, 'capacity' => $roomSelection->capacity, ]; } $this->handleApiError( $this->logger, 'Room selection resolves to zero participants; travel data carries no room occupancy.', [ 'travelId' => $bookingCreateDto->travel->id, 'travelCode' => $bookingCreateDto->travel->code, 'hotelId' => $bookingCreateDto->hotelId, 'rooms' => $rooms, ], 'Die Zimmerdaten dieser Reise sind unvollständig. Bitte wähle dein Zimmer erneut oder kontaktiere uns.' ); return $this->redirectToRoute('app_booking_create_step_1'); } /** * Ensures the booking DTO has the expected number of participant objects based on room selections. */ private function ensureCorrectNumberOfParticipants(BookingDto $bookingDto): void { $participantsCount = $this->calculateParticipantsCount($bookingDto->roomSelections, $bookingDto->travel); $existingParticipants = $bookingDto->participants; $bookingDto->participants = []; for ($i = 0; $i < $participantsCount; ++$i) { $participant = $existingParticipants[$i] ?? new ParticipantDto(); $participant->index = $i; $bookingDto->participants[$i] = $participant; } } /** * @param array $roomSelections */ private function calculateParticipantsCount(array $roomSelections, Travel $travelData): int { $participantsCount = 0; $rooms = $travelData->getAvailableRooms(); foreach ($roomSelections as $roomSelection) { $room = $rooms[$roomSelection->id] ?? null; if (null === $room) { // Unreachable from index(), which reconciles the selections first. Kept so a future // caller cannot silently fall back to a capacity of one and book too few people. continue; } $participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity; } return $participantsCount; } }