getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { $baseline = $this->createRoomSelectionSnapshot($bookingCreateDto); $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 { // Track last session update for staleness detection $bookingDto->lastSessionUpdate = new \DateTimeImmutable(); $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; } /** * Saves the booking creation DTO to the session. * * Persists the current booking state to the session for retrieval * across multiple HTTP requests during the booking flow. * * @param Request $request The HTTP request with session * @param BookingDto $bookingCreateDto The booking DTO to persist */ public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void { $this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); } /** * Clears the booking creation DTO from the session. * * This method removes only the booking DTO while preserving other session data. * Used after successful booking submission to clear the booking flow state. */ public function clearBookingCreateDto(Request $request): void { $request->getSession()->remove(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. */ public function clearBookingSession(Request $request): void { $session = $request->getSession(); $session->remove(self::BOOKING_CREATE_KEY); $session->remove(self::BOOKING_CREATE_BASELINE_KEY); } /** * 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 * - 'Buchungsstop': Booking stopped, no bookings allowed */ 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)); } // Handle Buchungsstop - no bookings allowed at all if ('Buchungsstop' === $travelData->status) { throw new BookingNotPossibleException($dateId, $hotelId); } // Determine booking status based on travel status $isInquiryBooking = 'Anfrage' === $travelData->status; $bookingStatus = $isInquiryBooking ? 'A' : 'F'; // Get available rooms $availableRooms = $travelData->getAvailableRooms(); // For regular bookings (Frei), prevent entry when no rooms are available // For inquiry bookings (Anfrage), allow even with 0 availability if (false === $isInquiryBooking && empty($availableRooms)) { throw new NoRoomsAvailableException($dateId, $hotelId); } // For inquiry bookings with 0 availability, get all rooms ignoring availability count if ($isInquiryBooking && empty($availableRooms)) { $availableRooms = array_filter($travelData->rooms, function (Room $room) { return \App\BusProNet\Constants::STATUS_AVAILABLE === $room->status; }); } // 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->bookingStatus = $bookingStatus; $this->saveBookingCreateDto($request, $bookingCreateDto); 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->roomId = $room->id; $selection->roomLabel = $room->label; $selection->roomPrice = $room->price; $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->roomId]; $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 */ public function getRoomAssignmentCounts(BookingDto $bookingDto): array { $counts = []; foreach ($bookingDto->getParticipants() as $participant) { if (null !== $participant->assignedRoomId) { $counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1; } } return $counts; } /** * 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'). * * @param array $rooms Rooms indexed by room ID * * @return array{by_pax: array, by_room: array} */ 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; } } 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->roomId] ?? null; if ($room) { $type = $room->getSelectionType(); $groups[$type][] = $roomSelection; } } return $groups; } /** * Resets all participant room assignments in the DTO. * * Clears room assignments when room selections change to prevent * invalid assignments. Called when users modify their room selections * in step 1 to ensure participants are reassigned appropriately. * * @param BookingDto $dto The booking DTO to reset assignments for */ public function resetParticipantAssignments(BookingDto $dto): void { foreach ($dto->participants as $participant) { $participant->assignedRoomId = null; } } /** * Creates a snapshot of the current room selection state. * * @return array Array of [roomId, quantity] pairs */ public function createRoomSelectionSnapshot(BookingDto $dto): array { return array_map( fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity], $dto->roomSelections ); } /** * Checks if room selection has changed compared to a previous snapshot. * * Compares the current room selection state with a baseline snapshot * to detect changes that would require participant reassignment. * * @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 */ public function hasRoomSelectionChanged(array $oldSnapshot, BookingDto $newDto): bool { $newSnapshot = $this->createRoomSelectionSnapshot($newDto); return $oldSnapshot !== $newSnapshot; } /** * 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(\App\BusProNet\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 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; } } }