feat: auto-booking services

addresses #869cfcptv
This commit is contained in:
Björn Fromme committed 2026-03-16 20:24:58 +01:00
1 parent 6d9265de0f
commit c510cbf590
19 files changed
+1829 -72

No files matched your search

+435 -60
View File
@@ -6,12 +6,14 @@ 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\XmlLoader\AgencyLoader;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\NoRoomsAvailableException;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Form\Model\RoomSelectionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
@@ -460,72 +462,445 @@ class BookingService
}
/**
* Pre-selects mandatory services for all participants in the booking DTO.
* Pre-selects default services for all participants.
*
* 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
* 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
);
}
}
/**
* @deprecated Use preselectDefaultServices() instead.
*/
public function preselectMandatoryServices(BookingDto $bookingDto): void
{
$additionalServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL);
$mandatoryServices = array_filter($additionalServices, fn ($service) => true === $service->mandatory);
$this->preselectDefaultServices($bookingDto);
}
// 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;
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);
}
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);
}
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);
}
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;
}
}
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;
}
}
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);
}
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);
}
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);
}
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);
}
/**