From ad6e42ab0c5851acf8f1844d11a05a2919f93a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 28 Sep 2026 12:07:22 +0200 Subject: [PATCH] feat: split booking between participants and applicant addresses #869f2q1vu --- config/services.yaml | 1 + docs/bpn-xml-api-reference.md | 2 +- .../DataProcessor/BookingDataProcessor.php | 75 ++++-- .../DataProcessor/BookingPayloadBuilder.php | 126 ++++----- .../Booking/Create/Step2Controller.php | 69 ++++- .../Create/Step2ParticipantController.php | 138 ++++++++++ src/Form/BookingApplicantTravelsType.php | 47 ++++ src/Form/BookingApplicantType.php | 93 +++++++ src/Form/BookingCreateStep2Type.php | 5 +- src/Form/BookingParticipantType.php | 4 +- src/Form/Model/BookingDto.php | 102 ++++++++ src/Form/Model/ParticipantDto.php | 105 +++++--- src/Form/Model/ParticipantEditDto.php | 27 +- .../FirstParticipantReadOnlyCondition.php | 54 ++-- ...ticipantSuppliesApplicantDataCondition.php | 50 ++++ .../Condition/RequiresOwnEmailCondition.php | 52 ---- src/Form/Service/CreateFieldStateProvider.php | 16 +- src/Form/Service/EditFieldStateProvider.php | 6 +- .../ParticipantAssignedRoomFieldHandler.php | 2 +- .../Service/ParticipantEmailFieldHandler.php | 102 ++++++++ .../ParticipantFieldHandlerRegistry.php | 3 + .../ParticipantFieldOptionsProvider.php | 2 +- src/Service/BookingEditPreFlightChecker.php | 2 +- src/Service/BookingNewsletterOptIn.php | 23 +- src/Service/ParticipantCardAssembler.php | 2 +- src/Service/ParticipantDataPrefiller.php | 29 ++- src/Service/ParticipantFormSupport.php | 31 +++ templates/booking/_participant_form.html.twig | 6 +- .../booking/create/_applicant_card.html.twig | 63 +++++ templates/booking/create/applicant.html.twig | 80 ++++++ templates/booking/create/step_2.html.twig | 42 ++- templates/booking/create/step_4.html.twig | 40 ++- .../BookingDataProcessorTest.php | 73 ++++++ .../BookingPayloadBuilderTest.php | 152 +++++++++++ .../Form/BookingApplicantTravelsTypeTest.php | 88 +++++++ tests/Form/Model/BookingDtoTest.php | 242 +++++++++++++++++- tests/Form/Model/ParticipantDtoTest.php | 30 +-- tests/Form/Model/ParticipantEditDtoTest.php | 32 ++- .../Service/CreateFieldStateProviderTest.php | 52 ++-- .../Service/EditFieldStateProviderTest.php | 7 +- .../ParticipantEmailFieldHandlerTest.php | 136 ++++++++++ tests/Service/BookingNewsletterOptInTest.php | 53 ++++ .../Service/ParticipantDataPrefillerTest.php | 28 +- tests/Service/ParticipantFormSupportTest.php | 48 +++- 44 files changed, 2024 insertions(+), 316 deletions(-) create mode 100644 src/Form/BookingApplicantTravelsType.php create mode 100644 src/Form/BookingApplicantType.php create mode 100644 src/Form/Service/Condition/FirstParticipantSuppliesApplicantDataCondition.php delete mode 100644 src/Form/Service/Condition/RequiresOwnEmailCondition.php create mode 100644 src/Form/Service/ParticipantEmailFieldHandler.php create mode 100644 templates/booking/create/_applicant_card.html.twig create mode 100644 templates/booking/create/applicant.html.twig create mode 100644 tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php create mode 100644 tests/Form/BookingApplicantTravelsTypeTest.php create mode 100644 tests/Form/Service/ParticipantEmailFieldHandlerTest.php diff --git a/config/services.yaml b/config/services.yaml index d00df647..cb49a4e1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/docs/bpn-xml-api-reference.md b/docs/bpn-xml-api-reference.md index cd5ed303..1b26a3e2 100644 --- a/docs/bpn-xml-api-reference.md +++ b/docs/bpn-xml-api-reference.md @@ -208,7 +208,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (``), 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 | | diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index e11504c3..ddb2f621 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -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 ) - * - 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 and minimal data in - // . 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 and only a minimal one in + * . 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. * diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php index ee664c0b..43107814 100644 --- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -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 + */ + 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. * diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index 3e6dc1bc..cb4465ea 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -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 $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. * diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php index 0e6ca551..1b5e3b87 100644 --- a/src/Controller/Booking/Create/Step2ParticipantController.php +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -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 $form * @param array $notifications diff --git a/src/Form/BookingApplicantTravelsType.php b/src/Form/BookingApplicantTravelsType.php new file mode 100644 index 00000000..714a8051 --- /dev/null +++ b/src/Form/BookingApplicantTravelsType.php @@ -0,0 +1,47 @@ + 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 + */ +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, + ]); + } +} diff --git a/src/Form/BookingApplicantType.php b/src/Form/BookingApplicantType.php new file mode 100644 index 00000000..f5f84a6d --- /dev/null +++ b/src/Form/BookingApplicantType.php @@ -0,0 +1,93 @@ + + */ +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'], + ]); + } +} diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index a4685e83..e8db1095 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -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']; }, ]); } diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 994953e4..17c7f034 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -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', diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 28e3f723..25de5ed2 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -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 { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index f0f83c54..0e845cb1 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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(); } } diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 23bdc4a3..e2f12134 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -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; } diff --git a/src/Form/Service/Condition/FirstParticipantReadOnlyCondition.php b/src/Form/Service/Condition/FirstParticipantReadOnlyCondition.php index d0273796..1709767e 100644 --- a/src/Form/Service/Condition/FirstParticipantReadOnlyCondition.php +++ b/src/Form/Service/Condition/FirstParticipantReadOnlyCondition.php @@ -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'; } } diff --git a/src/Form/Service/Condition/FirstParticipantSuppliesApplicantDataCondition.php b/src/Form/Service/Condition/FirstParticipantSuppliesApplicantDataCondition.php new file mode 100644 index 00000000..7f499632 --- /dev/null +++ b/src/Form/Service/Condition/FirstParticipantSuppliesApplicantDataCondition.php @@ -0,0 +1,50 @@ + 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 $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'; + } +} diff --git a/src/Form/Service/Condition/RequiresOwnEmailCondition.php b/src/Form/Service/Condition/RequiresOwnEmailCondition.php deleted file mode 100644 index 2f9ca218..00000000 --- a/src/Form/Service/Condition/RequiresOwnEmailCondition.php +++ /dev/null @@ -1,52 +0,0 @@ - $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'; - } -} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index f74b36eb..3668f940 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -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: diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 21c621c6..479d0a9a 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -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 diff --git a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php index f71ee16d..8d1f592b 100644 --- a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php +++ b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php @@ -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)); diff --git a/src/Form/Service/ParticipantEmailFieldHandler.php b/src/Form/Service/ParticipantEmailFieldHandler.php new file mode 100644 index 00000000..68052107 --- /dev/null +++ b/src/Form/Service/ParticipantEmailFieldHandler.php @@ -0,0 +1,102 @@ + $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); + } +} diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index 88a561fa..53f2a046 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -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', diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 12b6b95d..209557b1 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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' ), ], ]; diff --git a/src/Service/BookingEditPreFlightChecker.php b/src/Service/BookingEditPreFlightChecker.php index cf58d035..c16114dd 100644 --- a/src/Service/BookingEditPreFlightChecker.php +++ b/src/Service/BookingEditPreFlightChecker.php @@ -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; } diff --git a/src/Service/BookingNewsletterOptIn.php b/src/Service/BookingNewsletterOptIn.php index c2d4d850..de8b3b55 100644 --- a/src/Service/BookingNewsletterOptIn.php +++ b/src/Service/BookingNewsletterOptIn.php @@ -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; } diff --git a/src/Service/ParticipantCardAssembler.php b/src/Service/ParticipantCardAssembler.php index af6d49b1..3b7dee07 100644 --- a/src/Service/ParticipantCardAssembler.php +++ b/src/Service/ParticipantCardAssembler.php @@ -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'; } diff --git a/src/Service/ParticipantDataPrefiller.php b/src/Service/ParticipantDataPrefiller.php index 46d14d88..9f2bfdbc 100644 --- a/src/Service/ParticipantDataPrefiller.php +++ b/src/Service/ParticipantDataPrefiller.php @@ -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 = 'probeelternteil@example.com'; + $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; } } diff --git a/src/Service/ParticipantFormSupport.php b/src/Service/ParticipantFormSupport.php index 7c9b3058..e2b39f06 100644 --- a/src/Service/ParticipantFormSupport.php +++ b/src/Service/ParticipantFormSupport.php @@ -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( diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 3f8f989f..96d70c5b 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -161,7 +161,7 @@ {{ participantIndex + 1 }}
- {{ participantIndex == 0 and not bookingDto.isInternalAgencyBooking() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }} + {{ participantIndex == 0 and bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
@@ -506,7 +506,7 @@
- Reiseversicherung* – wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }} + Reiseversicherung* – wie {{ bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in 1' }} {% if applicantInsurance %} @@ -541,7 +541,7 @@ {% else %}
- + diff --git a/templates/booking/create/_applicant_card.html.twig b/templates/booking/create/_applicant_card.html.twig new file mode 100644 index 00000000..1abe15ee --- /dev/null +++ b/templates/booking/create/_applicant_card.html.twig @@ -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) %} + +
+
Anmelder:in
+ + {% if applicantErrors is not empty %} +
    + {% for message in applicantErrors %} +
  • {{ message }}
  • + {% endfor %} +
