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->applyCreateBookingStatusRules($bookingCreateDto); $this->bookingSessionService->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 ?? 1; $selection->quantity = $roomsIdsAndQuantities[$room->id] ?? 0; return $selection; } /** * Pre-selects default services for all participants. * * Mandatory and auto-book rules are handled in dedicated methods: * mandatory first, auto-book second. */ public function preselectDefaultServices(BookingDto $bookingDto): void { $additionalServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL); $mandatoryAdditionalServices = array_filter( $additionalServices, static fn (Service $service): bool => true === $service->mandatory ); $autoBookAdditionalServices = array_filter( $additionalServices, // Services that are both mandatory and auto-book belong to mandatory bucket only. static fn (Service $service): bool => true === $service->autoBook && false === $service->mandatory ); $skiPassServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true); $mandatorySkiPassServices = array_filter( $skiPassServices, static fn (Service $service): bool => true === $service->mandatory ); $autoBookSkiPassServices = array_filter( $skiPassServices, // Services that are both mandatory and auto-book belong to mandatory bucket only. static fn (Service $service): bool => true === $service->autoBook && false === $service->mandatory ); $boardServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD, true); $mandatoryBoardServices = array_filter( $boardServices, static fn (Service $service): bool => true === $service->mandatory ); $autoBookBoardServices = array_filter( $boardServices, // Services that are both mandatory and auto-book belong to mandatory bucket only. static fn (Service $service): bool => true === $service->autoBook && false === $service->mandatory ); $rentalServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true); $mandatoryRentalServices = array_filter( $rentalServices, static fn (Service $service): bool => true === $service->mandatory ); $autoBookRentalServices = array_filter( $rentalServices, // Services that are both mandatory and auto-book belong to mandatory bucket only. static fn (Service $service): bool => true === $service->autoBook && false === $service->mandatory ); $ageEvaluator = new ServiceAgeEvaluator(); foreach ($bookingDto->participants as $participantIndex => $participant) { if (false === $this->canPreselectServicesForParticipant($bookingDto, $participantIndex, $participant)) { continue; } $this->preselectMandatoryAdditionalServices( $participant, $mandatoryAdditionalServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectAutoBookAdditionalServices( $participant, $autoBookAdditionalServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectMandatorySkiPass( $participant, $mandatorySkiPassServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectAutoBookSkiPass( $participant, $autoBookSkiPassServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectMandatoryBoardServices( $participant, $mandatoryBoardServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectAutoBookBoardServices( $participant, $autoBookBoardServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectMandatoryRentals( $participant, $mandatoryRentalServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->preselectAutoBookRentals( $participant, $autoBookRentalServices, $bookingDto, $participantIndex, $ageEvaluator ); } } private function canPreselectServicesForParticipant( BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant, ): bool { if (null === $participant->dateOfBirth) { return false; } $age = $participant->getAge($bookingDto->travel->dateFrom); if (null !== $age && $age <= Constants::BABY_MAX_AGE) { return false; } return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex); } /** @param array $mandatoryAdditionalServices */ private function preselectMandatoryAdditionalServices( ParticipantDto $participant, array $mandatoryAdditionalServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { $eligibleServices = $this->getEligibleServicesForParticipant( $mandatoryAdditionalServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->appendAdditionalServices($participant, $eligibleServices, false); } /** @param array $autoBookAdditionalServices */ private function preselectAutoBookAdditionalServices( ParticipantDto $participant, array $autoBookAdditionalServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { $eligibleServices = $this->getEligibleServicesForParticipant( $autoBookAdditionalServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->appendAdditionalServices($participant, $eligibleServices, true); } /** @param array $mandatorySkiPassServices */ private function preselectMandatorySkiPass( ParticipantDto $participant, array $mandatorySkiPassServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { if (null !== $participant->skiPass) { return; } $eligibleServices = $this->getEligibleServicesForParticipant( $mandatorySkiPassServices, $bookingDto, $participantIndex, $ageEvaluator ); foreach ($eligibleServices as $service) { $participant->skiPass = $service; return; } } /** @param array $autoBookSkiPassServices */ private function preselectAutoBookSkiPass( ParticipantDto $participant, array $autoBookSkiPassServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { if (null !== $participant->skiPass) { return; } $eligibleServices = $this->getEligibleServicesForParticipant( $autoBookSkiPassServices, $bookingDto, $participantIndex, $ageEvaluator ); foreach ($eligibleServices as $service) { if (null !== $service->id && true === in_array($service->id, $participant->autoBookOptOutSkiPassIds, true)) { continue; } $participant->skiPass = $service; return; } } /** @param array $mandatoryBoardServices */ private function preselectMandatoryBoardServices( ParticipantDto $participant, array $mandatoryBoardServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { $eligibleServices = $this->getEligibleServicesForParticipant( $mandatoryBoardServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->appendBoardServices($participant, $eligibleServices, false); } /** @param array $autoBookBoardServices */ private function preselectAutoBookBoardServices( ParticipantDto $participant, array $autoBookBoardServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { $eligibleServices = $this->getEligibleServicesForParticipant( $autoBookBoardServices, $bookingDto, $participantIndex, $ageEvaluator ); $this->appendBoardServices($participant, $eligibleServices, true); } /** @param array $mandatoryRentalServices */ private function preselectMandatoryRentals( ParticipantDto $participant, array $mandatoryRentalServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { if (null === $participant->skiPass) { return; } $eligibleServices = $this->getEligibleServicesForParticipant( $mandatoryRentalServices, $bookingDto, $participantIndex, $ageEvaluator ); $matchingDurationServices = $this->getRentalsMatchingSkiPassDuration($eligibleServices, $participant); $this->appendRentalServices($participant, $matchingDurationServices, false); } /** @param array $autoBookRentalServices */ private function preselectAutoBookRentals( ParticipantDto $participant, array $autoBookRentalServices, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): void { if (null === $participant->skiPass) { return; } $eligibleServices = $this->getEligibleServicesForParticipant( $autoBookRentalServices, $bookingDto, $participantIndex, $ageEvaluator ); $matchingDurationServices = $this->getRentalsMatchingSkiPassDuration($eligibleServices, $participant); $this->appendRentalServices($participant, $matchingDurationServices, true); } /** * @param Service[] $services * * @return Service[] */ private function getEligibleServicesForParticipant( array $services, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): array { return array_filter( $services, fn (Service $service): bool => $this->isServiceAvailableForParticipant( $service, $bookingDto, $participantIndex, $ageEvaluator ) ); } /** * @param Service[] $services */ private function appendAdditionalServices(ParticipantDto $participant, array $services, bool $respectOptOut): void { $currentSelections = $participant->additionalServices; $currentServiceIds = array_map(static fn (Service $service): ?int => $service->id, $currentSelections); foreach ($services as $service) { if (null === $service->id || true === in_array($service->id, $currentServiceIds, true)) { continue; } if (true === $respectOptOut && true === in_array($service->id, $participant->autoBookOptOutServiceIds, true)) { continue; } $currentSelections[] = $service; } $participant->additionalServices = $currentSelections; } /** * @param Service[] $services */ private function appendBoardServices(ParticipantDto $participant, array $services, bool $respectOptOut): void { $currentSelections = $participant->board; $currentServiceIds = array_map(static fn (Service $service): ?int => $service->id, $currentSelections); foreach ($services as $service) { if (null === $service->id || true === in_array($service->id, $currentServiceIds, true)) { continue; } if (true === $respectOptOut && true === in_array($service->id, $participant->autoBookOptOutBoardIds, true)) { continue; } $currentSelections[] = $service; } $participant->board = $currentSelections; } /** * @param Service[] $services */ private function appendRentalServices(ParticipantDto $participant, array $services, bool $respectOptOut): void { $currentSelections = $participant->rentals; $currentServiceIds = array_map(static fn (Service $service): ?int => $service->id, $currentSelections); foreach ($services as $service) { if (null === $service->id || true === in_array($service->id, $currentServiceIds, true)) { continue; } if (true === $respectOptOut && true === in_array($service->id, $participant->autoBookOptOutRentalIds, true)) { continue; } $currentSelections[] = $service; } $participant->rentals = $currentSelections; } /** * @param Service[] $rentals * * @return Service[] */ private function getRentalsMatchingSkiPassDuration(array $rentals, ParticipantDto $participant): array { $selectedSkiPass = $participant->skiPass; if (null === $selectedSkiPass || null === $selectedSkiPass->dateFrom || null === $selectedSkiPass->dateTo) { return []; } return array_filter($rentals, static function (Service $rental) use ($selectedSkiPass): bool { if (null === $rental->dateFrom || null === $rental->dateTo) { return false; } return $rental->dateFrom == $selectedSkiPass->dateFrom && $rental->dateTo == $selectedSkiPass->dateTo; }); } private function isServiceAvailableForParticipant( Service $service, BookingDto $bookingDto, int $participantIndex, ServiceAgeEvaluator $ageEvaluator, ): bool { if (false === $ageEvaluator->canEvaluate($service)) { return true; } return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); } /** * Determines the initial booking status based on travel configuration and API restrictions. * * This method implements a multi-stage check to determine the appropriate booking status: * 1. Checks API restrictions via buchungstatusmoeglich attribute * 2. Checks if only inquiry rooms are available (no Frei rooms with available > 0) * * When API provides allowed status restrictions, the method respects them by selecting * the best available status in order of preference: configured default → 'F' → 'O' → 'A'. * * 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 The appropriate booking status code ('F', 'O', or 'A') */ private function determineInitialBookingStatus(Travel $travelData): string { // Check 1: Only inquiry rooms available (all rooms with available > 0 have status 'Anfrage') // This takes precedence as it's a business rule independent of API restrictions if ($travelData->requiresInquiryBooking()) { return Constants::BOOKING_STATUS_INQUIRY; } // Check 2: If API provided allowed status restrictions, respect them if ([] !== $travelData->allowedBookingStatus) { // Prefer configured default status if allowed if ($travelData->isBookingStatusAllowed($this->defaultBookingStatus)) { return $this->defaultBookingStatus; } // Fall back to allowed statuses in order of preference: F → O(option) → A if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_FREE)) { return Constants::BOOKING_STATUS_FREE; } if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_OPTION)) { return Constants::BOOKING_STATUS_OPTION; } if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_INQUIRY)) { return Constants::BOOKING_STATUS_INQUIRY; } } 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; } } } /** * Applies booking-level status rules in create flow. * * Inquiry bookings always win and are never overridden. */ public function applyCreateBookingStatusRules(BookingDto $bookingDto): void { if (Constants::BOOKING_STATUS_INQUIRY === $bookingDto->bookingStatus) { return; } $bookingDto->bookingStatus = $this->bookingStatusRuleRegistry->evaluateStatus($bookingDto); } }