getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { $baseline = $bookingCreateDto->createRoomSelectionSnapshot(); $request->getSession()->set($baselineKey, $baseline); return $baseline; } return $request->getSession()->get($baselineKey); } /** * Clears the baseline snapshot from the session. * * Should be called when moving to the next step or when the baseline * needs to be refreshed. */ public function clearBaselineSnapshot(Request $request): void { $request->getSession()->remove('booking_create_baseline_snapshot'); } /** * Retrieves the booking creation DTO from the session. * * This method enforces the secure booking flow by only returning existing * session data. Users must go through the proper initialization flow via * CreateInitController to create new booking sessions. * * @param Request $request The HTTP request containing session data * * @return BookingDto The booking DTO from session * * @throws BookingSessionNotFoundException When no valid booking session exists */ public function getOrCreateBookingCreateDto(Request $request): BookingDto { $bookingCreateDto = $request->getSession()->get(self::BOOKING_CREATE_KEY); // Return existing DTO from session if available if (null !== $bookingCreateDto) { return $bookingCreateDto; } // No session found - user must go through proper init flow throw new BookingSessionNotFoundException(); } /** * Saves the booking DTO to the session. * * @param Request $request The HTTP request with session * @param BookingDto $bookingDto The booking DTO to persist * @param string $mode The booking mode (create/edit) */ public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void { $sessionKey = $this->getSessionKey($mode); $request->getSession()->set($sessionKey, $bookingDto); } /** * Retrieves the booking DTO from the session. * * @param Request $request The HTTP request containing session data * @param string $mode The booking mode (create/edit) * * @return BookingDto|null The booking DTO from session or null if not found */ public function getBookingDto(Request $request, string $mode): ?BookingDto { $sessionKey = $this->getSessionKey($mode); $session = $request->getSession(); if (false === $session->has($sessionKey)) { return null; } return $session->get($sessionKey); } /** * Clears the booking DTO from the session. * * @param Request $request The HTTP request with session * @param string $mode The booking mode (create/edit) */ public function clearBookingDto(Request $request, string $mode): void { $sessionKey = $this->getSessionKey($mode); $request->getSession()->remove($sessionKey); } /** * Generates session key based on mode. * * @param string $mode 'create' or 'edit' * * @return string The session key */ private function getSessionKey(string $mode): string { return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY; } /** * Clears all booking-related session data. * * This method removes all booking session data including the main DTO * and any cached snapshots to ensure a completely fresh start. * Note: RETURN_URL_KEY is intentionally preserved so it remains available * for redirects after cancel or error flows. */ public function clearBookingSession(Request $request): void { $session = $request->getSession(); $session->remove(self::BOOKING_CREATE_KEY); $session->remove(self::BOOKING_CREATE_BASELINE_KEY); } /** * Stores the return URL in the session. * * Validates that the URL is a valid absolute URL with http/https scheme. * Falls back to the default return URL if null or invalid. */ public function storeReturnUrl(Request $request, ?string $returnUrl): void { $url = self::DEFAULT_RETURN_URL; if (null !== $returnUrl && '' !== trim($returnUrl)) { if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL) && 1 === preg_match('#^https?://#i', $returnUrl)) { $url = $returnUrl; } } $request->getSession()->set(self::RETURN_URL_KEY, $url); } /** * Retrieves the return URL from the session. * * Returns the default URL if not set in session. */ public function getReturnUrl(Request $request): string { return $request->getSession()->get(self::RETURN_URL_KEY, self::DEFAULT_RETURN_URL); } /** * Stores the theme identifier in the session. * * Sanitizes the theme value to allow only alphanumeric characters and hyphens. * Falls back to the default theme if empty or invalid. */ public function storeTheme(Request $request, string $theme): void { $sanitized = preg_replace('/[^a-zA-Z0-9-]/', '', $theme); if ('' === $sanitized) { $sanitized = self::DEFAULT_THEME; } $request->getSession()->set(self::THEME_KEY, $sanitized); } /** * Retrieves the theme identifier from the session. * * Returns the default theme if not set in session. */ public function getTheme(Request $request): string { return $request->getSession()->get(self::THEME_KEY, self::DEFAULT_THEME); } /** * Creates a fresh booking session with the provided travel parameters. * * This method initializes a new BookingDto with empty room selections * and saves it to the session. It's designed to be called from the clean * booking entry point without requiring UID parameters. * * Handles three booking status types: * - 'Frei': Regular booking with availability checks * - 'Anfrage': Inquiry booking, allows booking even with 0 availability */ public function startFreshBooking(Request $request, int $dateId, int $hotelId, ?int $agencyId = null): BookingDto { $travelData = $this->travelDataService->getTravelData($dateId, $hotelId); if (null === $travelData) { throw new NotFoundHttpException(sprintf('Travel data not found for date ID %d and hotel ID %d', $dateId, $hotelId)); } // Fetch and patch availability data (includes allowedBookingStatus from API) // Force refresh ensures fresh data at booking start, then populates cache for subsequent loads $availabilities = $this->travelDataService->getAvailabilityData($dateId, cached: true, forceRefresh: true); if (null !== $availabilities) { $this->travelDataService->patchAvailabilities($travelData, $availabilities); } // Get bookable rooms (Frei or Anfrage with available > 0) $availableRooms = $travelData->getAvailableRooms(); // No bookable rooms means booking is not possible if ([] === $availableRooms) { throw new NoRoomsAvailableException($dateId, $hotelId); } // Determine initial booking status based on room availability // This is for early UI decisions (e.g., voucher visibility), final verdict is in step 3 $bookingStatus = $this->determineInitialBookingStatus($travelData); // Create room selections with zero quantities (user will set these in step 1) $roomSelections = array_map( fn (Room $room) => $this->createRoomSelection($room, []), $availableRooms ); $bookingCreateDto = new BookingDto($travelData, $hotelId); $bookingCreateDto->roomSelections = $roomSelections; $bookingCreateDto->currentStep = 1; $bookingCreateDto->agencyId = $agencyId; $bookingCreateDto->agencyCode = null !== $agencyId ? $this->agencyLoader->loadById($agencyId)?->code : null; $bookingCreateDto->bookingStatus = $bookingStatus; $this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); return $bookingCreateDto; } /** * Creates a room selection DTO from room data and quantities. * * Converts a Room model into a RoomSelectionDto with the specified quantity * selection. Used during booking initialization to create selectable room options. * * @param Room $room The room model to convert * @param array $roomsIdsAndQuantities Array of room ID to quantity mappings * * @return RoomSelectionDto The room selection DTO */ private function createRoomSelection(Room $room, array $roomsIdsAndQuantities): RoomSelectionDto { $selection = new RoomSelectionDto(); $selection->id = $room->id; $selection->label = $room->label; $selection->price = $room->price; $selection->status = $room->status; $selection->maxQuantity = $room->available; $selection->capacity = $room->minPax; $selection->quantity = $roomsIdsAndQuantities[$room->id] ?? 0; return $selection; } /** * Calculates the total number of participants based on room selections. * * Multiplies each room's minimum occupancy (minPax) by the selected quantity * to determine the total number of participants required for the booking. * * @param array $roomSelections Array of RoomSelectionDto objects * @param Travel $travelData Travel data containing room information * * @return int Total number of participants required */ public function getParticipantsCount(array $roomSelections, Travel $travelData): int { $participantsCount = 0; $rooms = $travelData->getAvailableRooms(); foreach ($roomSelections as $roomSelection) { $room = $rooms[$roomSelection->id]; $participantsCount += $room->minPax * $roomSelection->quantity; } return $participantsCount; } /** * Calculates the number of participants assigned to each room ID. * * @return array an array where the key is the room ID and the value is the count of assigned participants * * @deprecated Use BookingDto::getRoomAssignmentCounts() instead */ public function getRoomAssignmentCounts(BookingDto $bookingDto): array { return $bookingDto->getRoomAssignmentCounts(); } /** * Returns a summary of selected rooms, participant count, and pricing information for a booking. * * @return array{selectedRooms: array, participantCount: int, pricing: array} */ public function getRoomSummaryAndParticipantCount(BookingDto $bookingDto): array { $selectedRooms = $bookingDto->getSelectedRooms(); // In edit mode, count actual participants; in create mode, calculate from room selections if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { $participantCount = count($bookingDto->getParticipants()); } else { $participantCount = $this->getParticipantsCount($selectedRooms, $bookingDto->travel); } $pricing = $this->priceCalculator->getPricingBreakdown($bookingDto); return [ 'selectedRooms' => $selectedRooms, 'participantCount' => $participantCount, 'pricing' => $pricing, ]; } /** * Groups available rooms by selection type ('by_pax' or 'by_room') and sorts them by maxPax. * * @param array $rooms Rooms indexed by room ID * * @return array{by_pax: array, by_room: array} Rooms grouped and sorted by maxPax (ascending) */ public function groupRoomsBySelectionType(array $rooms): array { $groups = [ Room::SELECTION_TYPE_BY_PAX => [], Room::SELECTION_TYPE_BY_ROOM => [], ]; foreach ($rooms as $room) { if (false !== stripos($room->label, 'bett')) { $groups[Room::SELECTION_TYPE_BY_PAX][$room->id] = $room; } else { $groups[Room::SELECTION_TYPE_BY_ROOM][$room->id] = $room; } } // Sort each group by maxPax (ascending order - smallest capacity first) uasort($groups[Room::SELECTION_TYPE_BY_PAX], fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax); uasort($groups[Room::SELECTION_TYPE_BY_ROOM], fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax); return $groups; } /** * Groups roomSelections by selection type ('by_pax' or 'by_room'), using Room::getSelectionType(). * * @param array $roomSelections Array of selected RoomSelectionDto * @param array $roomsById Rooms indexed by room ID * * @return array{by_pax: array, by_room: array} */ public function groupRoomSelectionsByType(array $roomSelections, array $roomsById): array { $groups = [ Room::SELECTION_TYPE_BY_PAX => [], Room::SELECTION_TYPE_BY_ROOM => [], ]; foreach ($roomSelections as $roomSelection) { $room = $roomsById[$roomSelection->id] ?? null; if ($room) { $type = $room->getSelectionType(); $groups[$type][] = $roomSelection; } } return $groups; } /** * Resets all participant room assignments in the DTO. * * @param BookingDto $dto The booking DTO to reset assignments for * * @deprecated Use BookingDto::resetParticipantAssignments() instead */ public function resetParticipantAssignments(BookingDto $dto): void { $dto->resetParticipantAssignments(); } /** * Creates a snapshot of the current room selection state. * * @return array Array of [roomId, quantity] pairs * * @deprecated Use BookingDto::createRoomSelectionSnapshot() instead */ public function createRoomSelectionSnapshot(BookingDto $dto): array { return $dto->createRoomSelectionSnapshot(); } /** * Checks if room selection has changed compared to a previous snapshot. * * @param array $oldSnapshot The baseline room selection snapshot * @param BookingDto $newDto The current booking DTO * * @return bool True if room selections have changed, false otherwise * * @deprecated Use BookingDto::hasRoomSelectionChanged() instead */ public function hasRoomSelectionChanged(array $oldSnapshot, BookingDto $newDto): bool { return $newDto->hasRoomSelectionChanged($oldSnapshot); } /** * Pre-selects mandatory services for all participants in the booking DTO. * * This method ensures that mandatory services are selected before form rendering * and pricing calculations, resolving timing issues where mandatory services * were only selected during form rendering via choice_attr callbacks. * * Mandatory services are only preselected for eligible participants - those who * have at least one skipass available for their age. Ineligible participants are * skipped to prevent their mandatory services from being included in pricing. * * @param BookingDto $bookingDto The booking DTO to update with mandatory services */ public function preselectMandatoryServices(BookingDto $bookingDto): void { $additionalServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL); $mandatoryServices = array_filter($additionalServices, fn ($service) => true === $service->mandatory); // Pre-select mandatory services for each participant foreach ($bookingDto->participants as $participantIndex => $participant) { if (null === $participant->dateOfBirth) { continue; // Skip participants without age information } // Skip babies - they only get services with explicit age ranges (handled by field filtering) $age = $participant->getAge($bookingDto->travel->dateFrom); if (null !== $age && $age <= Constants::BABY_MAX_AGE) { continue; } // Skip ineligible participants (no skipasses available for their age) if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { continue; } // Get age-appropriate mandatory services for this participant $ageAppropriateServices = array_filter($mandatoryServices, function ($service) use ($bookingDto, $participant) { if (null === $service->ageFrom && null === $service->ageTo) { return true; // No age restrictions } // participant's age at travel date is relevant $age = $participant->getAge($bookingDto->travel->dateFrom); if (null !== $service->ageFrom && $age < $service->ageFrom) { return false; } if (null !== $service->ageTo && $age > $service->ageTo) { return false; } return true; }); // Add mandatory services to current selections $currentSelections = $participant->additionalServices ?? []; $currentServiceIds = array_map(fn ($service) => $service->id, $currentSelections); foreach ($ageAppropriateServices as $mandatoryService) { if (false === in_array($mandatoryService->id, $currentServiceIds, true)) { $currentSelections[] = $mandatoryService; } } $participant->additionalServices = $currentSelections; } } /** * Determines the initial booking status based on travel configuration. * * This method implements a multi-stage check to determine if a booking * should start as inquiry ('A') or use the configured default status: * 1. Checks if final bookings are allowed via buchungstatusmoeglich attribute from API * 2. Checks if only inquiry rooms are available (no Frei rooms with available > 0) * * The default status is configured via DEFAULT_BOOKING_STATUS env variable. * Use 'O' (Option) during beta for agency confirmation, 'F' (Final) for production. * * @param Travel $travelData The travel data to evaluate * * @return string 'A' for inquiry booking, or the configured default status */ private function determineInitialBookingStatus(Travel $travelData): string { // Check 1: Allowed booking status from buchungstatusmoeglich attribute (from availability API) // Only applies if the API provided status restrictions if ([] !== $travelData->allowedBookingStatus && false === $travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_FREE)) { return 'A'; } // Check 2: Only inquiry rooms available (all rooms with available > 0 have status 'Anfrage') if ($travelData->requiresInquiryBooking()) { return 'A'; } return $this->defaultBookingStatus; } /** * Updates booking status based on selected room requirements. * * Checks if any selected room has inquiry-only status. If so, forces * the entire booking to inquiry mode. This override happens after room * selection in Step 1 and respects the business rule: if ANY room requires * inquiry, the whole booking becomes an inquiry. * * @param BookingDto $bookingDto The booking DTO to update */ public function updateBookingStatusFromRoomSelection(BookingDto $bookingDto): void { // Skip if already inquiry - no need to check if ('A' === $bookingDto->bookingStatus) { return; } // Check selected rooms for inquiry-only status foreach ($bookingDto->getSelectedRooms() as $roomSelection) { $room = $bookingDto->travel->getRoomById($roomSelection->id); if ($room && Constants::STATUS_ON_REQUEST === $room->status && $room->available > 0) { $bookingDto->bookingStatus = 'A'; return; } } } /** * Ensures the booking DTO has the correct number of participant objects. * * Creates or removes ParticipantDto objects based on room selections. * Preserves existing participant data when adjusting the count. * Optionally prepopulates the applicant (index 0) from an authenticated user. * * @param BookingDto $bookingDto The booking DTO to update * @param \Symfony\Component\Security\Core\User\UserInterface|null $user Optional authenticated user for prepopulation * @param callable|null $prepopulateCallback Callback to prepopulate applicant: fn(UserInterface, ParticipantDto): ParticipantDto */ public function ensureCorrectNumberOfParticipants( BookingDto $bookingDto, ?\Symfony\Component\Security\Core\User\UserInterface $user = null, ?callable $prepopulateCallback = null, ): void { $participantsCount = $this->getParticipantsCount($bookingDto->roomSelections, $bookingDto->travel); $existingParticipants = $bookingDto->participants; $bookingDto->participants = []; for ($i = 0; $i < $participantsCount; ++$i) { $participant = $existingParticipants[$i] ?? new ParticipantDto(); $participant->index = $i; // Prepopulate applicant from authenticated user (index 0 only) if (0 === $i && null !== $user && null !== $prepopulateCallback && $this->shouldPrepopulate($participant)) { $participant = $prepopulateCallback($user, $participant); } $bookingDto->participants[$i] = $participant; } } /** * Determines if a participant should be prepopulated. * * Only prepopulates if the participant is "fresh" (no name set yet). */ private function shouldPrepopulate(ParticipantDto $participant): bool { return null === $participant->firstName || '' === $participant->firstName; } }