Files
myep/src/Service/BookingService.php
T

1045 lines
37 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Service\BookingStatusRuleRegistry;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\NoRoomsAvailableException;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Form\Model\RoomSelectionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BookingService
{
public const BOOKING_CREATE_KEY = 'booking_create';
public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot';
public const BOOKING_EDIT_KEY = 'booking_edit';
public const RETURN_URL_KEY = 'booking_return_url';
public const DEFAULT_RETURN_URL = 'https://www.ep-reisen.de';
public function __construct(
private readonly TravelDataService $travelDataService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ParticipantEligibilityService $participantEligibilityService,
private readonly BookingStatusRuleRegistry $bookingStatusRuleRegistry,
private readonly AgencyLoader $agencyLoader,
#[Autowire('%default_booking_status%')]
private readonly string $defaultBookingStatus,
) {
}
/**
* Gets or creates the baseline room selection snapshot for change detection.
*
* The baseline snapshot captures the initial room selection state when step 1
* is first loaded, before any HTMX modifications. This ensures accurate change
* detection for room assignment resets.
*/
public function getOrCreateBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array
{
$baselineKey = self::BOOKING_CREATE_BASELINE_KEY;
if (!$request->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
{
$bookingDto = $this->getBookingDto($request, BookingDto::MODE_CREATE);
if (null !== $bookingDto) {
return $bookingDto;
}
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 and restores the Travel object.
*
* After deserialization the DTO contains only a Travel skeleton with the ID.
* This method replaces it with the full Travel via hydrate().
*
* @param Request $request The HTTP request containing session data
* @param string $mode The booking mode (create/edit)
*
* @return BookingDto|null The booking DTO 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;
}
$bookingDto = $session->get($sessionKey);
$this->hydrate($bookingDto);
return $bookingDto;
}
/**
* 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;
}
/**
* Restores the full Travel object after session deserialization.
*
* BookingDto::__serialize() replaces Travel with just its ID to keep session
* payloads small. This method fetches the complete Travel (from DB snapshot or
* XML fallback) and sets it on both the DTO and the Booking reference.
*/
private function hydrate(BookingDto $bookingDto): void
{
$travel = $this->travelDataService->getTravelData(
$bookingDto->travel->id,
$bookingDto->hotelId
);
if (null === $travel) {
return;
}
$bookingDto->travel = $travel;
if (null !== $bookingDto->booking) {
$bookingDto->booking->travelData = $travel;
}
}
/**
* 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);
}
/**
* 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<int, int> 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<int, Room> $rooms Rooms indexed by room ID
*
* @return array{by_pax: array<int, Room>, by_room: array<int, Room>} 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<int, Room> $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<int, array{int, int}> 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 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
);
}
}
/**
* @deprecated Use preselectDefaultServices() instead.
*/
public function preselectMandatoryServices(BookingDto $bookingDto): void
{
$this->preselectDefaultServices($bookingDto);
}
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);
}
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);
}
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);
}
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;
}
}
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;
}
}
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);
}
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);
}
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);
}
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 → A
if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_FREE)) {
return Constants::BOOKING_STATUS_FREE;
}
if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_OPEN)) {
return Constants::BOOKING_STATUS_OPEN;
}
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);
}
/**
* 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;
}
}