feat: split booking between participants and applicant
addresses #869f2q1vu
This commit is contained in:
1 parent
2d49212b08
commit
1ff293f41f
44 files changed
+2024
-316
No files matched your search
@@ -49,8 +49,9 @@ class BookingDataProcessor
|
||||
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
|
||||
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the
|
||||
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
|
||||
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not
|
||||
* the first participant, so copying applicant data into participant[0] would be incorrect
|
||||
* - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
|
||||
* bookings (the applicant is a customer) and split bookings (the applicant does not
|
||||
* travel), where it would write one person's address onto another
|
||||
*/
|
||||
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
|
||||
{
|
||||
@@ -74,32 +75,6 @@ class BookingDataProcessor
|
||||
$participantData = ParticipantDto::fromPersonalData($participant);
|
||||
$participantData->index = $index;
|
||||
|
||||
// For normal bookings, applicant === participant[0]: copy address and body dimensions
|
||||
// from applicant when BPN returns full data only in <anmelder> and minimal data in
|
||||
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
|
||||
// a customer (a different entity from the first traveler).
|
||||
if (0 === $index && false === $isInternalAgencyBooking) {
|
||||
// Copy address if first participant has no street (indicating empty/incomplete address)
|
||||
$isEmpty = null === $participantData->address
|
||||
|| null === $participantData->address->street
|
||||
|| u($participantData->address->street)->trim()->isEmpty();
|
||||
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
}
|
||||
|
||||
// Copy body dimensions from applicant if not present in participant
|
||||
if (null === $participantData->height && null !== $booking->applicant->height) {
|
||||
$participantData->height = $booking->applicant->height;
|
||||
}
|
||||
if (null === $participantData->weight && null !== $booking->applicant->weight) {
|
||||
$participantData->weight = $booking->applicant->weight;
|
||||
}
|
||||
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
|
||||
$participantData->shoeSize = $booking->applicant->shoeSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract service selections from booking and assign to participant DTO
|
||||
$participantData->courses = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
|
||||
@@ -183,9 +158,53 @@ class BookingDataProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// Runs once the participants are in place, because the decision needs the assembled DTO.
|
||||
// The explicit flag covers internal agency bookings, whose applicant is a customer; the
|
||||
// DTO predicate covers a split booking, whose participant[0] is a different person.
|
||||
if (false === $isInternalAgencyBooking && $dto->isFirstParticipantApplicant()) {
|
||||
$this->fillFirstParticipantFromApplicant($dto, $booking);
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills gaps in participant[0] from the applicant, who is the same person.
|
||||
*
|
||||
* BPN routinely returns the full record in <anmelder> and only a minimal one in
|
||||
* <teilnehmer id="1">. Only ever called where participant[0] IS the applicant: on a
|
||||
* split booking or an internal agency booking this would write somebody else's address
|
||||
* onto a traveller at the next edit submit.
|
||||
*/
|
||||
private function fillFirstParticipantFromApplicant(BookingDto $dto, Booking $booking): void
|
||||
{
|
||||
$participantData = $dto->getParticipant(0);
|
||||
|
||||
if (null === $participantData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy address if first participant has no street (indicating empty/incomplete address)
|
||||
$isEmpty = null === $participantData->address
|
||||
|| null === $participantData->address->street
|
||||
|| u($participantData->address->street)->trim()->isEmpty();
|
||||
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
}
|
||||
|
||||
// Copy body dimensions from applicant if not present in participant
|
||||
if (null === $participantData->height && null !== $booking->applicant->height) {
|
||||
$participantData->height = $booking->applicant->height;
|
||||
}
|
||||
if (null === $participantData->weight && null !== $booking->applicant->weight) {
|
||||
$participantData->weight = $booking->applicant->weight;
|
||||
}
|
||||
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
|
||||
$participantData->shoeSize = $booking->applicant->shoeSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches participant service selections with data from travel model.
|
||||
*
|
||||
|
||||
@@ -254,7 +254,11 @@ class BookingPayloadBuilder
|
||||
*/
|
||||
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
$firstParticipant = $bookingDto->participants[0];
|
||||
// The applicant is participant[0] on an ordinary booking, and a person of their own
|
||||
// when they do not travel along.
|
||||
$applicantSource = $bookingDto->applicantTravels
|
||||
? $bookingDto->participants[0]
|
||||
: ($bookingDto->applicant ?? $bookingDto->participants[0]);
|
||||
|
||||
$payload = [
|
||||
'buchungsart' => $bookingType,
|
||||
@@ -264,36 +268,10 @@ class BookingPayloadBuilder
|
||||
'idagentur' => $bookingDto->agencyId,
|
||||
];
|
||||
|
||||
// Add applicant (first participant data)
|
||||
$payload['anmelder'] = [
|
||||
'name' => $firstParticipant->lastName,
|
||||
'vorname' => $firstParticipant->firstName,
|
||||
'geschlecht' => $firstParticipant->gender ?? '',
|
||||
'nationalitaet' => $firstParticipant->nationality ?? '',
|
||||
];
|
||||
|
||||
// DO NOT include personId or addressId in create mode
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
|
||||
if (null !== $firstParticipant->dateOfBirth) {
|
||||
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Add address for applicant
|
||||
if (null !== $firstParticipant->address) {
|
||||
$payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload();
|
||||
}
|
||||
|
||||
if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) {
|
||||
$payload['anmelder']['kommunikation'] = [];
|
||||
if (null !== $firstParticipant->email) {
|
||||
$payload['anmelder']['kommunikation']['email'] = $firstParticipant->email;
|
||||
}
|
||||
if (null !== $firstParticipant->mobile) {
|
||||
$payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile;
|
||||
}
|
||||
}
|
||||
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
|
||||
|
||||
// Determine if this is an inquiry booking for voucher handling
|
||||
$isInquiryBooking = 'A' === $bookingDto->bookingStatus;
|
||||
@@ -301,42 +279,13 @@ class BookingPayloadBuilder
|
||||
// Add participants
|
||||
$payload['teilnehmerliste']['teilnehmer'] = [];
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => self::DEFAULT_PARTICIPANT_STATUS,
|
||||
'name' => $participant->lastName,
|
||||
'vorname' => $participant->firstName,
|
||||
'geschlecht' => $participant->gender ?? '',
|
||||
'nationalitaet' => $participant->nationality ?? '',
|
||||
];
|
||||
|
||||
// DO NOT include personId or addressId in create mode
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
|
||||
if (null !== $participant->dateOfBirth) {
|
||||
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Add address (always include structure, even if empty)
|
||||
$participantData['anschrift'] = $participant->address?->toPayload() ?? [
|
||||
'strasse' => null,
|
||||
'plz' => null,
|
||||
'ort' => null,
|
||||
'ortsteil' => null,
|
||||
'land' => null,
|
||||
];
|
||||
|
||||
// Add contact info for all participants
|
||||
if (null !== $participant->email || null !== $participant->mobile) {
|
||||
$participantData['kommunikation'] = [];
|
||||
if (null !== $participant->email) {
|
||||
$participantData['kommunikation']['email'] = $participant->email;
|
||||
}
|
||||
if (null !== $participant->mobile) {
|
||||
$participantData['kommunikation']['telefonmobil'] = $participant->mobile;
|
||||
}
|
||||
}
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => self::DEFAULT_PARTICIPANT_STATUS,
|
||||
] + $this->buildPersonalDataPayload($participant, $applicantSource->email, true);
|
||||
|
||||
// Add body dimensions
|
||||
if (null !== $participant->height) {
|
||||
@@ -434,6 +383,61 @@ class BookingPayloadBuilder
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
|
||||
*
|
||||
* @param ParticipantDto $person The applicant or participant to map
|
||||
* @param string|null $fallbackEmail Used when the person has none of their own. BusPro
|
||||
* requires a Mail-Adresse for every participant once
|
||||
* a room is present and rejects the Anfrage with 650
|
||||
* otherwise, but accepts one shared with the Anmelder
|
||||
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
|
||||
* even when empty; the anmelder only when set
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPersonalDataPayload(ParticipantDto $person, ?string $fallbackEmail = null, bool $alwaysIncludeAddress = false): array
|
||||
{
|
||||
$data = [
|
||||
'name' => $person->lastName,
|
||||
'vorname' => $person->firstName,
|
||||
'geschlecht' => $person->gender ?? '',
|
||||
'nationalitaet' => $person->nationality ?? '',
|
||||
];
|
||||
|
||||
if (null !== $person->dateOfBirth) {
|
||||
$data['geburtsdatum'] = $person->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
if (null !== $person->address) {
|
||||
$data['anschrift'] = $person->address->toPayload();
|
||||
} elseif ($alwaysIncludeAddress) {
|
||||
$data['anschrift'] = [
|
||||
'strasse' => null,
|
||||
'plz' => null,
|
||||
'ort' => null,
|
||||
'ortsteil' => null,
|
||||
'land' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$email = null !== $person->email && '' !== trim($person->email)
|
||||
? $person->email
|
||||
: $fallbackEmail;
|
||||
|
||||
if (null !== $email || null !== $person->mobile) {
|
||||
$data['kommunikation'] = [];
|
||||
if (null !== $email) {
|
||||
$data['kommunikation']['email'] = $email;
|
||||
}
|
||||
if (null !== $person->mobile) {
|
||||
$data['kommunikation']['telefonmobil'] = $person->mobile;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds services from a mapping to the payload.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\User;
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Exception\HotelNotInTravelException;
|
||||
use App\Exception\TravelNotFoundException;
|
||||
use App\Form\BookingApplicantTravelsType;
|
||||
use App\Form\BookingCreateStep2Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
@@ -22,10 +23,12 @@ use App\Service\RoomAssigner;
|
||||
use App\Service\TravelDataProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
|
||||
/**
|
||||
* Handles the second step of the booking creation process using card-based UI.
|
||||
@@ -80,14 +83,26 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
|
||||
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
|
||||
|
||||
// The account's own data belongs to whoever is the applicant: participant[0] normally,
|
||||
// and the separate applicant record when they do not travel along.
|
||||
$user = $this->getUser();
|
||||
if ($user instanceof User
|
||||
&& isset($bookingCreateDto->participants[0])
|
||||
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
||||
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->participants[0]
|
||||
);
|
||||
if ($user instanceof User) {
|
||||
if (false === $bookingCreateDto->applicantTravels) {
|
||||
$bookingCreateDto->applicant ??= new ParticipantDto();
|
||||
|
||||
if ($this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->applicant)) {
|
||||
$bookingCreateDto->applicant = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->applicant
|
||||
);
|
||||
}
|
||||
} elseif (isset($bookingCreateDto->participants[0])
|
||||
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
||||
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->participants[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate room assignments against current selection (handles back-navigation from step 2 to step 1)
|
||||
@@ -122,6 +137,10 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
$templateData = [
|
||||
'form' => $form->createView(),
|
||||
'bookingCreateContext' => $context,
|
||||
'applicantErrors' => $this->collectApplicantErrors($form),
|
||||
'applicantTravelsForm' => $this
|
||||
->createForm(BookingApplicantTravelsType::class, $bookingCreateDto)
|
||||
->createView(),
|
||||
];
|
||||
|
||||
return $this->render('booking/create/step_2.html.twig', $templateData);
|
||||
@@ -200,6 +219,42 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls out the violations that belong to the applicant block rather than a participant.
|
||||
*
|
||||
* The step-2 form carries no fields of its own, so every violation surfaces at the form root
|
||||
* and the page can only say "check the participants". An incomplete applicant would then be
|
||||
* an invisible blocker: nothing on the page is marked and the button simply does nothing.
|
||||
*
|
||||
* @param FormInterface<mixed> $form
|
||||
*
|
||||
* @return string[] Messages to render on the applicant card
|
||||
*/
|
||||
private function collectApplicantErrors(FormInterface $form): array
|
||||
{
|
||||
if (false === $form->isSubmitted()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
|
||||
foreach ($form->getErrors(true) as $error) {
|
||||
$cause = $error->getCause();
|
||||
|
||||
if (false === $cause instanceof ConstraintViolationInterface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === str_contains($cause->getPropertyPath(), 'applicant')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = $error->getMessage();
|
||||
}
|
||||
|
||||
return array_values(array_unique($messages));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a room selection that resolves to no participants at all.
|
||||
*
|
||||
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\Form\BookingApplicantTravelsType;
|
||||
use App\Form\BookingApplicantType;
|
||||
use App\Form\BookingParticipantType;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingConfigurator;
|
||||
@@ -130,6 +133,141 @@ class Step2ParticipantController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the booking-level "I am not travelling myself" choice.
|
||||
*
|
||||
* Toggling on moves participant[0]'s personal data into the applicant record and leaves
|
||||
* behind a blank traveller that keeps only its index and room assignment: the services were
|
||||
* derived from an age that no longer applies. Toggling off moves it back.
|
||||
*/
|
||||
#[Route(
|
||||
path: '/bookings/create/applicant-toggle',
|
||||
name: 'app_booking_create_step_2_applicant_toggle',
|
||||
methods: ['POST']
|
||||
)]
|
||||
public function toggleApplicantTravels(Request $request): Response
|
||||
{
|
||||
$result = $this->loadBookingDtoOrRedirect($request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
$bookingDto = $result;
|
||||
|
||||
// Internal agency bookings are pre-populated with a non-travelling applicant already,
|
||||
// so the choice is neither offered nor accepted for them.
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||
}
|
||||
|
||||
$this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel);
|
||||
|
||||
// Captured before handleRequest(), which binds the new choice straight onto the DTO.
|
||||
$previousChoice = $bookingDto->applicantTravels;
|
||||
|
||||
$form = $this->createForm(BookingApplicantTravelsType::class, $bookingDto);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||
}
|
||||
|
||||
$applicantTravels = $bookingDto->applicantTravels;
|
||||
|
||||
if ($applicantTravels !== $previousChoice) {
|
||||
$this->participantFormSupportService->ensureParticipantExists($bookingDto, 0);
|
||||
|
||||
if (false === $applicantTravels) {
|
||||
$bookingDto->applicant = $bookingDto->participants[0];
|
||||
$bookingDto->applicant->index = null;
|
||||
$bookingDto->participants[0] = $this->participantFormSupportService
|
||||
->createBlankTravellerFrom($bookingDto->applicant);
|
||||
} else {
|
||||
$restored = $bookingDto->applicant;
|
||||
if (null !== $restored) {
|
||||
$restored->index = 0;
|
||||
$restored->assignedRoomId = $bookingDto->participants[0]->assignedRoomId;
|
||||
$bookingDto->participants[0] = $restored;
|
||||
}
|
||||
$bookingDto->applicant = null;
|
||||
}
|
||||
|
||||
// Family insurance is not offered on a split booking, so a choice already made on
|
||||
// participant[0] has to go with it.
|
||||
if (false === $applicantTravels) {
|
||||
$this->participantFormSupportService->clearFamilyInsurance($bookingDto);
|
||||
}
|
||||
|
||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||
}
|
||||
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// The choice rebuilds the whole participant list, so navigate rather than swap a fragment.
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact data of an applicant who does not travel along.
|
||||
*
|
||||
* Guests only. A logged-in applicant's data comes from their account and is rendered as
|
||||
* static text on the step-2 card, because editing it here would create a duplicate BusPro
|
||||
* record - the same reason participant[0] is static when the applicant travels.
|
||||
*/
|
||||
#[Route(
|
||||
path: '/bookings/create/applicant',
|
||||
name: 'app_booking_create_applicant'
|
||||
)]
|
||||
public function editApplicant(Request $request): Response
|
||||
{
|
||||
$result = $this->loadBookingDtoOrRedirect($request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
$bookingDto = $result;
|
||||
|
||||
if (true === $bookingDto->applicantTravels || $bookingDto->isInternalAgencyBooking()) {
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
$bookingDto->applicant ??= new ParticipantDto();
|
||||
|
||||
$form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant);
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the
|
||||
// whole block with dummy data instead of saving what was typed.
|
||||
if ($form->isSubmitted()
|
||||
&& $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) {
|
||||
$this->prepopulationService->fillDummyApplicant($bookingDto->applicant);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_applicant');
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
// Keep an invalid submission in the session so the entered data survives a reload.
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$bookingCreateContext = $this->createContextFactory->create(
|
||||
$bookingDto,
|
||||
RoomPricingCalculator::PRICING_MODE_SELECTION
|
||||
);
|
||||
|
||||
return $this->render('booking/create/applicant.html.twig', [
|
||||
'form' => $form,
|
||||
'bookingCreateContext' => $bookingCreateContext,
|
||||
'bookingDto' => $bookingCreateContext->bookingDto,
|
||||
'summaryData' => $bookingCreateContext->summaryData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface<mixed> $form
|
||||
* @param array<array{type: string, message: string}> $notifications
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user