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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 – 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ??= [
|
||||
|
||||
Reference in new issue
Block a user