494 lines
18 KiB
PHP
494 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Model\Room;
|
|
use App\BusProNet\Model\Travel;
|
|
use App\Exception\BookingNotPossibleException;
|
|
use App\Exception\BookingSessionNotFoundException;
|
|
use App\Exception\NoRoomsAvailableException;
|
|
use App\Form\Model\BookingDto;
|
|
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 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 = $this->createRoomSelectionSnapshot($bookingCreateDto);
|
|
$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
|
|
{
|
|
// Track last session update for staleness detection
|
|
$bookingDto->lastSessionUpdate = new \DateTimeImmutable();
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Saves the booking creation DTO to the session.
|
|
*
|
|
* Persists the current booking state to the session for retrieval
|
|
* across multiple HTTP requests during the booking flow.
|
|
*
|
|
* @param Request $request The HTTP request with session
|
|
* @param BookingDto $bookingCreateDto The booking DTO to persist
|
|
*/
|
|
public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void
|
|
{
|
|
$this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
|
}
|
|
|
|
/**
|
|
* Clears the booking creation DTO from the session.
|
|
*
|
|
* This method removes only the booking DTO while preserving other session data.
|
|
* Used after successful booking submission to clear the booking flow state.
|
|
*/
|
|
public function clearBookingCreateDto(Request $request): void
|
|
{
|
|
$request->getSession()->remove(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.
|
|
*/
|
|
public function clearBookingSession(Request $request): void
|
|
{
|
|
$session = $request->getSession();
|
|
$session->remove(self::BOOKING_CREATE_KEY);
|
|
$session->remove(self::BOOKING_CREATE_BASELINE_KEY);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* - 'Buchungsstop': Booking stopped, no bookings allowed
|
|
*/
|
|
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));
|
|
}
|
|
|
|
// Handle Buchungsstop - no bookings allowed at all
|
|
if ('Buchungsstop' === $travelData->status) {
|
|
throw new BookingNotPossibleException($dateId, $hotelId);
|
|
}
|
|
|
|
// Determine booking status based on travel status
|
|
$isInquiryBooking = 'Anfrage' === $travelData->status;
|
|
$bookingStatus = $isInquiryBooking ? 'A' : 'F';
|
|
|
|
// Get available rooms
|
|
$availableRooms = $travelData->getAvailableRooms();
|
|
|
|
// For regular bookings (Frei), prevent entry when no rooms are available
|
|
// For inquiry bookings (Anfrage), allow even with 0 availability
|
|
if (false === $isInquiryBooking && empty($availableRooms)) {
|
|
throw new NoRoomsAvailableException($dateId, $hotelId);
|
|
}
|
|
|
|
// For inquiry bookings with 0 availability, get all rooms ignoring availability count
|
|
if ($isInquiryBooking && empty($availableRooms)) {
|
|
$availableRooms = array_filter($travelData->rooms, function (Room $room) {
|
|
return \App\BusProNet\Constants::STATUS_AVAILABLE === $room->status;
|
|
});
|
|
}
|
|
|
|
// 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->saveBookingCreateDto($request, $bookingCreateDto);
|
|
|
|
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->roomId = $room->id;
|
|
$selection->roomLabel = $room->label;
|
|
$selection->roomPrice = $room->price;
|
|
$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->roomId];
|
|
$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
|
|
*/
|
|
public function getRoomAssignmentCounts(BookingDto $bookingDto): array
|
|
{
|
|
$counts = [];
|
|
foreach ($bookingDto->getParticipants() as $participant) {
|
|
if (null !== $participant->assignedRoomId) {
|
|
$counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* 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').
|
|
*
|
|
* @param array<int, Room> $rooms Rooms indexed by room ID
|
|
*
|
|
* @return array{by_pax: array<int, Room>, by_room: array<int, Room>}
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
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->roomId] ?? null;
|
|
if ($room) {
|
|
$type = $room->getSelectionType();
|
|
$groups[$type][] = $roomSelection;
|
|
}
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
/**
|
|
* Resets all participant room assignments in the DTO.
|
|
*
|
|
* Clears room assignments when room selections change to prevent
|
|
* invalid assignments. Called when users modify their room selections
|
|
* in step 1 to ensure participants are reassigned appropriately.
|
|
*
|
|
* @param BookingDto $dto The booking DTO to reset assignments for
|
|
*/
|
|
public function resetParticipantAssignments(BookingDto $dto): void
|
|
{
|
|
foreach ($dto->participants as $participant) {
|
|
$participant->assignedRoomId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a snapshot of the current room selection state.
|
|
*
|
|
* @return array<int, array{int, int}> Array of [roomId, quantity] pairs
|
|
*/
|
|
public function createRoomSelectionSnapshot(BookingDto $dto): array
|
|
{
|
|
return array_map(
|
|
fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity],
|
|
$dto->roomSelections
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if room selection has changed compared to a previous snapshot.
|
|
*
|
|
* Compares the current room selection state with a baseline snapshot
|
|
* to detect changes that would require participant reassignment.
|
|
*
|
|
* @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
|
|
*/
|
|
public function hasRoomSelectionChanged(array $oldSnapshot, BookingDto $newDto): bool
|
|
{
|
|
$newSnapshot = $this->createRoomSelectionSnapshot($newDto);
|
|
|
|
return $oldSnapshot !== $newSnapshot;
|
|
}
|
|
|
|
/**
|
|
* 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(\App\BusProNet\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 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;
|
|
}
|
|
}
|
|
}
|