594 lines
22 KiB
PHP
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;
|
|
}
|
|
}
|