Files
myep/src/Service/BookingService.php
T
Björn Fromme 776b9d0912 feat: proper determination of agency initiated bookings
For booking that are agency initiated (groups) different rules apply
concerning mutability and requirement of personal data and whether the
first participant is the applicant or not. This commit adds logic to
evaluate the agency id assigned to the booking data to decide.
2026-01-08 12:54:59 +01:00

635 lines
24 KiB
PHP

<?php
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
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\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 const THEME_KEY = 'booking_theme';
public const DEFAULT_THEME = 'base';
public function __construct(
private readonly TravelDataService $travelDataService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ParticipantEligibilityService $participantEligibilityService,
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
{
$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);
}
/**
* Stores the theme identifier in the session.
*
* Sanitizes the theme value to allow only alphanumeric characters and hyphens.
* Falls back to the default theme if empty or invalid.
*/
public function storeTheme(Request $request, string $theme): void
{
$sanitized = preg_replace('/[^a-zA-Z0-9-]/', '', $theme);
if ('' === $sanitized) {
$sanitized = self::DEFAULT_THEME;
}
$request->getSession()->set(self::THEME_KEY, $sanitized);
}
/**
* Retrieves the theme identifier from the session.
*
* Returns the default theme if not set in session.
*/
public function getTheme(Request $request): string
{
return $request->getSession()->get(self::THEME_KEY, self::DEFAULT_THEME);
}
/**
* 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 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 use the configured default status:
* 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)
*
* 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 'A' for inquiry booking, or the configured default status
*/
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 $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;
}
}
}
/**
* 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;
}
}