Files
myep/src/Service/BookingConfigurator.php
T

653 lines
23 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\NoRoomsAvailableException;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Form\Service\ServiceAgeEvaluator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BookingConfigurator
{
public function __construct(
private readonly BookingSessionManager $bookingSessionService,
private readonly TravelDataProvider $travelDataService,
private readonly ParticipantEligibilityChecker $participantEligibilityService,
private readonly BookingStatusRuleRegistry $bookingStatusRuleRegistry,
private readonly AgencyLoader $agencyLoader,
#[Autowire(param: 'default_booking_status')]
private readonly string $defaultBookingStatus,
) {
}
/**
* 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
* - 'Option': Create-flow override from specific rules
* - '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->applyCreateBookingStatusRules($bookingCreateDto);
$this->bookingSessionService->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<int|string, int> $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 ?? 1;
$selection->quantity = $roomsIdsAndQuantities[$room->id] ?? 0;
return $selection;
}
/**
* 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
);
}
}
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);
}
/** @param array<int, Service> $mandatoryAdditionalServices */
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);
}
/** @param array<int, Service> $autoBookAdditionalServices */
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);
}
/** @param array<int, Service> $mandatorySkiPassServices */
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;
}
}
/** @param array<int, Service> $autoBookSkiPassServices */
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;
}
}
/** @param array<int, Service> $mandatoryBoardServices */
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);
}
/** @param array<int, Service> $autoBookBoardServices */
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);
}
/** @param array<int, Service> $mandatoryRentalServices */
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);
}
/** @param array<int, Service> $autoBookRentalServices */
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(option) → A
if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_FREE)) {
return Constants::BOOKING_STATUS_FREE;
}
if ($travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_OPTION)) {
return Constants::BOOKING_STATUS_OPTION;
}
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);
}
}