+ {% 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. #} +
+ {% if applicant is not null and applicant.firstName is not empty %} +
{{ applicant.firstName }} {{ applicant.lastName }}
+ {% if applicant.address is not null %} +
{{ applicant.address.street }}
+
{{ applicant.address.postCode }} {{ applicant.address.city }}
+ {% endif %} +
{{ applicant.email }}
+ {% if applicant.mobile is not empty %}
{{ applicant.mobile }}
{% endif %} + {% else %} +
Deine Daten werden aus deinem Konto übernommen.
+ {% endif %} +
+ {% else %} +
+
+ {% if isComplete %} +
{{ applicant.firstName }} {{ applicant.lastName }}
+
{{ applicant.address.postCode }} {{ applicant.address.city }}
+
{{ applicant.email }}
+ {% elseif showAsError %} +
+ unvollständige/fehlerhafte Angaben +
+ {% else %} +
Bitte gib deine Kontaktdaten an.
+ {% endif %} +
+ + {{ isComplete ? 'Bearbeiten' : 'Angeben' }} + +
+ {% endif %} +
diff --git a/templates/booking/create/applicant.html.twig b/templates/booking/create/applicant.html.twig new file mode 100644 index 00000000..3301591e --- /dev/null +++ b/templates/booking/create/applicant.html.twig @@ -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) #} +
+ {% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %} +
+
+ {# Summary - hidden on mobile, visible on desktop #} + + + {# Form area - full width mobile, 3 cols desktop #} +
+ {# Pagination - desktop only (fixed above scrollable content) #} + +
+ {# Flash messages - must be inside main-content for HTMX swap to display them #} + {% include '_partials/_flashes.html.twig' %} + +

Anmelder:in

+

+ 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. +

+ + {% 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 %} + +
+ {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} + {{ form_row(form.gender) }} + {{ form_row(form.dateOfBirth) }} + {{ form_row(form.nationality) }} +
+ +
+ {{ form_row(form.address) }} +
+ +
+ {{ form_row(form.email) }} + {{ form_row(form.mobile) }} +
+
+
+
+
+
+ + Zurück + + +
+
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig index d9920208..eb5dbe7b 100644 --- a/templates/booking/create/step_2.html.twig +++ b/templates/booking/create/step_2.html.twig @@ -36,12 +36,44 @@ {# 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. #} +
+ {{ 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 %} +
{% endif %}
diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index aeb66e5d..1b81f040 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -116,6 +116,44 @@
{% endif %} + {% if not bookingCreateContext.bookingDto.applicantTravels + and bookingCreateContext.bookingDto.applicant is not null %} + {% set applicant = bookingCreateContext.bookingDto.applicant %} +

+ Anmelder:in +

+
+
+ {{ applicant.firstName }} {{ applicant.lastName }} + (reist nicht mit) +
+
+ {% if applicant.dateOfBirth %} +
+ Geburtsdatum: + {{ applicant.dateOfBirth|date('d.m.Y') }} +
+ {% endif %} + {% if applicant.address is not null %} +
+ Adresse: + {{ applicant.address.street }}, {{ applicant.address.postCode }} {{ applicant.address.city }} +
+ {% endif %} +
+ E-Mail: + {{ applicant.email }} +
+ {% if applicant.mobile %} +
+ Telefon (mobil): + {{ applicant.mobile }} +
+ {% endif %} +
+
+ {% endif %} + {# Participants Summary #}

Teilnehmer @@ -126,7 +164,7 @@
{{ participant.firstName }} {{ participant.lastName }} - {% if loop.first and not bookingCreateContext.bookingDto.isInternalAgencyBooking() %}(Anmelder:in){% endif %} + {% if loop.first and bookingCreateContext.bookingDto.isFirstParticipantApplicant() %}(Anmelder:in){% endif %} {% if bookingCreateContext.participantPrices is defined and bookingCreateContext.participantPrices[loop.index0] is defined %} {{ bookingCreateContext.participantPrices[loop.index0]|format_currency('EUR') }} diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index 6b46bca7..636af79a 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -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 , 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(); diff --git a/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php b/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php new file mode 100644 index 00000000..cc0a0098 --- /dev/null +++ b/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php @@ -0,0 +1,152 @@ + 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', 'elternteil@example.com'); + + $payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG'); + + $this->assertSame('Elternteil', $payload['anmelder']['vorname']); + $this->assertSame('elternteil@example.com', $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', 'elternteil@example.com'); + $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', 'elternteil@example.com'); + $bookingDto->participants[0]->email = null; + + $payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG'); + + $this->assertSame( + 'elternteil@example.com', + $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', 'elternteil@example.com'); + + $payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG'); + + $this->assertSame( + 'kind@example.com', + $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', 'elternteil@example.com'); + + $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', 'kind@example.com'); + $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; + } +} diff --git a/tests/Form/BookingApplicantTravelsTypeTest.php b/tests/Form/BookingApplicantTravelsTypeTest.php new file mode 100644 index 00000000..91bd9865 --- /dev/null +++ b/tests/Form/BookingApplicantTravelsTypeTest.php @@ -0,0 +1,88 @@ +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 */ + private function createForm(BookingDto $bookingDto): \Symfony\Component\Form\FormInterface + { + return Forms::createFormFactory()->create(BookingApplicantTravelsType::class, $bookingDto); + } +} diff --git a/tests/Form/Model/BookingDtoTest.php b/tests/Form/Model/BookingDtoTest.php index 9eeb495a..92753ba1 100644 --- a/tests/Form/Model/BookingDtoTest.php +++ b/tests/Form/Model/BookingDtoTest.php @@ -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 = 'elternteil@example.com'; + $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 = 'elternteil@example.com'; + $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; + } } diff --git a/tests/Form/Model/ParticipantDtoTest.php b/tests/Form/Model/ParticipantDtoTest.php index e8b8ddcf..d26e4dc0 100644 --- a/tests/Form/Model/ParticipantDtoTest.php +++ b/tests/Form/Model/ParticipantDtoTest.php @@ -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()); } } diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index c2d27f75..2f04f7e8 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -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('applicant@example.com'), @@ -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('applicant@example.com'), + $this->createChildParticipant('applicant@example.com'), + ]); + + $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)); } /** diff --git a/tests/Form/Service/CreateFieldStateProviderTest.php b/tests/Form/Service/CreateFieldStateProviderTest.php index d26e3b9b..cb0c65b2 100644 --- a/tests/Form/Service/CreateFieldStateProviderTest.php +++ b/tests/Form/Service/CreateFieldStateProviderTest.php @@ -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' ); } diff --git a/tests/Form/Service/EditFieldStateProviderTest.php b/tests/Form/Service/EditFieldStateProviderTest.php index 3336c871..985ea66f 100644 --- a/tests/Form/Service/EditFieldStateProviderTest.php +++ b/tests/Form/Service/EditFieldStateProviderTest.php @@ -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 diff --git a/tests/Form/Service/ParticipantEmailFieldHandlerTest.php b/tests/Form/Service/ParticipantEmailFieldHandlerTest.php new file mode 100644 index 00000000..156deeac --- /dev/null +++ b/tests/Form/Service/ParticipantEmailFieldHandlerTest.php @@ -0,0 +1,136 @@ +handler = new ParticipantEmailFieldHandler(); + } + + public function testChildWithoutEmailInheritsTheApplicants(): void + { + $bookingDto = $this->createBookingDto(); + + $this->handler->processField(['email' => ''], $bookingDto, 1); + + $this->assertSame('applicant@example.com', $bookingDto->participants[1]->email); + } + + public function testChildKeepsAnEmailThatWasTypedIn(): void + { + $bookingDto = $this->createBookingDto(); + + $this->handler->processField(['email' => 'kind@example.com'], $bookingDto, 1); + + $this->assertSame( + 'kind@example.com', + $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('elternteil@example.com', '1981-03-16'); + $bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + $this->handler->processField(['email' => ''], $bookingDto, 0); + + $this->assertSame('elternteil@example.com', $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('applicant@example.com', $bookingDto->participants[1]->email); + $this->assertSame( + 'applicant@example.com', + $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('applicant@example.com', '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; + } +} diff --git a/tests/Service/BookingNewsletterOptInTest.php b/tests/Service/BookingNewsletterOptInTest.php index d562262d..b1d9e14b 100644 --- a/tests/Service/BookingNewsletterOptInTest.php +++ b/tests/Service/BookingNewsletterOptInTest.php @@ -172,6 +172,59 @@ class BookingNewsletterOptInTest extends TestCase ); } + public function testApplicantIsTheSeparateRecordWhenTheyDoNotTravel(): void + { + $bookingDto = $this->createBookingDto([['kind@example.com', 12]]); + $bookingDto->applicantTravels = false; + + $applicant = new ParticipantDto(); + $applicant->firstName = 'Probe'; + $applicant->lastName = 'Elternteil'; + $applicant->email = 'elternteil@example.com'; + $bookingDto->applicant = $applicant; + + $service = $this->createService($this->createStub(NewsletterManager::class)); + + $this->assertSame('elternteil@example.com', $service->resolveApplicantEmail($bookingDto, null)); + $this->assertSame($applicant, $service->resolveApplicantParticipant($bookingDto)); + $this->assertSame('Probe Elternteil', $service->resolveRequestedByName($bookingDto)); + } + + public function testAdultAtIndexZeroIsOfferedACompanionOptInOnASplitBooking(): void + { + $bookingDto = $this->createBookingDto([['geschwister@example.com', 25]]); + $bookingDto->applicantTravels = false; + + $applicant = new ParticipantDto(); + $applicant->firstName = 'Probe'; + $applicant->lastName = 'Elternteil'; + $applicant->email = 'elternteil@example.com'; + $bookingDto->applicant = $applicant; + + $service = $this->createService($this->createStub(NewsletterManager::class)); + $companions = $service->resolveCompanions($bookingDto, 'elternteil@example.com'); + + $this->assertCount( + 1, + $companions, + 'participant[0] is an ordinary companion once the applicant stays home' + ); + $this->assertSame('geschwister@example.com', $companions[0]->email); + } + + public function testChildCarryingTheApplicantAddressIsNotOfferedACompanionOptIn(): void + { + $bookingDto = $this->createBookingDto([ + ['elternteil@example.com', 40], + ['elternteil@example.com', 12], + ]); + + $service = $this->createService($this->createStub(NewsletterManager::class)); + $companions = $service->resolveCompanions($bookingDto, 'elternteil@example.com'); + + $this->assertSame([], $companions); + } + private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn { return new BookingNewsletterOptIn($newsletterManager, new NullLogger()); diff --git a/tests/Service/ParticipantDataPrefillerTest.php b/tests/Service/ParticipantDataPrefillerTest.php index d06d0f4c..7179e7be 100644 --- a/tests/Service/ParticipantDataPrefillerTest.php +++ b/tests/Service/ParticipantDataPrefillerTest.php @@ -300,7 +300,33 @@ class ParticipantDataPrefillerTest extends TestCase $this->assertSame('teilnehmer.in1@example.com', $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('probeelternteil@example.com', $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(); } diff --git a/tests/Service/ParticipantFormSupportTest.php b/tests/Service/ParticipantFormSupportTest.php index 85c6009b..071309cc 100644 --- a/tests/Service/ParticipantFormSupportTest.php +++ b/tests/Service/ParticipantFormSupportTest.php @@ -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|null $bodyDimensionRanges + * array|null $bodyDimensionRanges. */ + public function testBlankTravellerKeepsTheRoomAssignmentAndDropsTheRest(): void + { + $previous = new ParticipantDto(); + $previous->index = 0; + $previous->assignedRoomId = 4711; + $previous->firstName = 'Elternteil'; + $previous->email = 'elternteil@example.com'; + $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 ??= [

wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}wie {{ bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in 1' }}