Files
myep/src/Service/BookingService.php
T

594 lines
22 KiB
PHP

<?php
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\NoRoomsAvailableException;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
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,
) {
}
/**
* 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
{
$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);
}
/**
* 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)
$availabilities = $this->travelDataService->getAvailabilityData($dateId);
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->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 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 final ('F') booking:
* 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)
*
* @param Travel $travelData The travel data to evaluate
*
* @return string 'A' for inquiry booking, 'F' for final booking
*/
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 'F';
}
/**
* 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;
}
}