feat: split booking between participants and applicant

addresses #869f2q1vu
This commit is contained in:
fromme committed 2026-10-16 17:49:04 +02:00
1 parent 0ee8fafbe1
commit ad6e42ab0c
44 files changed
+2024 -316

No files matched your search

@@ -49,8 +49,9 @@ class BookingDataProcessor
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not
* the first participant, so copying applicant data into participant[0] would be incorrect
* - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
* bookings (the applicant is a customer) and split bookings (the applicant does not
* travel), where it would write one person's address onto another
*/
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
{
@@ -74,32 +75,6 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index;
// For normal bookings, applicant === participant[0]: copy address and body dimensions
// from applicant when BPN returns full data only in <anmelder> and minimal data in
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
// a customer (a different entity from the first traveler).
if (0 === $index && false === $isInternalAgencyBooking) {
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
// Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
@@ -183,9 +158,53 @@ class BookingDataProcessor
}
}
// Runs once the participants are in place, because the decision needs the assembled DTO.
// The explicit flag covers internal agency bookings, whose applicant is a customer; the
// DTO predicate covers a split booking, whose participant[0] is a different person.
if (false === $isInternalAgencyBooking && $dto->isFirstParticipantApplicant()) {
$this->fillFirstParticipantFromApplicant($dto, $booking);
}
return $dto;
}
/**
* Fills gaps in participant[0] from the applicant, who is the same person.
*
* BPN routinely returns the full record in <anmelder> and only a minimal one in
* <teilnehmer id="1">. Only ever called where participant[0] IS the applicant: on a
* split booking or an internal agency booking this would write somebody else's address
* onto a traveller at the next edit submit.
*/
private function fillFirstParticipantFromApplicant(BookingDto $dto, Booking $booking): void
{
$participantData = $dto->getParticipant(0);
if (null === $participantData) {
return;
}
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
/**
* Enriches participant service selections with data from travel model.
*
@@ -254,7 +254,11 @@ class BookingPayloadBuilder
*/
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
{
$firstParticipant = $bookingDto->participants[0];
// The applicant is participant[0] on an ordinary booking, and a person of their own
// when they do not travel along.
$applicantSource = $bookingDto->applicantTravels
? $bookingDto->participants[0]
: ($bookingDto->applicant ?? $bookingDto->participants[0]);
$payload = [
'buchungsart' => $bookingType,
@@ -264,36 +268,10 @@ class BookingPayloadBuilder
'idagentur' => $bookingDto->agencyId,
];
// Add applicant (first participant data)
$payload['anmelder'] = [
'name' => $firstParticipant->lastName,
'vorname' => $firstParticipant->firstName,
'geschlecht' => $firstParticipant->gender ?? '',
'nationalitaet' => $firstParticipant->nationality ?? '',
];
// DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies
if (null !== $firstParticipant->dateOfBirth) {
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
}
// Add address for applicant
if (null !== $firstParticipant->address) {
$payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload();
}
if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) {
$payload['anmelder']['kommunikation'] = [];
if (null !== $firstParticipant->email) {
$payload['anmelder']['kommunikation']['email'] = $firstParticipant->email;
}
if (null !== $firstParticipant->mobile) {
$payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile;
}
}
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
// Determine if this is an inquiry booking for voucher handling
$isInquiryBooking = 'A' === $bookingDto->bookingStatus;
@@ -301,42 +279,13 @@ class BookingPayloadBuilder
// Add participants
$payload['teilnehmerliste']['teilnehmer'] = [];
foreach ($bookingDto->participants as $index => $participant) {
$participantData = [
'@id' => $index + 1,
'status' => self::DEFAULT_PARTICIPANT_STATUS,
'name' => $participant->lastName,
'vorname' => $participant->firstName,
'geschlecht' => $participant->gender ?? '',
'nationalitaet' => $participant->nationality ?? '',
];
// DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies
if (null !== $participant->dateOfBirth) {
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
}
// Add address (always include structure, even if empty)
$participantData['anschrift'] = $participant->address?->toPayload() ?? [
'strasse' => null,
'plz' => null,
'ort' => null,
'ortsteil' => null,
'land' => null,
];
// Add contact info for all participants
if (null !== $participant->email || null !== $participant->mobile) {
$participantData['kommunikation'] = [];
if (null !== $participant->email) {
$participantData['kommunikation']['email'] = $participant->email;
}
if (null !== $participant->mobile) {
$participantData['kommunikation']['telefonmobil'] = $participant->mobile;
}
}
$participantData = [
'@id' => $index + 1,
'status' => self::DEFAULT_PARTICIPANT_STATUS,
] + $this->buildPersonalDataPayload($participant, $applicantSource->email, true);
// Add body dimensions
if (null !== $participant->height) {
@@ -434,6 +383,61 @@ class BookingPayloadBuilder
return $payload;
}
/**
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
*
* @param ParticipantDto $person The applicant or participant to map
* @param string|null $fallbackEmail Used when the person has none of their own. BusPro
* requires a Mail-Adresse for every participant once
* a room is present and rejects the Anfrage with 650
* otherwise, but accepts one shared with the Anmelder
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
* even when empty; the anmelder only when set
*
* @return array<string, mixed>
*/
private function buildPersonalDataPayload(ParticipantDto $person, ?string $fallbackEmail = null, bool $alwaysIncludeAddress = false): array
{
$data = [
'name' => $person->lastName,
'vorname' => $person->firstName,
'geschlecht' => $person->gender ?? '',
'nationalitaet' => $person->nationality ?? '',
];
if (null !== $person->dateOfBirth) {
$data['geburtsdatum'] = $person->dateOfBirth->format('d.m.Y');
}
if (null !== $person->address) {
$data['anschrift'] = $person->address->toPayload();
} elseif ($alwaysIncludeAddress) {
$data['anschrift'] = [
'strasse' => null,
'plz' => null,
'ort' => null,
'ortsteil' => null,
'land' => null,
];
}
$email = null !== $person->email && '' !== trim($person->email)
? $person->email
: $fallbackEmail;
if (null !== $email || null !== $person->mobile) {
$data['kommunikation'] = [];
if (null !== $email) {
$data['kommunikation']['email'] = $email;
}
if (null !== $person->mobile) {
$data['kommunikation']['telefonmobil'] = $person->mobile;
}
}
return $data;
}
/**
* Adds services from a mapping to the payload.
*
@@ -9,6 +9,7 @@ use App\Entity\User;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingApplicantTravelsType;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
@@ -22,10 +23,12 @@ use App\Service\RoomAssigner;
use App\Service\TravelDataProvider;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Handles the second step of the booking creation process using card-based UI.
@@ -80,14 +83,26 @@ class Step2Controller extends AbstractBookingCreateController
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
// The account's own data belongs to whoever is the applicant: participant[0] normally,
// and the separate applicant record when they do not travel along.
$user = $this->getUser();
if ($user instanceof User
&& isset($bookingCreateDto->participants[0])
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
$user,
$bookingCreateDto->participants[0]
);
if ($user instanceof User) {
if (false === $bookingCreateDto->applicantTravels) {
$bookingCreateDto->applicant ??= new ParticipantDto();
if ($this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->applicant)) {
$bookingCreateDto->applicant = $this->prepopulationService->prefillApplicantFromUser(
$user,
$bookingCreateDto->applicant
);
}
} elseif (isset($bookingCreateDto->participants[0])
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
$user,
$bookingCreateDto->participants[0]
);
}
}
// Validate room assignments against current selection (handles back-navigation from step 2 to step 1)
@@ -122,6 +137,10 @@ class Step2Controller extends AbstractBookingCreateController
$templateData = [
'form' => $form->createView(),
'bookingCreateContext' => $context,
'applicantErrors' => $this->collectApplicantErrors($form),
'applicantTravelsForm' => $this
->createForm(BookingApplicantTravelsType::class, $bookingCreateDto)
->createView(),
];
return $this->render('booking/create/step_2.html.twig', $templateData);
@@ -200,6 +219,42 @@ class Step2Controller extends AbstractBookingCreateController
];
}
/**
* Pulls out the violations that belong to the applicant block rather than a participant.
*
* The step-2 form carries no fields of its own, so every violation surfaces at the form root
* and the page can only say "check the participants". An incomplete applicant would then be
* an invisible blocker: nothing on the page is marked and the button simply does nothing.
*
* @param FormInterface<mixed> $form
*
* @return string[] Messages to render on the applicant card
*/
private function collectApplicantErrors(FormInterface $form): array
{
if (false === $form->isSubmitted()) {
return [];
}
$messages = [];
foreach ($form->getErrors(true) as $error) {
$cause = $error->getCause();
if (false === $cause instanceof ConstraintViolationInterface) {
continue;
}
if (false === str_contains($cause->getPropertyPath(), 'applicant')) {
continue;
}
$messages[] = $error->getMessage();
}
return array_values(array_unique($messages));
}
/**
* Rejects a room selection that resolves to no participants at all.
*
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\Form\BookingApplicantTravelsType;
use App\Form\BookingApplicantType;
use App\Form\BookingParticipantType;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait;
use App\Service\ApplicantInsuranceCascade;
use App\Service\BookingConfigurator;
@@ -130,6 +133,141 @@ class Step2ParticipantController extends AbstractController
);
}
/**
* Flips the booking-level "I am not travelling myself" choice.
*
* Toggling on moves participant[0]'s personal data into the applicant record and leaves
* behind a blank traveller that keeps only its index and room assignment: the services were
* derived from an age that no longer applies. Toggling off moves it back.
*/
#[Route(
path: '/bookings/create/applicant-toggle',
name: 'app_booking_create_step_2_applicant_toggle',
methods: ['POST']
)]
public function toggleApplicantTravels(Request $request): Response
{
$result = $this->loadBookingDtoOrRedirect($request);
if ($result instanceof Response) {
return $result;
}
$bookingDto = $result;
// Internal agency bookings are pre-populated with a non-travelling applicant already,
// so the choice is neither offered nor accepted for them.
if ($bookingDto->isInternalAgencyBooking()) {
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
}
$this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel);
// Captured before handleRequest(), which binds the new choice straight onto the DTO.
$previousChoice = $bookingDto->applicantTravels;
$form = $this->createForm(BookingApplicantTravelsType::class, $bookingDto);
$form->handleRequest($request);
if (false === $form->isSubmitted() || false === $form->isValid()) {
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
}
$applicantTravels = $bookingDto->applicantTravels;
if ($applicantTravels !== $previousChoice) {
$this->participantFormSupportService->ensureParticipantExists($bookingDto, 0);
if (false === $applicantTravels) {
$bookingDto->applicant = $bookingDto->participants[0];
$bookingDto->applicant->index = null;
$bookingDto->participants[0] = $this->participantFormSupportService
->createBlankTravellerFrom($bookingDto->applicant);
} else {
$restored = $bookingDto->applicant;
if (null !== $restored) {
$restored->index = 0;
$restored->assignedRoomId = $bookingDto->participants[0]->assignedRoomId;
$bookingDto->participants[0] = $restored;
}
$bookingDto->applicant = null;
}
// Family insurance is not offered on a split booking, so a choice already made on
// participant[0] has to go with it.
if (false === $applicantTravels) {
$this->participantFormSupportService->clearFamilyInsurance($bookingDto);
}
$this->bookingService->preselectDefaultServices($bookingDto);
$this->applicantInsuranceCascade->apply($bookingDto);
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
}
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
// The choice rebuilds the whole participant list, so navigate rather than swap a fragment.
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
}
/**
* Contact data of an applicant who does not travel along.
*
* Guests only. A logged-in applicant's data comes from their account and is rendered as
* static text on the step-2 card, because editing it here would create a duplicate BusPro
* record - the same reason participant[0] is static when the applicant travels.
*/
#[Route(
path: '/bookings/create/applicant',
name: 'app_booking_create_applicant'
)]
public function editApplicant(Request $request): Response
{
$result = $this->loadBookingDtoOrRedirect($request);
if ($result instanceof Response) {
return $result;
}
$bookingDto = $result;
if (true === $bookingDto->applicantTravels || $bookingDto->isInternalAgencyBooking()) {
return $this->redirectToRoute('app_booking_create_step_2');
}
$bookingDto->applicant ??= new ParticipantDto();
$form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant);
$form->handleRequest($request);
// Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the
// whole block with dummy data instead of saving what was typed.
if ($form->isSubmitted()
&& $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) {
$this->prepopulationService->fillDummyApplicant($bookingDto->applicant);
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
return $this->redirectToRoute('app_booking_create_applicant');
}
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
return $this->redirectToRoute('app_booking_create_step_2');
}
// Keep an invalid submission in the session so the entered data survives a reload.
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
$bookingCreateContext = $this->createContextFactory->create(
$bookingDto,
RoomPricingCalculator::PRICING_MODE_SELECTION
);
return $this->render('booking/create/applicant.html.twig', [
'form' => $form,
'bookingCreateContext' => $bookingCreateContext,
'bookingDto' => $bookingCreateContext->bookingDto,
'summaryData' => $bookingCreateContext->summaryData,
]);
}
/**
* @param FormInterface<mixed> $form
* @param array<array{type: string, message: string}> $notifications
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\BookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The booking-level choice "do I travel along myself?".
*
* Rendered inside the step 2 form, so the template emits the widget alone and never a second
* <form> tag; htmx posts the enclosed inputs, the CSRF token among them, to the toggle route.
*
* Validation is switched off: this form only carries the choice, and running the step 2 groups
* here would report every gap in the participant data the moment somebody flips the radio.
*
* @extends AbstractType<BookingDto>
*/
class BookingApplicantTravelsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('applicantTravels', ChoiceType::class, [
'label' => 'Wer reist mit?',
'choices' => [
'Ich reise selbst mit' => true,
// Note the literal non-breaking space in "z. B." - it keeps the abbreviation
// from wrapping, and replacing it with a plain space is a silent regression.
'Ich buche für andere (z. B. mein Kind)' => false,
],
'expanded' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingDto::class,
'validation_groups' => false,
]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Contact data of an applicant who does not travel along.
*
* Bound to ParticipantDto rather than a contact DTO of its own: the fields are the same
* personal-data set, and a second near-identical type would only have to be kept in step with
* this one. Only shown to guests; a logged-in applicant's data comes from their account and is
* rendered as static text, because editing it here would create a duplicate BusPro record.
*
* Validation runs in the dedicated 'booking_applicant' group, which BookingDto::validateApplicant()
* owns, so the applicant is checked independently of the participant cards.
*
* @extends AbstractType<ParticipantDto>
*/
class BookingApplicantType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'label' => 'Vorname',
'required' => true,
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
'required' => true,
])
->add('gender', ChoiceType::class, [
'label' => 'Gender',
'required' => true,
'placeholder' => false,
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'required' => true,
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'required' => true,
'widget' => 'text',
'input' => 'datetime_immutable',
'html5' => false,
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => true,
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
],
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => true,
'sanitize_html' => true,
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
],
])
->add('address', AddressType::class, [
'label' => 'Adresse',
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ParticipantDto::class,
'validation_groups' => ['booking_applicant', 'booking_create'],
]);
}
}
+3 -2
View File
@@ -31,8 +31,9 @@ class BookingCreateStep2Type extends AbstractType
$resolver->setDefaults([
'data_class' => BookingDto::class,
'validation_groups' => function (FormInterface $form) {
// Create mode: always strict validation
return ['strict_required', 'booking_create'];
// Create mode: always strict validation. 'booking_applicant' additionally
// requires a complete applicant when they do not travel along.
return ['strict_required', 'booking_create', 'booking_applicant'];
},
]);
}
+3 -1
View File
@@ -210,7 +210,9 @@ class BookingParticipantType extends AbstractType
if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) {
$form->add('email', EmailType::class, $this->mergeFieldState([
'label' => 'E-Mail',
'required' => false,
// Required for every participant (BPN 650); EditFieldStateProvider clears it
// again for internal agency bookings, where staff may leave data blank.
'required' => true,
'property_path' => 'participant.email',
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
+102
View File
@@ -63,6 +63,26 @@ class BookingDto
*/
public ?Booking $booking = null;
/**
* Whether the applicant travels along, i.e. is also participant[0].
*
* Create mode only: it is the booking-level choice behind "Ich reise selbst mit".
* In edit mode the split is derived from the loaded booking instead, see
* isFirstParticipantApplicant(). Internal agency bookings never flip this.
*/
public bool $applicantTravels = true;
/**
* The applicant's own personal data, used only when they do not travel.
*
* Reuses ParticipantDto for the personal fields rather than introducing a second,
* near-identical contact DTO. Its index stays null, which is what tells the shared
* 'booking_applicant' rules apart from an ordinary traveller, and validateApplicant()
* below drives its checks explicitly rather than by cascade, so the violations land
* under 'applicant.' exactly once.
*/
public ?ParticipantDto $applicant = null;
/**
* Fingerprint of the booking state when loaded from API (edit mode only).
* This property stores the original state and is never updated after initial load.
@@ -173,6 +193,39 @@ class BookingDto
return $this->participants[$index] ?? null;
}
/**
* Whether participant[0] is also the applicant.
*
* This is the single predicate for "the first participant IS the booking's contact".
* It replaces the former practice of reading isInternalAgencyBooking() to mean the
* same thing, which only held because the internal agency was the one case where
* applicant and participant[0] differed.
*
* - internal agency: always false, their applicant is a staff member and never travels
* - create: the booking-level choice
* - edit: derived from the loaded booking. A null person id on either side counts as
* "same", so incomplete BPN data keeps today's stricter, locked-down behaviour.
*/
public function isFirstParticipantApplicant(): bool
{
if ($this->isInternalAgencyBooking()) {
return false;
}
if (self::MODE_CREATE === $this->getMode()) {
return $this->applicantTravels;
}
$applicantPersonId = $this->booking?->applicant?->personId;
$firstParticipantPersonId = $this->getParticipant(0)?->personId;
if (null === $applicantPersonId || null === $firstParticipantPersonId) {
return true;
}
return $applicantPersonId === $firstParticipantPersonId;
}
/**
* Determines if this is a family booking based on participant age distribution.
*
@@ -182,6 +235,13 @@ class BookingDto
*/
public function isFamilyBooking(): bool
{
// Family insurance is not offered when the applicant does not travel. The age
// distribution alone would not rule it out: an 18-year-old sibling travelling with a
// younger child still satisfies the adult/child counts below.
if (false === $this->applicantTravels) {
return false;
}
$adults = 0;
$children = 0;
@@ -204,6 +264,48 @@ class BookingDto
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
}
/**
* Requires a complete, adult applicant whenever the applicant does not travel.
*
* Delegates the field checks to ParticipantDto::validateApplicantCompleteness(), which the
* applicant form runs directly through the 'booking_applicant' group.
*/
#[Assert\Callback(groups: ['booking_applicant'])]
public function validateApplicant(ExecutionContextInterface $context): void
{
if (true === $this->applicantTravels) {
return;
}
if (null === $this->applicant) {
$context->buildViolation('Bitte gib die Daten der anmeldenden Person an.')
->atPath('applicant')
->addViolation();
return;
}
$this->applicant->validateApplicantCompleteness($context, 'applicant.');
}
/**
* Requires a complete address from participant[0] only where they supply the anmelder block.
*
* BusPro does not require an anschrift from an ordinary participant, so on a split booking
* the traveller at index 0 is not asked for one. Keyed on $applicantTravels rather than
* isFirstParticipantApplicant() so internal agency bookings, whose payload still builds the
* anmelder from participant[0], keep asking for it.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateFirstParticipantAddress(ExecutionContextInterface $context): void
{
if (false === $this->applicantTravels) {
return;
}
$this->getParticipant(0)?->validateAddressFields($context, 'participants[0].');
}
#[Assert\Callback]
public function validateBankAccount(ExecutionContextInterface $context): void
{
+66 -39
View File
@@ -21,8 +21,9 @@ class ParticipantDto
/**
* Age below which a participant counts as a child.
*
* Children may share an email address with an adult and are not required to have one of
* their own; both rules go through isChild(), so this is the only place the age lives.
* Children may share an email address with an adult, and have the applicant's address
* pre-filled for them; both rules go through isChild(), so this is the only place the
* age lives.
*/
public const CHILD_AGE_THRESHOLD = 16;
@@ -79,14 +80,11 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
public ?\DateTimeImmutable $dateOfBirth = null;
// Requiredness is delegated to requiresOwnEmail() so the constraint and the field state
// conditions cannot state the rule differently.
// Every participant needs an email address: BusPro rejects the step-3 Anfrage with 650
// otherwise, children included. Under-16s have the applicant's address pre-filled by
// ParticipantEmailFieldHandler and may override it, but may not leave it blank.
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
#[Assert\When(
expression: 'this.requiresOwnEmail()',
constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
groups: ['strict_required']
)]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
public ?string $email = null;
public ?string $mobile = null;
@@ -369,22 +367,6 @@ class ParticipantDto
*
* @return bool True if participant is under the age threshold, false otherwise or if age unknown
*/
/**
* Decides whether this participant has to supply an email address of their own.
*
* The applicant is the booking's contact and always has to be reachable. Everyone else only
* needs an address once they are old enough to have one, so children are exempt. An unknown
* date of birth counts as an adult, following isChild().
*
* Single source for the rule: the Assert\When on $email enforces it, and the 'required'
* field state conditions in CreateFieldStateProvider/EditFieldStateProvider render the
* matching mandatory marker.
*/
public function requiresOwnEmail(): bool
{
return $this->isApplicant() || false === $this->isChild();
}
public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool
{
$age = $this->getAge();
@@ -495,28 +477,73 @@ class ParticipantDto
}
/**
* Validates that the participant has a complete address when required.
* Validates that this person is usable as the booking's applicant.
*
* In regular create/edit submission flows this only applies to participant 0.
* The edit overview preflight checks address fields separately for the applicant only.
* Runs on its own in the 'booking_applicant' group, which only the applicant form activates,
* the way 'applicant_address' already scopes the address NotNull above. BookingDto delegates
* to the same body for the step-2 submit, so the rule has one home.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateApplicantAddress(ExecutionContextInterface $context): void
#[Assert\Callback(groups: ['booking_applicant'])]
public function validateAsApplicant(ExecutionContextInterface $context): void
{
// Only validate applicant's address in regular create/edit submission flows.
if (0 !== $this->index) {
// The applicant record is the only ParticipantDto without an index. Travellers carry one,
// and BookingDto cascades the 'booking_applicant' group into $participants as well, so
// without this guard every traveller would be held to the applicant's rules - an adult
// address and a minimum age of 18.
if (null !== $this->index) {
return;
}
$this->validateAddressFields($context);
$this->validateApplicantCompleteness($context);
}
private function validateAddressFields(ExecutionContextInterface $context): void
/**
* Shared body. $pathPrefix places the violations in the caller's object graph; it is not a
* callback itself, because Symfony passes the constraint payload as the second argument.
*/
public function validateApplicantCompleteness(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
foreach (['firstName', 'lastName', 'gender', 'nationality', 'email', 'mobile'] as $field) {
$value = $this->$field;
if (null === $value || '' === trim($value)) {
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.$field)
->addViolation();
}
}
$this->validateAddressFields($context, $pathPrefix);
if (null === $this->dateOfBirth) {
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.'dateOfBirth')
->addViolation();
return;
}
$age = $this->getAge();
if (null !== $age && 18 > $age) {
$context->buildViolation('Die anmeldende Person muss volljährig sein.')
->atPath($pathPrefix.'dateOfBirth')
->addViolation();
}
}
/**
* Validates that this person has a complete address.
*
* Only the booking's contact needs one: BusPro does not require an anschrift from an
* ordinary participant (measured on 2036, an empty anschrift passes). Deciding who the
* contact is needs the booking, so the callers own the gate and this method only ever
* runs the field checks. $pathPrefix places the violations in the caller's object graph.
*/
public function validateAddressFields(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
// Address object is required for the validated participant
if (null === $this->address) {
$context->buildViolation('Bitte angeben')
->atPath('address')
->atPath($pathPrefix.'address')
->addViolation();
return;
@@ -525,25 +552,25 @@ class ParticipantDto
// Validate address subfields
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.street')
->atPath($pathPrefix.'address.street')
->addViolation();
}
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.postCode')
->atPath($pathPrefix.'address.postCode')
->addViolation();
}
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.city')
->atPath($pathPrefix.'address.city')
->addViolation();
}
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.country')
->atPath($pathPrefix.'address.country')
->addViolation();
}
}
+24 -3
View File
@@ -92,11 +92,31 @@ class ParticipantEditDto
}
}
/**
* Requires a complete address from the participant form's applicant only.
*
* Mirrors BookingDto::validateFirstParticipantAddress() for the per-participant form, which
* binds this wrapper rather than the booking.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateApplicantAddress(ExecutionContextInterface $context): void
{
if (false === $this->participant->isApplicant()) {
return;
}
if (false === $this->bookingContext->applicantTravels) {
return;
}
$this->participant->validateAddressFields($context, 'participant.');
}
/**
* Validates that non-applicant adult participants have unique email addresses.
*
* Exempt from validation:
* - Applicant (index 0): Can share email with dependents they're booking for
* - The applicant: can share an email with dependents they're booking for
* - Children (under 16): Can share email addresses with adults
*
* Email comparison is case-insensitive and whitespace is normalized.
@@ -109,8 +129,9 @@ class ParticipantEditDto
return;
}
// In regular create/edit submission flows, the applicant can share an email with dependents.
if (true === $this->participant->isApplicant()) {
// The applicant can share an email with dependents. On a split booking participant[0] is
// an ordinary traveller, so an adult there is held to uniqueness like everyone else.
if (true === $this->participant->isApplicant() && true === $this->bookingContext->isFirstParticipantApplicant()) {
return;
}
@@ -10,15 +10,19 @@ use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that determines if the first participant's personal data should be read-only.
*
* This condition evaluates read-only state based on agency type and authentication state,
* replacing the previous personId-based detection with agency code-based detection.
* It fires only where participant[0] IS the booking's applicant, which
* BookingDto::isFirstParticipantApplicant() decides: the booking-level choice in create mode,
* and anmelder vs. participant[0] person id in edit mode. Internal agency bookings answer
* false there, which preserves the behaviour this class used to spell out itself.
*
* Business Rules:
* - CREATE mode (anonymous user): First participant data is editable (user must fill it in)
* - CREATE mode (logged-in, non-internal agency): First participant = applicant, data is read-only
* - CREATE mode (logged-in, internal agency): First participant ≠ applicant, data is editable
* - EDIT mode (internal agency): First participant ≠ applicant, data is editable (mutability applies separately)
* - EDIT mode (non-internal agency): First participant = applicant, always read-only
* - CREATE mode (logged-in, participant[0] is the applicant): data is prepopulated, read-only
* - CREATE mode (applicant does not travel, or internal agency): participant[0] is an ordinary
* traveller, data is editable
* - EDIT mode (participant[0] is the applicant): always read-only
* - EDIT mode (split booking, or internal agency): participant[0] ≠ applicant, editable here
* (PersonalDataMutabilityCondition still applies separately)
*
* Non-first participants are never affected by this condition - they return false here
* but may still be read-only via PersonalDataMutabilityCondition in edit mode.
@@ -36,45 +40,31 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
// This condition only applies to first participant (agency-based read-only)
// This condition only applies to the first participant.
// Non-first participants: return false here, but may still be read-only via PersonalDataMutabilityCondition
if (0 !== $participantIndex) {
return false;
}
$participant = $bookingDto->getParticipant($participantIndex);
// CREATE mode logic
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Anonymous user (no personId) = must fill in data = editable
if (null === $participant?->personId) {
return false;
}
// Internal agency = first participant ≠ applicant = editable
if ($bookingDto->isInternalAgencyBooking()) {
return false;
}
// Logged-in user with non-internal agency = data prepopulated = read-only
return true;
}
// EDIT mode logic
// Internal agency = first participant ≠ applicant = editable (mutability check applies separately)
if ($bookingDto->isInternalAgencyBooking()) {
// participant[0] is a traveller in their own right: nothing to protect here.
if (false === $bookingDto->isFirstParticipantApplicant()) {
return false;
}
// Non-internal agency = first participant = applicant = always read-only
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Anonymous user (no personId) = must fill in data = editable
return null !== $bookingDto->getParticipant($participantIndex)?->personId;
}
// EDIT mode: participant[0] is the applicant, whose record is owned by the account.
return true;
}
/**
* Returns field names that this condition depends on.
*
* This condition is based on agency code and personId which are set during initialization
* and don't change during form interaction, so no field dependencies.
* This condition is based on the applicant split and personId, which are set during
* initialization and don't change during form interaction, so no field dependencies.
*
* @return string[] Empty array - no field dependencies
*/
@@ -90,6 +80,6 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
*/
public function getDescription(): string
{
return 'First participant personal data is read-only for non-internal agency bookings with logged-in users';
return 'First participant personal data is read-only when they are also the applicant';
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition for "participant[0] is the person the <anmelder> block is built from".
*
* Use it where a field is only mandatory because BusPro asks it of the Anmelder rather than of
* an ordinary participant, such as mobile and address (an empty anschrift passes for a
* participant, measured on 2036).
*
* It deliberately tracks $applicantTravels, the same condition
* BookingPayloadBuilder::buildCreatePayload() keys its $applicantSource on. That keeps internal
* agency bookings unchanged: their applicant never travels, but their payload still takes the
* anmelder from participant[0], so participant[0] still owes the Anmelder's fields. This is why
* it is not isFirstParticipantApplicant(), which answers false for them.
*
* FirstParticipantCondition remains the right choice where plain index 0 is meant, such as
* showing a field on the first card regardless of who that person is.
*/
class FirstParticipantSuppliesApplicantDataCondition implements FieldConditionInterface
{
/**
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return 0 === $participantIndex && $bookingDto->applicantTravels;
}
/**
* @return string[] Empty array - the applicant choice does not change during form interaction
*/
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Checks if participant[0] supplies the anmelder block';
}
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that mirrors ParticipantDto::requiresOwnEmail() into the field state layer.
*
* It deliberately holds no logic of its own. The rule for who needs an email address is
* enforced by the Assert\When on ParticipantDto::$email; this condition exists so the
* mandatory marker rendered next to the field is derived from that same method rather than
* from a second, independently maintained composition of age and applicant conditions.
*/
class RequiresOwnEmailCondition implements FieldConditionInterface
{
/**
* Evaluates whether the participant has to supply an email address of their own.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if the participant needs their own email address
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
// A participant that does not exist yet is treated as needing an address, matching the
// "unknown counts as an adult" stance of ParticipantDto::isChild().
return null === $participant || $participant->requiresOwnEmail();
}
/**
* The rule reads the date of birth, so the state has to be recalculated when it changes.
*
* @return string[] Array containing 'dateOfBirth'
*/
public function getDependentFields(): array
{
return ['dateOfBirth'];
}
public function getDescription(): string
{
return 'Checks if the participant needs an email address of their own';
}
}
@@ -17,10 +17,10 @@ use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\FinalBookingOnlyCondition;
use App\Form\Service\Condition\FirstParticipantCondition;
use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
use App\Form\Service\Condition\FirstParticipantSuppliesApplicantDataCondition;
use App\Form\Service\Condition\MultipleParticipantsCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RequiresOwnEmailCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SingleRoomTypeCondition;
@@ -112,15 +112,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'static_text' => $firstParticipantReadOnlyCondition,
];
// Every participant needs an email address, children included (BPN 650), so
// requiredness is the form type's base option and not a condition here.
$this->fieldStateConditions['email'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => new RequiresOwnEmailCondition(),
];
// Mobile field: static text for first participant (when read-only), required for first participant (guest bookings)
// Mobile field: static text for first participant (when read-only). Mandatory only for the
// applicant (guest bookings) — BusPro does not ask a telefonmobil of an ordinary participant.
$this->fieldStateConditions['mobile'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => new FirstParticipantCondition(),
'required' => new FirstParticipantSuppliesApplicantDataCondition(),
];
// Address subfields - must render as static text to prevent creating duplicate BPN records
@@ -313,11 +315,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => FieldValueCondition::equals('parking', false),
];
// Make address field required for first participant (index 0)
// Also render as static text for first participant when read-only
// Address is mandatory for the applicant only; BusPro accepts an empty anschrift from an
// ordinary participant. Also rendered as static text for the applicant when read-only.
$this->fieldStateConditions['address'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => new FirstParticipantCondition(),
'required' => new FirstParticipantSuppliesApplicantDataCondition(),
];
// Room assignment field:
+1 -5
View File
@@ -20,7 +20,6 @@ use App\Form\Service\Condition\PersonalDataMutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RequiresOwnEmailCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SkiPassSelectionCondition;
@@ -93,10 +92,7 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
// data optional throughout.
$this->fieldStateConditions['email'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => CompositeCondition::and(
CompositeCondition::not($internalAgencyBookingCondition),
new RequiresOwnEmailCondition(),
),
'required' => CompositeCondition::not($internalAgencyBookingCondition),
];
// Address fields - render as static text if first participant in non-internal agency OR participant not mutable
@@ -203,7 +203,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
// Add notification for unassigned participant
// For internal agency bookings, first participant is not the applicant
$isApplicant = $participant->isApplicant() && false === $bookingDto->isInternalAgencyBooking();
$isApplicant = $participant->isApplicant() && $bookingDto->isFirstParticipantApplicant();
$participantLabel = $isApplicant ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1);
$participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel));
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles the email field, pre-filling the applicant's address for under-16 participants.
*
* BusPro requires a Mail-Adresse for every participant once a room is present and rejects the
* step-3 Anfrage with 650 otherwise, children included. It does accept an address identical to
* the Anmelder's, so children inherit the applicant's rather than being asked for one they
* typically do not have. The value stays editable and stays mandatory.
*
* The copy only ever fills a blank, so an address somebody typed is never overwritten. A later
* correction to the applicant's own address therefore does not propagate to children who already
* inherited the previous one.
*
* Depends on dateOfBirth: whether a participant counts as a child is only known once their date
* of birth has been processed.
*/
class ParticipantEmailFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'email';
}
/**
* @return string[]
*/
public function getDependencies(): array
{
return ['dateOfBirth'];
}
/**
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (false === $participant instanceof ParticipantDto) {
return;
}
$submitted = $this->getFieldValue($submittedData, $this->getFieldName());
if (true === is_string($submitted)) {
$participant->email = '' !== trim($submitted) ? trim($submitted) : null;
}
$this->applyApplicantEmail($bookingDto, $participant);
}
/**
* Copies the applicant's address onto a child who has none of their own.
*/
public function applyApplicantEmail(BookingDto $bookingDto, ParticipantDto $participant): void
{
if (false === $participant->isChild()) {
return;
}
$applicantEmail = $this->resolveApplicantEmail($bookingDto);
if (null === $applicantEmail) {
return;
}
// The applicant themselves never inherits, and on an ordinary booking that is participant[0].
if (true === $participant->isApplicant() && true === $bookingDto->applicantTravels) {
return;
}
if (null === $participant->email || '' === trim($participant->email)) {
$participant->email = $applicantEmail;
}
}
private function resolveApplicantEmail(BookingDto $bookingDto): ?string
{
$applicant = $bookingDto->applicantTravels
? ($bookingDto->participants[0] ?? null)
: $bookingDto->applicant;
$email = $applicant?->email;
if (null === $email || '' === trim($email)) {
return null;
}
return trim($email);
}
}
@@ -250,6 +250,9 @@ class ParticipantFieldHandlerRegistry
{
// Define fields that may be reassigned by handlers and need syncing
$serviceFields = [
// Not a service: ParticipantEmailFieldHandler pre-fills a child's blank email from
// the applicant, and without syncing it back the form would bind the blank over it.
'email',
'transportationOutbound',
'transportationInbound',
'insurance',
@@ -677,7 +677,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'attr' => [
'data-description' => sprintf(
'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
$bookingDto->isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'den/die Anmelder:in'
$bookingDto->isFirstParticipantApplicant() ? 'den/die Anmelder:in' : 'Teilnehmer:in 1'
),
],
];
+1 -1
View File
@@ -83,7 +83,7 @@ class BookingEditPreFlightChecker
}
}
if (false === $bookingDto->isInternalAgencyBooking() && true === $participant->isApplicant()) {
if (true === $bookingDto->isFirstParticipantApplicant() && true === $participant->isApplicant()) {
foreach ($this->getMissingValueAddressLabels($participant) as $valueLabel) {
$valueLabels[] = $valueLabel;
}
+18 -5
View File
@@ -33,8 +33,8 @@ class BookingNewsletterOptIn
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
if (null === $email || '' === trim((string) $email)) {
$email = $this->resolveApplicantParticipant($bookingDto)?->email;
}
if (null === $email) {
@@ -46,9 +46,16 @@ class BookingNewsletterOptIn
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
/**
* The person who is the booking's contact: participant[0] on an ordinary booking, and the
* separate applicant record when they do not travel along.
*/
public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
$participant = $bookingDto->applicantTravels
? ($bookingDto->participants[0] ?? null)
: $bookingDto->applicant;
if (null === $participant) {
return null;
}
@@ -63,7 +70,7 @@ class BookingNewsletterOptIn
/**
* Companions eligible for a third-party opt-in offer.
*
* Children are excluded because they are allowed to share an adult's address, so subscribing
* Children are excluded because they carry the applicant's address by default, so subscribing
* them would mail somebody else and would rest on a minor's consent. Deliberately no consent
* lookups here: whether the checkbox appears must not disclose who is already subscribed.
*
@@ -75,7 +82,13 @@ class BookingNewsletterOptIn
$seenEmails = [];
foreach ($bookingDto->participants as $participant) {
if (true === $participant->isApplicant() || true === $participant->isChild()) {
// participant[0] is only the booking contact when the applicant travels along; on a
// split booking they are an ordinary companion like everyone else.
if (true === $participant->isApplicant() && true === $bookingDto->applicantTravels) {
continue;
}
if (true === $participant->isChild()) {
continue;
}
+1 -1
View File
@@ -99,7 +99,7 @@ class ParticipantCardAssembler
if ('' === $name) {
// For internal agency bookings, first participant is not the applicant
if (0 === $index && false === $bookingDto->isInternalAgencyBooking()) {
if (0 === $index && $bookingDto->isFirstParticipantApplicant()) {
return 'Anmelder:in';
}
+28 -1
View File
@@ -135,6 +135,33 @@ class ParticipantDataPrefiller
/**
* Fills the participant DTO with generated dummy personal data.
*/
/**
* Fills the applicant DTO with generated dummy contact data.
*
* Same `#KUN#` shortcut as fillDummyParticipant(), but applicant-shaped: the applicant has to
* be a complete adult, so gender and nationality are filled too and the date of birth is well
* clear of 18. Used for manual testing against the BusPro test system.
*/
public function fillDummyApplicant(ParticipantDto $applicant): void
{
$now = CarbonImmutable::now();
$applicant->firstName = 'Probeelternteil';
$applicant->lastName = sprintf('Elternteil %s', $now->format('H:i'));
$applicant->gender = 'W';
$applicant->nationality = 'D';
$applicant->mobile = '0171/222222';
$applicant->email = '[email protected]';
$applicant->dateOfBirth = $now->subYears(45)->toImmutable();
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
$applicant->address = $address;
}
public function fillDummyParticipant(ParticipantDto $participant, int $participantIndex): void
{
$participantNumber = $participantIndex + 1;
@@ -150,7 +177,7 @@ class ParticipantDataPrefiller
$address->street = 'Musterstr. 123';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'DE';
$address->country = 'D';
$participant->address = $address;
}
}
+31
View File
@@ -40,6 +40,37 @@ class ParticipantFormSupport
return $participant;
}
/**
* Builds the traveller that stays behind at index 0 when the applicant stops travelling.
*
* Only the booking-structural fields carry over. Everything service-shaped was derived from
* the applicant's age and no longer applies to whoever actually travels in that slot.
*/
public function createBlankTravellerFrom(ParticipantDto $previous): ParticipantDto
{
$traveller = new ParticipantDto();
$traveller->index = 0;
$traveller->assignedRoomId = $previous->assignedRoomId;
return $traveller;
}
/**
* Drops a family insurance selection from every participant.
*
* Used when a booking turns into a split booking, where family insurance is not offered.
*/
public function clearFamilyInsurance(BookingDto $bookingDto): void
{
foreach ($bookingDto->participants as $participant) {
if (true === $participant->insurance?->familyInsurance) {
$participant->insurance = null;
}
$participant->bulkInsuranceBooking = false;
}
}
public function createParticipantEditDto(BookingDto $bookingDto, int $index): ParticipantEditDto
{
return new ParticipantEditDto(