feat: split booking between participants and applicant

addresses #869f2q1vu
This commit is contained in:
fromme committed 2026-10-19 09:18:22 +02:00
1 parent 2d49212b08
commit 1ff293f41f
44 files changed
+2024 -316

No files matched your search

+1
View File
@@ -302,6 +302,7 @@ services:
- 'App\Form\Service\ParticipantDropOffFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
- 'App\Form\Service\ParticipantEmailFieldHandler'
# Complex handlers with dependencies - use service references
- '@App\Form\Service\ParticipantDateOfBirthFieldHandler'
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
+1 -1
View File
@@ -208,7 +208,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
| `anmelder` | yes | yes | Create builds it from participant[0], or from `BookingDto::$applicant` when the applicant does not travel; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
| `teilnehmerliste` | yes | yes | |
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
@@ -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(
@@ -161,7 +161,7 @@
{{ participantIndex + 1 }}
</div>
<div class="text-2xl">
{{ participantIndex == 0 and not bookingDto.isInternalAgencyBooking() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
{{ participantIndex == 0 and bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
</div>
</div>
@@ -506,7 +506,7 @@
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic">– wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}</span>
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic">– wie {{ bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in 1' }}</span>
</legend>
{% if applicantInsurance %}
<table class="w-full table-fixed border-collapse">
@@ -541,7 +541,7 @@
{% else %}
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}</td>
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie {{ bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in 1' }}</td>
<td class="border border-primary-bg p-2 align-top w-24"></td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
@@ -0,0 +1,63 @@
{# Applicant block, shown only when the applicant does not travel along. #}
{% set isSubmitted = isSubmitted|default(false) %}
{% set applicantErrors = applicantErrors|default([]) %}
{% set isComplete = applicant is not null
and applicant.firstName is not empty
and applicant.lastName is not empty
and applicant.email is not empty
and applicant.mobile is not empty
and applicant.dateOfBirth is not null
and applicant.address is not null %}
{% set showAsError = applicantErrors is not empty or (not isComplete and isSubmitted) %}
<div class="mt-4 border-t border-gray-200 pt-4" id="applicant-card">
<div class="font-bold pb-2">Anmelder:in</div>
{% if applicantErrors is not empty %}
<ul class="mb-2 list-disc pl-5 text-sm text-red-500" {{ qa_attribute('applicant-errors') }}>
{% for message in applicantErrors %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% if is_granted('IS_AUTHENTICATED_FULLY') %}
{# Logged in: the data comes from the account. Editing it here would create a duplicate
BusPro record, which is the same reason participant[0] is static when they travel. #}
<div class="text-sm leading-5" {{ qa_attribute('applicant-static') }}>
{% if applicant is not null and applicant.firstName is not empty %}
<div>{{ applicant.firstName }} {{ applicant.lastName }}</div>
{% if applicant.address is not null %}
<div>{{ applicant.address.street }}</div>
<div>{{ applicant.address.postCode }} {{ applicant.address.city }}</div>
{% endif %}
<div>{{ applicant.email }}</div>
{% if applicant.mobile is not empty %}<div>{{ applicant.mobile }}</div>{% endif %}
{% else %}
<div class="italic text-gray-600">Deine Daten werden aus deinem Konto übernommen.</div>
{% endif %}
</div>
{% else %}
<div class="flex items-start justify-between gap-4">
<div class="{{ html_classes('text-sm leading-5', { 'italic': not isComplete }) }}"
{{ qa_attribute('applicant-summary') }}>
{% if isComplete %}
<div>{{ applicant.firstName }} {{ applicant.lastName }}</div>
<div>{{ applicant.address.postCode }} {{ applicant.address.city }}</div>
<div>{{ applicant.email }}</div>
{% elseif showAsError %}
<div class="font-medium text-red-500" {{ qa_attribute('applicant-invalid') }}>
unvollständige/fehlerhafte Angaben
</div>
{% else %}
<div class="text-gray-600">Bitte gib deine Kontaktdaten an.</div>
{% endif %}
</div>
<a href="{{ path('app_booking_create_applicant') }}"
class="button button--secondary shrink-0"
{{ qa_attribute('btn-edit-applicant') }}>
{{ isComplete ? 'Bearbeiten' : 'Angeben' }}
</a>
</div>
{% endif %}
</div>
@@ -0,0 +1,80 @@
{% extends 'layout_booking.html.twig' %}
{% block content %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate'
}
}) }}
{# Pagination - mobile only (above form) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Summary - hidden on mobile, visible on desktop #}
<div id="booking-summary"
class="hidden lg:block lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2">
{% include 'booking/_summary.html.twig' with {
'bookingDto': bookingCreateContext.bookingDto,
'summaryData': bookingCreateContext.summaryData
} %}
</div>
{# Form area - full width mobile, 3 cols desktop #}
<div class="flex-1 order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div id="main-content" class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
{# Flash messages - must be inside main-content for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
<h2 class="pb-2">Anmelder:in</h2>
<p class="pb-4 text-sm leading-5">
Du reist nicht selbst mit. Bitte gib deine Kontaktdaten an &ndash; wir
verwenden sie für die Buchungsbestätigung und für Rückfragen zur Reise.
</p>
{% if form.vars.submitted and not form.vars.valid %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe deine Angaben',
messages: ['Einige Angaben fehlen noch oder sind fehlerhaft.']
} %}
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.gender) }}
{{ form_row(form.dateOfBirth) }}
{{ form_row(form.nationality) }}
</div>
<div class="pt-4">
{{ form_row(form.address) }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
{{ form_row(form.email) }}
{{ form_row(form.mobile) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
<a href="{{ path('app_booking_create_step_2') }}"
class="button button--secondary">
Zurück
</a>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }}>
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+37 -5
View File
@@ -36,12 +36,44 @@
</h2>
{# Display form-level validation errors #}
{% set applicantErrors = applicantErrors|default([]) %}
{% if form.vars.submitted and not form.vars.valid %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe die Teilnehmerdaten',
messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
} %}
{% if applicantErrors is not empty %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe die Angaben zur anmeldenden Person',
messages: ['Die Angaben zur anmeldenden Person sind noch unvollständig oder fehlerhaft.']
} %}
{% else %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe die Teilnehmerdaten',
messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
} %}
{% endif %}
{% endif %}
{% set bookingDto = bookingCreateContext.bookingDto %}
{% if not bookingDto.isInternalAgencyBooking() %}
{# No form_start here: this sits inside the step 2 form and nested
form tags are invalid. htmx posts the inputs enclosed below,
including the CSRF token, to the toggle route. #}
<div class="mb-6 rounded border border-gray-200 bg-white p-4"
hx-post="{{ path('app_booking_create_step_2_applicant_toggle') }}"
hx-trigger="change"
hx-include="this"
{{ qa_attribute('applicant-travels') }}>
{{ form_row(applicantTravelsForm.applicantTravels) }}
{{ form_rest(applicantTravelsForm) }}
{% if not bookingDto.applicantTravels %}
{% include 'booking/create/_applicant_card.html.twig' with {
'applicant': bookingDto.applicant,
'isSubmitted': bookingCreateContext.isSubmitted,
'applicantErrors': applicantErrors
} %}
{% endif %}
</div>
{% endif %}
<div class="divide-y divide-gray-200">
+39 -1
View File
@@ -116,6 +116,44 @@
</div>
{% endif %}
{% if not bookingCreateContext.bookingDto.applicantTravels
and bookingCreateContext.bookingDto.applicant is not null %}
{% set applicant = bookingCreateContext.bookingDto.applicant %}
<h3 class="text-xl font-semibold mb-4">
Anmelder:in
</h3>
<div class="border border-primary-bg mb-4" {{ qa_attribute('summary-applicant') }}>
<div class="p-2 bg-primary-bg uppercase font-semibold">
{{ applicant.firstName }} {{ applicant.lastName }}
<span class="text-sm font-normal normal-case">(reist nicht mit)</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 p-2 text-sm">
{% if applicant.dateOfBirth %}
<div>
<span class="text-gray-600 font-semibold">Geburtsdatum:</span>
{{ applicant.dateOfBirth|date('d.m.Y') }}
</div>
{% endif %}
{% if applicant.address is not null %}
<div>
<span class="text-gray-600 font-semibold">Adresse:</span>
{{ applicant.address.street }}, {{ applicant.address.postCode }} {{ applicant.address.city }}
</div>
{% endif %}
<div>
<span class="text-gray-600 font-semibold">E-Mail:</span>
{{ applicant.email }}
</div>
{% if applicant.mobile %}
<div>
<span class="text-gray-600 font-semibold">Telefon (mobil):</span>
{{ applicant.mobile }}
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Participants Summary #}
<h3 class="text-xl font-semibold mb-4">
Teilnehmer
@@ -126,7 +164,7 @@
<div class="p-2 bg-primary-bg uppercase font-semibold flex justify-between items-center">
<span>
{{ participant.firstName }} {{ participant.lastName }}
{% if loop.first and not bookingCreateContext.bookingDto.isInternalAgencyBooking() %}<span class="text-sm font-normal">(Anmelder:in)</span>{% endif %}
{% if loop.first and bookingCreateContext.bookingDto.isFirstParticipantApplicant() %}<span class="text-sm font-normal">(Anmelder:in)</span>{% endif %}
</span>
{% if bookingCreateContext.participantPrices is defined and bookingCreateContext.participantPrices[loop.index0] is defined %}
<span>{{ bookingCreateContext.participantPrices[loop.index0]|format_currency('EUR') }}</span>
@@ -155,6 +155,79 @@ class BookingDataProcessorTest extends TestCase
$this->assertSame(194672, $dto->participants[0]->veg?->id);
}
public function testApplicantAddressIsCopiedOntoFirstParticipantOnAnOrdinaryBooking(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
$booking->applicant->personId = 4711;
$booking->participants[0]->personId = 4711;
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$booking->participants[0]->height = null;
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertSame(
'Elternweg 7',
$dto->participants[0]->address?->street,
'BPN returns the full record only in <anmelder>, so participant[0] is filled from it'
);
$this->assertSame('175', $dto->participants[0]->height);
}
public function testApplicantAddressIsNotCopiedOntoFirstParticipantOnASplitBooking(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
// The applicant is a different person: a parent who booked for their child.
$booking->applicant->personId = 4711;
$booking->participants[0]->personId = 4712;
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$booking->participants[0]->height = null;
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertNull(
$dto->participants[0]->address?->street,
"The parent's address must not be written onto the child at the next edit submit"
);
$this->assertNull(
$dto->participants[0]->height,
"The parent's body dimensions must not be written onto the child either"
);
}
public function testApplicantAddressIsCopiedWhenPersonIdsAreUnknown(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertSame(
'Elternweg 7',
$dto->participants[0]->address?->street,
'Incomplete BPN data falls back to the established, stricter behaviour'
);
}
private function createFilledAddress(string $street): Address
{
$address = new Address();
$address->street = $street;
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
return $address;
}
public function testTransportationServicesProcessing(): void
{
$formData = $this->createFormDataWithTransportation();
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\DataProcessor;
use App\BusProNet\DataProcessor\BookingPayloadBuilder;
use App\BusProNet\DataProcessor\ServiceMappingCollector;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
/**
* Covers who the <anmelder> block is built from, and the participant email fallback.
*/
class BookingPayloadBuilderTest extends TestCase
{
private BookingPayloadBuilder $builder;
protected function setUp(): void
{
$mappingCollector = $this->createStub(ServiceMappingCollector::class);
$mappingCollector->method('getPromoVoucherCodeForParticipant')->willReturn(null);
$this->builder = new BookingPayloadBuilder($mappingCollector);
}
public function testAnmelderComesFromFirstParticipantWhenTheApplicantTravels(): void
{
$bookingDto = $this->createBookingDto();
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame('Kind', $payload['anmelder']['vorname']);
$this->assertSame('Kind', $payload['teilnehmerliste']['teilnehmer'][0]['vorname']);
}
public function testAnmelderComesFromTheApplicantRecordWhenTheyDoNotTravel(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame('Elternteil', $payload['anmelder']['vorname']);
$this->assertSame('[email protected]', $payload['anmelder']['kommunikation']['email']);
$this->assertSame(
'Kind',
$payload['teilnehmerliste']['teilnehmer'][0]['vorname'],
'participant[0] stays an ordinary traveller'
);
}
public function testAnmelderStillCarriesNoIdsOnCreate(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$applicant = $this->createPerson('Elternteil', '[email protected]');
$applicant->personId = 4711;
$applicant->addressId = 1234;
$bookingDto->applicant = $applicant;
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertArrayNotHasKey('idadresseperson', $payload['anmelder']);
$this->assertArrayNotHasKey('idadresse', $payload['anmelder']);
}
public function testParticipantWithoutEmailInheritsTheApplicants(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$bookingDto->participants[0]->email = null;
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame(
'[email protected]',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation']['email'],
'BusPro rejects a participant without a Mail-Adresse with 650, but accepts a shared one'
);
}
public function testParticipantKeepsTheirOwnEmail(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame(
'[email protected]',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation']['email']
);
}
public function testOrdinaryBookingPayloadIsUnchangedByTheApplicantSwitch(): void
{
$withApplicantTravelling = $this->builder->buildCreatePayload($this->createBookingDto(), 'BUCHUNG');
$bookingDto = $this->createBookingDto();
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$this->assertSame(
$withApplicantTravelling,
$this->builder->buildCreatePayload($bookingDto, 'BUCHUNG'),
'A stale applicant record must not leak into a booking where the applicant travels'
);
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 12500;
$travel->hotelId = 223055;
$bookingDto = new BookingDto($travel, 223055);
$bookingDto->agencyId = 77883;
$participant = $this->createPerson('Kind', '[email protected]');
$participant->index = 0;
$bookingDto->participants = [$participant];
return $bookingDto;
}
private function createPerson(string $firstName, string $email): ParticipantDto
{
$person = new ParticipantDto();
$person->firstName = $firstName;
$person->lastName = 'Probe';
$person->gender = 'W';
$person->nationality = 'D';
$person->dateOfBirth = new \DateTimeImmutable('2014-09-16');
$person->email = $email;
$person->mobile = '0171/222222';
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
$person->address = $address;
return $person;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\BusProNet\Model\Travel;
use App\Form\BookingApplicantTravelsType;
use App\Form\Model\BookingDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Forms;
class BookingApplicantTravelsTypeTest extends TestCase
{
public function testBooleanChoicesRenderAsRadioValues(): void
{
$form = $this->createForm($this->createBookingDto());
$values = [];
foreach ($form->get('applicantTravels')->createView()->vars['choices'] as $choice) {
$values[] = $choice->value;
}
// Symfony derives these from the booleans on its own, so the type needs no choice_value.
// Pinned because the whole toggle silently stops working if that ever changes.
$this->assertSame(['1', '0'], $values);
}
public function testSubmittingTheSplitChoiceFlipsTheFlag(): void
{
$bookingDto = $this->createBookingDto();
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '0']);
$this->assertTrue($form->isSynchronized());
$this->assertTrue($form->isValid());
$this->assertFalse($bookingDto->applicantTravels);
}
public function testSubmittingTheTravellingChoiceRestoresTheFlag(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '1']);
$this->assertTrue($form->isValid());
$this->assertTrue($bookingDto->applicantTravels);
}
public function testCurrentChoiceIsPreselected(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$view = $this->createForm($bookingDto)->get('applicantTravels')->createView();
$this->assertSame('0', $view->vars['value']);
}
public function testNoValidationRunsOnTheToggle(): void
{
$bookingDto = $this->createBookingDto();
// Deliberately incomplete: flipping the radio must not report participant gaps.
$bookingDto->participants = [];
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '0']);
$this->assertCount(0, $form->getErrors(true));
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-06-01');
return new BookingDto($travel, 1);
}
/** @return \Symfony\Component\Form\FormInterface<BookingDto> */
private function createForm(BookingDto $bookingDto): \Symfony\Component\Form\FormInterface
{
return Forms::createFormFactory()->create(BookingApplicantTravelsType::class, $bookingDto);
}
}
+241 -1
View File
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\BusProNet\Model\Travel;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
/**
* Tests for BookingDto validation and business logic.
@@ -128,4 +132,240 @@ class BookingDtoTest extends TestCase
$this->assertSame('Test City', $restored->participants[0]->address->city);
$this->assertSame('DE', $restored->participants[0]->address->country);
}
public function testFirstParticipantIsTheApplicantByDefaultInCreateMode(): void
{
$dto = $this->createCreateModeDto();
$this->assertTrue($dto->isFirstParticipantApplicant());
}
public function testFirstParticipantIsNotTheApplicantWhenTheApplicantDoesNotTravel(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$this->assertFalse($dto->isFirstParticipantApplicant());
}
public function testInternalAgencyBookingNeverHasTheFirstParticipantAsApplicant(): void
{
$dto = $this->createCreateModeDto();
$dto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE;
$this->assertFalse(
$dto->isFirstParticipantApplicant(),
'Their applicant is a staff member who never travels'
);
}
public function testEditModeDerivesTheSplitFromPersonIds(): void
{
$dto = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: 4711);
$this->assertTrue($dto->isFirstParticipantApplicant());
$split = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: 4712);
$this->assertFalse($split->isFirstParticipantApplicant());
}
public function testEditModeTreatsAnUnknownPersonIdAsTheSamePerson(): void
{
$dto = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: null);
$this->assertTrue(
$dto->isFirstParticipantApplicant(),
'Incomplete BPN data must fall back to the established, locked-down behaviour'
);
}
public function testFamilyBookingRequiresTheApplicantToTravel(): void
{
$dto = $this->createCreateModeDto();
$dto->participants = [
$this->createParticipantAged(40),
$this->createParticipantAged(12),
];
$this->assertTrue($dto->isFamilyBooking());
$dto->applicantTravels = false;
$this->assertFalse(
$dto->isFamilyBooking(),
'Family insurance is not offered when the applicant does not travel'
);
}
public function testAdultSiblingWithAChildIsNotAFamilyBookingOnceTheApplicantStaysHome(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants = [
$this->createParticipantAged(18),
$this->createParticipantAged(12),
];
$this->assertFalse(
$dto->isFamilyBooking(),
'The age distribution alone would still qualify, which is why the flag is checked first'
);
}
public function testOldSessionsWithoutTheApplicantFieldsRestoreToTheDefaults(): void
{
$travel = new Travel();
$travel->id = 42;
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$dto = new BookingDto($travel, 1);
$data = $dto->__serialize();
unset($data['applicantTravels'], $data['applicant']);
$restored = new BookingDto($travel, 1);
$restored->__unserialize($data);
$this->assertTrue($restored->applicantTravels);
$this->assertNull($restored->applicant);
}
private function createCreateModeDto(): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
$dto = new BookingDto($travel, 1);
$dto->participants = [$this->createParticipantAged(40)];
return $dto;
}
private function createEditModeDto(?int $applicantPersonId, ?int $firstParticipantPersonId): BookingDto
{
$dto = $this->createCreateModeDto();
$applicant = new PersonalData();
$applicant->personId = $applicantPersonId;
$booking = new Booking();
$booking->applicant = $applicant;
$dto->booking = $booking;
$dto->participants[0]->personId = $firstParticipantPersonId;
return $dto;
}
private function createParticipantAged(int $years): ParticipantDto
{
$participant = new ParticipantDto();
$participant->dateOfBirth = (new \DateTimeImmutable('2025-06-01'))->modify(sprintf('-%d years', $years));
return $participant;
}
public function testApplicantRulesDoNotLeakOntoTravellers(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants = [$this->createParticipantAged(15)];
$dto->participants[0]->index = 0;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->gender = 'W';
$applicant->nationality = 'D';
$applicant->email = '[email protected]';
$applicant->mobile = '0171/222222';
$applicant->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$applicant->address = $this->createAddress();
$dto->applicant = $applicant;
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_applicant']);
$paths = [];
foreach ($violations as $violation) {
$paths[] = $violation->getPropertyPath();
}
$this->assertSame(
[],
$paths,
'A 15-year-old traveller must not be held to the applicant rules through the group cascade'
);
}
public function testIncompleteApplicantIsReportedUnderTheApplicantPathOnlyOnce(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants[0]->index = 0;
$applicant = new ParticipantDto();
$applicant->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$dto->applicant = $applicant;
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_applicant']);
$paths = [];
foreach ($violations as $violation) {
$paths[] = $violation->getPropertyPath();
}
$this->assertContains('applicant.firstName', $paths);
$this->assertContains(
'applicant.address.street',
$paths,
'A fresh ParticipantDto carries an empty Address, so the gaps surface per subfield'
);
$this->assertSame(
array_unique($paths),
$paths,
'Explicit delegation plus a cascade would report each gap twice'
);
}
public function testMinorApplicantIsRejected(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants[0]->index = 0;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->gender = 'W';
$applicant->nationality = 'D';
$applicant->email = '[email protected]';
$applicant->mobile = '0171/222222';
$applicant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-17 years');
$applicant->address = $this->createAddress();
$dto->applicant = $applicant;
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_applicant']);
$messages = [];
foreach ($violations as $violation) {
$messages[] = $violation->getMessage();
}
$this->assertContains('Die anmeldende Person muss volljährig sein.', $messages);
}
private function createAddress(): Address
{
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
return $address;
}
}
+9 -21
View File
@@ -205,46 +205,34 @@ class ParticipantDtoTest extends TestCase
return $insurance;
}
public function testRequiresOwnEmailIsFalseForNonApplicantChild(): void
public function testIsChildIsTrueBelowTheAgeThreshold(): void
{
$participant = new ParticipantDto();
$participant->index = 1;
$participant->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->assertFalse($participant->requiresOwnEmail());
$this->assertTrue($participant->isChild());
}
public function testRequiresOwnEmailIsTrueForNonApplicantAdult(): void
public function testIsChildIsFalseForAnAdult(): void
{
$participant = new ParticipantDto();
$participant->index = 1;
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->assertTrue($participant->requiresOwnEmail());
$this->assertFalse($participant->isChild());
}
public function testRequiresOwnEmailIsTrueForTheApplicantEvenAsAChild(): void
{
$participant = new ParticipantDto();
$participant->index = 0;
$participant->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->assertTrue(
$participant->requiresOwnEmail(),
'The applicant is the booking contact and always has to be reachable'
);
}
public function testRequiresOwnEmailTreatsUnknownAgeAsAdult(): void
public function testIsChildTreatsUnknownAgeAsAdult(): void
{
$participant = new ParticipantDto();
$participant->index = 1;
$participant->dateOfBirth = null;
$this->assertTrue($participant->requiresOwnEmail());
$this->assertFalse($participant->isChild(), 'An unknown date of birth counts as an adult');
}
public function testRequiresOwnEmailBoundaryAtChildAgeThreshold(): void
public function testIsChildBoundaryAtAgeThreshold(): void
{
$exactlyThreshold = new ParticipantDto();
$exactlyThreshold->index = 1;
@@ -257,7 +245,7 @@ class ParticipantDtoTest extends TestCase
->modify(sprintf('-%d years', ParticipantDto::CHILD_AGE_THRESHOLD))
->modify('+1 day');
$this->assertTrue($exactlyThreshold->requiresOwnEmail());
$this->assertFalse($oneDayShort->requiresOwnEmail());
$this->assertFalse($exactlyThreshold->isChild());
$this->assertTrue($oneDayShort->isChild());
}
}
+26 -6
View File
@@ -877,7 +877,7 @@ class ParticipantEditDtoTest extends TestCase
* available ski pass. Ski pass validation depends on the travel actually offering
* passes, so a travel without them is a distinct fixture, not the default.
*/
public function testChildWithoutEmailPassesStrictValidation(): void
public function testChildWithoutEmailFailsStrictValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
@@ -886,10 +886,14 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame([], $this->emailRequiredMessages($violations));
$this->assertSame(
['Bitte angeben'],
$this->emailRequiredMessages($violations),
'BusPro requires a Mail-Adresse for every participant, children included'
);
}
public function testChildWithNullEmailPassesStrictValidation(): void
public function testChildWithNullEmailFailsStrictValidation(): void
{
$child = $this->createChildParticipant('');
$child->email = null;
@@ -901,7 +905,23 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame([], $this->emailRequiredMessages($violations));
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testChildSharingTheApplicantsEmailPassesStrictValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame(
[],
$this->emailRequiredMessages($violations),
'A child inherits the applicant address, which BusPro accepts'
);
}
public function testChildWithMalformedEmailStillFailsEmailFormatValidation(): void
@@ -984,7 +1004,7 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testDependentOneDayShortOfSixteenDoesNotRequireEmail(): void
public function testDependentOneDayShortOfSixteenStillRequiresEmail(): void
{
$participant = $this->createAdultParticipant('');
$participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years')->modify('+1 day');
@@ -996,7 +1016,7 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame([], $this->emailRequiredMessages($violations));
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
/**
@@ -74,57 +74,55 @@ class CreateFieldStateProviderTest extends TestCase
);
}
public function testEmailIsNotRequiredForNonApplicantChild(): void
public function testEmailIsRequiredForEveryParticipantIncludingChildren(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$this->assertFalse(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'A child travelling with an applicant does not need an email address of their own'
$this->assertArrayNotHasKey(
'required',
$this->provider->getFieldState('email', $bookingDto, 1),
'Requiredness is the form type base option now that it is unconditional'
);
}
public function testEmailIsRequiredForNonApplicantAdult(): void
public function testMobileIsRequiredForTheApplicantOnly(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('1990-01-01'));
$this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']);
$this->assertTrue($this->provider->getFieldState('mobile', $bookingDto, 0)['required']);
$this->assertFalse($this->provider->getFieldState('mobile', $bookingDto, 1)['required']);
}
public function testEmailIsRequiredForTheApplicantEvenAsAChild(): void
public function testMobileAndAddressAreNotRequiredOfParticipantZeroWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto->applicantTravels = false;
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 0)['required'],
'The applicant is the booking contact and always has to be reachable'
$this->assertFalse(
$this->provider->getFieldState('mobile', $bookingDto, 0)['required'],
'BusPro does not ask a telefonmobil of an ordinary participant'
);
$this->assertFalse(
$this->provider->getFieldState('address', $bookingDto, 0)['required'],
'BusPro accepts an empty anschrift for an ordinary participant'
);
}
public function testEmailIsRequiredWhenDateOfBirthIsUnknown(): void
public function testParticipantZeroIsEditableWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[1]->dateOfBirth = null;
$bookingDto->participants[0]->personId = 4711;
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'An unknown age counts as an adult'
$this->provider->shouldRenderAsStaticText('firstName', $bookingDto, 0),
'A logged-in applicant who travels keeps their prefilled data locked'
);
}
public function testEmailRequirementFlipsWhenDateOfBirthChanges(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$dependent = $bookingDto->participants[1];
$bookingDto->applicantTravels = false;
$this->assertFalse($this->provider->getFieldState('email', $bookingDto, 1)['required']);
$dependent->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'Correcting a child date of birth to an adult one must reinstate the requirement'
$this->assertFalse(
$this->provider->shouldRenderAsStaticText('firstName', $bookingDto, 0),
'Once the applicant does not travel, participant[0] is an ordinary traveller'
);
}
@@ -31,11 +31,14 @@ class EditFieldStateProviderTest extends TestCase
$this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']);
}
public function testEmailIsNotRequiredForNonApplicantChild(): void
public function testEmailIsRequiredForNonApplicantChild(): void
{
$bookingDto = $this->createBookingDto(new \DateTimeImmutable('2015-01-01'), false);
$this->assertFalse($this->provider->getFieldState('email', $bookingDto, 1)['required']);
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'BusPro requires a Mail-Adresse for every participant, children included'
);
}
public function testEmailIsNotRequiredForInternalAgencyBookingsEvenForAdults(): void
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantDateOfBirthFieldHandler;
use App\Form\Service\ParticipantEmailFieldHandler;
use App\Form\Service\ParticipantFieldHandlerRegistry;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class ParticipantEmailFieldHandlerTest extends TestCase
{
private ParticipantEmailFieldHandler $handler;
protected function setUp(): void
{
$this->handler = new ParticipantEmailFieldHandler();
}
public function testChildWithoutEmailInheritsTheApplicants(): void
{
$bookingDto = $this->createBookingDto();
$this->handler->processField(['email' => ''], $bookingDto, 1);
$this->assertSame('[email protected]', $bookingDto->participants[1]->email);
}
public function testChildKeepsAnEmailThatWasTypedIn(): void
{
$bookingDto = $this->createBookingDto();
$this->handler->processField(['email' => '[email protected]'], $bookingDto, 1);
$this->assertSame(
'[email protected]',
$bookingDto->participants[1]->email,
'The pre-filled address stays overridable'
);
}
public function testAdultWithoutEmailDoesNotInherit(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->participants[1]->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->handler->processField(['email' => ''], $bookingDto, 1);
$this->assertNull(
$bookingDto->participants[1]->email,
'An adult supplies their own address; NotBlank reports the gap'
);
}
public function testApplicantDoesNotInheritFromThemselves(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->handler->processField(['email' => ''], $bookingDto, 0);
$this->assertNull($bookingDto->participants[0]->email);
}
public function testChildAtIndexZeroInheritsWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createParticipant('[email protected]', '1981-03-16');
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->handler->processField(['email' => ''], $bookingDto, 0);
$this->assertSame('[email protected]', $bookingDto->participants[0]->email);
}
public function testHandlerRunsAfterDateOfBirth(): void
{
$this->assertSame(
['dateOfBirth'],
$this->handler->getDependencies(),
'Whether someone counts as a child is only known once their DOB is processed'
);
}
/**
* The handler runs in PRE_SUBMIT and writes to the DTO, but the form then binds the submitted
* data over it. Only fields the registry syncs back survive, so the pre-filled address has to
* reappear in the returned data - otherwise the blank wins and the field renders empty.
*/
public function testPreFilledEmailIsSyncedBackIntoTheSubmittedData(): void
{
$bookingDto = $this->createBookingDto();
$registry = new ParticipantFieldHandlerRegistry([
new ParticipantDateOfBirthFieldHandler(new NullLogger()),
new ParticipantEmailFieldHandler(),
]);
$synced = $registry->processFieldsForParticipantAndSync(['email' => ''], $bookingDto, 1);
$this->assertSame('[email protected]', $bookingDto->participants[1]->email);
$this->assertSame(
'[email protected]',
$synced['email'],
'Without the sync the form binds the blank back over the pre-filled address'
);
}
private function createBookingDto(): BookingDto
{
$bookingDto = new BookingDto(new Travel(), 1);
$bookingDto->participants = [
$this->createParticipant('[email protected]', '1980-01-01'),
$this->createParticipant('', '2015-01-01'),
];
$bookingDto->participants[0]->index = 0;
$bookingDto->participants[1]->index = 1;
$bookingDto->participants[1]->email = null;
return $bookingDto;
}
private function createParticipant(string $email, string $dateOfBirth): ParticipantDto
{
$participant = new ParticipantDto();
$participant->email = '' !== $email ? $email : null;
$participant->dateOfBirth = new \DateTimeImmutable($dateOfBirth);
return $participant;
}
}
@@ -172,6 +172,59 @@ class BookingNewsletterOptInTest extends TestCase
);
}
public function testApplicantIsTheSeparateRecordWhenTheyDoNotTravel(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 12]]);
$bookingDto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->email = '[email protected]';
$bookingDto->applicant = $applicant;
$service = $this->createService($this->createStub(NewsletterManager::class));
$this->assertSame('[email protected]', $service->resolveApplicantEmail($bookingDto, null));
$this->assertSame($applicant, $service->resolveApplicantParticipant($bookingDto));
$this->assertSame('Probe Elternteil', $service->resolveRequestedByName($bookingDto));
}
public function testAdultAtIndexZeroIsOfferedACompanionOptInOnASplitBooking(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 25]]);
$bookingDto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->email = '[email protected]';
$bookingDto->applicant = $applicant;
$service = $this->createService($this->createStub(NewsletterManager::class));
$companions = $service->resolveCompanions($bookingDto, '[email protected]');
$this->assertCount(
1,
$companions,
'participant[0] is an ordinary companion once the applicant stays home'
);
$this->assertSame('[email protected]', $companions[0]->email);
}
public function testChildCarryingTheApplicantAddressIsNotOfferedACompanionOptIn(): void
{
$bookingDto = $this->createBookingDto([
['[email protected]', 40],
['[email protected]', 12],
]);
$service = $this->createService($this->createStub(NewsletterManager::class));
$companions = $service->resolveCompanions($bookingDto, '[email protected]');
$this->assertSame([], $companions);
}
private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn
{
return new BookingNewsletterOptIn($newsletterManager, new NullLogger());
+27 -1
View File
@@ -300,7 +300,33 @@ class ParticipantDataPrefillerTest extends TestCase
$this->assertSame('[email protected]', $participant->email);
$this->assertSame('2006-01-15', $participant->dateOfBirth?->format('Y-m-d'));
$this->assertSame('Musterstr. 123', $participant->address?->street);
$this->assertSame('DE', $participant->address?->country);
$this->assertSame(
'D',
$participant->address?->country,
"BusPro's country tokens are not ISO codes: 'DE' matches no choice and leaves the select empty"
);
CarbonImmutable::setTestNow();
}
public function testFillDummyApplicantSetsACompleteAdult(): void
{
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 15, 14, 30));
$applicant = new ParticipantDto();
$this->service()->fillDummyApplicant($applicant);
$this->assertSame('Probeelternteil', $applicant->firstName);
$this->assertSame('Elternteil 14:30', $applicant->lastName);
$this->assertSame('[email protected]', $applicant->email);
$this->assertSame('0171/222222', $applicant->mobile);
$this->assertSame('Probeweg 7', $applicant->address?->street);
// The applicant block requires these, unlike a participant.
$this->assertSame('W', $applicant->gender);
$this->assertSame('D', $applicant->nationality);
$this->assertSame('D', $applicant->address?->country);
$this->assertGreaterThanOrEqual(18, $applicant->getAge());
CarbonImmutable::setTestNow();
}
+47 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\Exception\ParticipantNotFoundException;
use App\Form\Model\BookingDto;
@@ -80,8 +81,53 @@ class ParticipantFormSupportTest extends TestCase
}
/**
* array<string, int>|null $bodyDimensionRanges
* array<string, int>|null $bodyDimensionRanges.
*/
public function testBlankTravellerKeepsTheRoomAssignmentAndDropsTheRest(): void
{
$previous = new ParticipantDto();
$previous->index = 0;
$previous->assignedRoomId = 4711;
$previous->firstName = 'Elternteil';
$previous->email = '[email protected]';
$previous->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$traveller = $this->createService()->createBlankTravellerFrom($previous);
$this->assertSame(0, $traveller->index);
$this->assertSame(4711, $traveller->assignedRoomId);
$this->assertNull($traveller->firstName);
$this->assertNull($traveller->email);
$this->assertNull(
$traveller->dateOfBirth,
'The services were derived from an age that no longer applies to this slot'
);
}
public function testClearFamilyInsuranceRemovesOnlyFamilyPolicies(): void
{
$bookingDto = $this->createBookingDto();
$familyInsurance = new Insurance();
$familyInsurance->familyInsurance = true;
$bookingDto->participants[0]->insurance = $familyInsurance;
$bookingDto->participants[0]->bulkInsuranceBooking = true;
$ordinaryInsurance = new Insurance();
$ordinaryInsurance->familyInsurance = false;
$bookingDto->participants[1]->insurance = $ordinaryInsurance;
$this->createService()->clearFamilyInsurance($bookingDto);
$this->assertNull($bookingDto->participants[0]->insurance);
$this->assertFalse($bookingDto->participants[0]->bulkInsuranceBooking);
$this->assertSame(
$ordinaryInsurance,
$bookingDto->participants[1]->insurance,
'Only the family policy goes; an ordinary one is still valid on a split booking'
);
}
private function createService(?array $bodyDimensionRanges = null): ParticipantFormSupport
{
$bodyDimensionRanges ??= [