Compare commits

5 Commits
41 changed files with 2426 additions and 225 deletions

No files matched your search

+15 -6
View File
@@ -167,11 +167,20 @@ fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` recor
`telefonprivat` and no `nationalitaet`. `telefonprivat` and no `nationalitaet`.
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a **Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. The field list
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`. above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on the placeholder records and
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is is stricter than what BusPro applies to ordinary participants.
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
the placeholder records and is stricter than what BusPro applies to ordinary participants. **The participant `Mail-Adresse` is BusPro configuration, not a fixed rule.** Until 2026-10-13 every
`teilnehmer` needed one once a room was present, children included: without it the answer was
`650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`, while an email identical to the Anmelder's
was accepted. On 2026-10-15 the requirement was switched off on the test system, and the same requests
(no `email`, no `kommunikation`, an empty `<email/>`, adults and children, with and without a travelling
Anmelder) all pass with an unchanged price. A committed `Buchung` (status `A`) with an email-less child
goes through as well, both with a travelling Anmelder and with one who does not travel. BusPro now accepts a participant without an email at any age,
so the rule MyE&P applies (required from 16, and for the applicant at any age) is enforced only by
`ParticipantDto` and `BookingDto` validation. Production needs the same configuration before under-16s
without an email can be booked there.
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking **The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is for their children) returns `möglich` with the same price as the control where the Anmelder is
@@ -208,7 +217,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) | | `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below | | `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 | | `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()` | | `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
| `teilnehmerliste` | yes | yes | | | `teilnehmerliste` | yes | yes | |
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | | | `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
@@ -49,8 +49,9 @@ class BookingDataProcessor
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references * - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the * - For normal bookings, participant[0]'s missing address/dimensions are filled from the
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>) * applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not * - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
* the first participant, so copying applicant data into participant[0] would be incorrect * 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 public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
{ {
@@ -74,32 +75,6 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant); $participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index; $participantData->index = $index;
// For normal bookings, applicant === participant[0]: copy address and body dimensions
// from applicant when BPN returns full data only in <anmelder> and minimal data in
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
// a customer (a different entity from the first traveler).
if (0 === $index && false === $isInternalAgencyBooking) {
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
// Extract service selections from booking and assign to participant DTO // Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking $participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); ->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; return $dto;
} }
/**
* Fills gaps in participant[0] from the applicant, who is the same person.
*
* BPN routinely returns the full record in <anmelder> and only a minimal one in
* <teilnehmer id="1">. Only ever called where participant[0] IS the applicant: on a
* split booking or an internal agency booking this would write somebody else's address
* onto a traveller at the next edit submit.
*/
private function fillFirstParticipantFromApplicant(BookingDto $dto, Booking $booking): void
{
$participantData = $dto->getParticipant(0);
if (null === $participantData) {
return;
}
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
/** /**
* Enriches participant service selections with data from travel model. * Enriches participant service selections with data from travel model.
* *
@@ -254,7 +254,11 @@ class BookingPayloadBuilder
*/ */
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array 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 = [ $payload = [
'buchungsart' => $bookingType, 'buchungsart' => $bookingType,
@@ -264,36 +268,10 @@ class BookingPayloadBuilder
'idagentur' => $bookingDto->agencyId, '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 // DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address) // BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies // Including IDs would prevent automatic matching and could cause data inconsistencies
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
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;
}
}
// Determine if this is an inquiry booking for voucher handling // Determine if this is an inquiry booking for voucher handling
$isInquiryBooking = 'A' === $bookingDto->bookingStatus; $isInquiryBooking = 'A' === $bookingDto->bookingStatus;
@@ -301,42 +279,13 @@ class BookingPayloadBuilder
// Add participants // Add participants
$payload['teilnehmerliste']['teilnehmer'] = []; $payload['teilnehmerliste']['teilnehmer'] = [];
foreach ($bookingDto->participants as $index => $participant) { 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 // DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address) // BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies // Including IDs would prevent automatic matching and could cause data inconsistencies
$participantData = [
if (null !== $participant->dateOfBirth) { '@id' => $index + 1,
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); 'status' => self::DEFAULT_PARTICIPANT_STATUS,
} ] + $this->buildPersonalDataPayload($participant, true);
// 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;
}
}
// Add body dimensions // Add body dimensions
if (null !== $participant->height) { if (null !== $participant->height) {
@@ -434,6 +383,57 @@ class BookingPayloadBuilder
return $payload; 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 bool $alwaysIncludeAddress Participants always carry the anschrift structure
* even when empty; the anmelder only when set
*
* @return array<string, mixed>
*/
private function buildPersonalDataPayload(ParticipantDto $person, 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,
];
}
// A participant may come without an email (under-16s, see ParticipantDto::$email), and
// BusPro accepts that, so a blank is sent as a blank rather than borrowing the applicant's.
$email = null !== $person->email && '' !== trim($person->email) ? $person->email : null;
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. * Adds services from a mapping to the payload.
* *
+15
View File
@@ -20,6 +20,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Travel class Travel
{ {
use SortByPriceTrait; use SortByPriceTrait;
/**
* CRM selection that offers the "I am not travelling myself" choice on a travel date.
*/
private const NON_TRAVELLING_APPLICANT_SELECTION_ID = 1488;
#[Groups(['api:list', 'api:single', 'snapshot'])] #[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $id = null; public ?int $id = null;
@@ -240,6 +246,15 @@ class Travel
return false; return false;
} }
/**
* Whether a booking for this travel date may be placed by an applicant who does not travel
* along, such as a parent booking for their children.
*/
public function allowsNonTravellingApplicant(): bool
{
return $this->hasSelectionId(self::NON_TRAVELLING_APPLICANT_SELECTION_ID);
}
/** /**
* Retrieves rooms filtered by their IDs. * Retrieves rooms filtered by their IDs.
* *
@@ -9,6 +9,7 @@ use App\Entity\User;
use App\Exception\BookingSessionNotFoundException; use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException; use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException; use App\Exception\TravelNotFoundException;
use App\Form\BookingApplicantTravelsType;
use App\Form\BookingCreateStep2Type; use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -22,10 +23,12 @@ use App\Service\RoomAssigner;
use App\Service\TravelDataProvider; use App\Service\TravelDataProvider;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\ConstraintViolationInterface;
/** /**
* Handles the second step of the booking creation process using card-based UI. * Handles the second step of the booking creation process using card-based UI.
@@ -80,15 +83,33 @@ class Step2Controller extends AbstractBookingCreateController
$this->ensureCorrectNumberOfParticipants($bookingCreateDto); $this->ensureCorrectNumberOfParticipants($bookingCreateDto);
// A session can still hold a split for a travel date that does not offer the choice.
if (false === $bookingCreateDto->applicantTravels
&& false === $bookingCreateDto->travel->allowsNonTravellingApplicant()) {
$bookingCreateDto->restoreTravellingApplicant();
}
// 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(); $user = $this->getUser();
if ($user instanceof User if ($user instanceof User) {
&& isset($bookingCreateDto->participants[0]) 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])) { && $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser( $bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
$user, $user,
$bookingCreateDto->participants[0] $bookingCreateDto->participants[0]
); );
} }
}
// Validate room assignments against current selection (handles back-navigation from step 2 to step 1) // Validate room assignments against current selection (handles back-navigation from step 2 to step 1)
$this->roomAssignmentService->validateAndResetInvalidAssignments($bookingCreateDto); $this->roomAssignmentService->validateAndResetInvalidAssignments($bookingCreateDto);
@@ -122,6 +143,10 @@ class Step2Controller extends AbstractBookingCreateController
$templateData = [ $templateData = [
'form' => $form->createView(), 'form' => $form->createView(),
'bookingCreateContext' => $context, 'bookingCreateContext' => $context,
'applicantErrors' => $this->collectApplicantErrors($form),
'applicantTravelsForm' => $this
->createForm(BookingApplicantTravelsType::class, $bookingCreateDto)
->createView(),
]; ];
return $this->render('booking/create/step_2.html.twig', $templateData); return $this->render('booking/create/step_2.html.twig', $templateData);
@@ -200,6 +225,42 @@ class Step2Controller extends AbstractBookingCreateController
]; ];
} }
/**
* Pulls out the violations that belong to the applicant block rather than a participant.
*
* The step-2 form carries no fields of its own, so every violation surfaces at the form root
* and the page can only say "check the participants". An incomplete applicant would then be
* an invisible blocker: nothing on the page is marked and the button simply does nothing.
*
* @param FormInterface<mixed> $form
*
* @return string[] Messages to render on the applicant card
*/
private function collectApplicantErrors(FormInterface $form): array
{
if (false === $form->isSubmitted()) {
return [];
}
$messages = [];
foreach ($form->getErrors(true) as $error) {
$cause = $error->getCause();
if (false === $cause instanceof ConstraintViolationInterface) {
continue;
}
if (false === str_contains($cause->getPropertyPath(), 'applicant')) {
continue;
}
$messages[] = $error->getMessage();
}
return array_values(array_unique($messages));
}
/** /**
* Rejects a room selection that resolves to no participants at all. * Rejects a room selection that resolves to no participants at all.
* *
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create; namespace App\Controller\Booking\Create;
use App\Form\BookingApplicantTravelsType;
use App\Form\BookingApplicantType;
use App\Form\BookingParticipantType; use App\Form\BookingParticipantType;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\ApplicantInsuranceCascade; use App\Service\ApplicantInsuranceCascade;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
@@ -130,6 +133,135 @@ 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.
*
* Only offered where the travel date allows it; elsewhere the request changes nothing.
*/
#[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;
if (false === $bookingDto->travel->allowsNonTravellingApplicant()) {
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 {
$bookingDto->restoreTravellingApplicant();
}
// 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 || false === $bookingDto->travel->allowsNonTravellingApplicant()) {
return $this->redirectToRoute('app_booking_create_step_2');
}
$bookingDto->applicant ??= new ParticipantDto();
$form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant);
$form->handleRequest($request);
// Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the
// whole block with dummy data instead of saving what was typed.
if ($form->isSubmitted()
&& $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) {
$this->prepopulationService->fillDummyApplicant($bookingDto->applicant);
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
return $this->redirectToRoute('app_booking_create_applicant');
}
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
return $this->redirectToRoute('app_booking_create_step_2');
}
// Keep an invalid submission in the session so the entered data survives a reload.
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
$bookingCreateContext = $this->createContextFactory->create(
$bookingDto,
RoomPricingCalculator::PRICING_MODE_SELECTION
);
return $this->render('booking/create/applicant.html.twig', [
'form' => $form,
'bookingCreateContext' => $bookingCreateContext,
'bookingDto' => $bookingCreateContext->bookingDto,
'summaryData' => $bookingCreateContext->summaryData,
]);
}
/** /**
* @param FormInterface<mixed> $form * @param FormInterface<mixed> $form
* @param array<array{type: string, message: string}> $notifications * @param array<array{type: string, message: string}> $notifications
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\BookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The booking-level choice "do I travel along myself?".
*
* Rendered inside the step 2 form, so the template emits the widget alone and never a second
* <form> tag; htmx posts the enclosed inputs, the CSRF token among them, to the toggle route.
*
* Validation is switched off: this form only carries the choice, and running the step 2 groups
* here would report every gap in the participant data the moment somebody flips the radio.
*
* @extends AbstractType<BookingDto>
*/
class BookingApplicantTravelsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('applicantTravels', ChoiceType::class, [
'label' => 'Wer reist mit?',
'choices' => [
'Ich reise selbst mit' => true,
// Note the literal non-breaking space in "z. B." - it keeps the abbreviation
// from wrapping, and replacing it with a plain space is a silent regression.
'Ich buche für andere (z. B. mein Kind)' => false,
],
'expanded' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingDto::class,
'validation_groups' => false,
]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Contact data of an applicant who does not travel along.
*
* Bound to ParticipantDto rather than a contact DTO of its own: the fields are the same
* personal-data set, and a second near-identical type would only have to be kept in step with
* this one. Only shown to guests; a logged-in applicant's data comes from their account and is
* rendered as static text, because editing it here would create a duplicate BusPro record.
*
* Validation runs in the dedicated 'booking_applicant' group, which BookingDto::validateApplicant()
* owns, so the applicant is checked independently of the participant cards.
*
* @extends AbstractType<ParticipantDto>
*/
class BookingApplicantType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'label' => 'Vorname',
'required' => true,
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
'required' => true,
])
->add('gender', ChoiceType::class, [
'label' => 'Gender',
'required' => true,
'placeholder' => false,
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'required' => true,
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'required' => true,
'widget' => 'text',
'input' => 'datetime_immutable',
'html5' => false,
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => true,
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
],
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => true,
'sanitize_html' => true,
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
],
])
->add('address', AddressType::class, [
'label' => 'Adresse',
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ParticipantDto::class,
'validation_groups' => ['booking_applicant', 'booking_create'],
]);
}
}
+3 -2
View File
@@ -31,8 +31,9 @@ class BookingCreateStep2Type extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => BookingDto::class, 'data_class' => BookingDto::class,
'validation_groups' => function (FormInterface $form) { 'validation_groups' => function (FormInterface $form) {
// Create mode: always strict validation // Create mode: always strict validation. 'booking_applicant' additionally
return ['strict_required', 'booking_create']; // requires a complete applicant when they do not travel along.
return ['strict_required', 'booking_create', 'booking_applicant'];
}, },
]); ]);
} }
+167
View File
@@ -63,6 +63,26 @@ class BookingDto
*/ */
public ?Booking $booking = null; 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().
*/
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). * 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. * This property stores the original state and is never updated after initial load.
@@ -173,6 +193,105 @@ class BookingDto
return $this->participants[$index] ?? null; return $this->participants[$index] ?? null;
} }
/**
* Ends a split: the applicant takes the participant[0] slot back and travels again.
*
* The traveller who held the slot is dropped; only the room assignment carries over,
* the mirror image of what the split left behind.
*/
public function restoreTravellingApplicant(): void
{
$restored = $this->applicant;
if (null !== $restored) {
$restored->index = 0;
$restored->assignedRoomId = ($this->participants[0] ?? null)?->assignedRoomId;
$this->participants[0] = $restored;
}
$this->applicant = null;
$this->applicantTravels = true;
}
/**
* Whether the participant at $index has to supply an email address.
*
* Everyone does except an under-16 who is not the applicant; BusPro itself accepts a blank
* for any participant. Validation enforces this in two parts, the Assert\When on
* ParticipantDto::$email (age) and validateFirstParticipantApplicant() (the applicant), and
* this is the same rule in one place for the field state. A participant that does not exist
* yet counts as needing one, matching the "unknown counts as an adult" stance of isChild().
*/
public function requiresParticipantEmail(int $index): bool
{
$participant = $this->getParticipant($index);
if (null === $participant || false === $participant->isChild()) {
return true;
}
return 0 === $index && true === $this->isFirstParticipantApplicant();
}
/**
* The applicant's email address, wherever the applicant is held.
*
* - participant[0] where they are the applicant
* - create, split: the separate applicant block
* - edit, split: the loaded booking's anmelder, since $applicant is only filled in create mode
*/
public function getApplicantEmail(): ?string
{
if (true === $this->isFirstParticipantApplicant()) {
$email = $this->getParticipant(0)?->email;
} elseif (self::MODE_CREATE === $this->getMode()) {
$email = $this->applicant?->email;
} else {
$email = $this->booking?->applicant?->communication->email;
}
if (null === $email || '' === trim($email)) {
return null;
}
return trim($email);
}
/**
* 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.
*
* - create: the booking-level choice. The agency plays no part here: internal agency
* bookings are made in the BusPro backend and only ever edited in this application.
* - edit, internal agency: always false, their applicant is a customer who is not the
* first traveller
* - edit, otherwise: 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 (self::MODE_CREATE === $this->getMode()) {
return $this->applicantTravels;
}
if ($this->isInternalAgencyBooking()) {
return false;
}
$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. * Determines if this is a family booking based on participant age distribution.
* *
@@ -182,6 +301,13 @@ class BookingDto
*/ */
public function isFamilyBooking(): bool 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; $adults = 0;
$children = 0; $children = 0;
@@ -204,6 +330,47 @@ class BookingDto
return ($adults >= 1 && $adults <= 2) && ($children >= 1); 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 and an email from participant[0] only where they are the applicant.
*
* BusPro asks neither of an ordinary participant, so on a split booking the traveller at index 0
* is not asked for an address, and an under-16 there may leave the email blank.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateFirstParticipantApplicant(ExecutionContextInterface $context): void
{
if (false === $this->isFirstParticipantApplicant()) {
return;
}
$this->getParticipant(0)?->validateApplicantEmail($context, 'participants[0].');
$this->getParticipant(0)?->validateAddressFields($context, 'participants[0].');
}
#[Assert\Callback] #[Assert\Callback]
public function validateBankAccount(ExecutionContextInterface $context): void public function validateBankAccount(ExecutionContextInterface $context): void
{ {
+91 -36
View File
@@ -21,8 +21,9 @@ class ParticipantDto
/** /**
* Age below which a participant counts as a child. * 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 * Children may share an email address with an adult, and have the applicant's address
* their own; both rules go through isChild(), so this is the only place the age lives. * pre-filled for them; both rules go through isChild(), so this is the only place the
* age lives.
*/ */
public const CHILD_AGE_THRESHOLD = 16; public const CHILD_AGE_THRESHOLD = 16;
@@ -79,13 +80,16 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])] #[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
public ?\DateTimeImmutable $dateOfBirth = null; public ?\DateTimeImmutable $dateOfBirth = null;
// Requiredness is delegated to requiresOwnEmail() so the constraint and the field state // BusPro accepts a participant without an email address, so this is the only gate. Under-16s
// conditions cannot state the rule differently. // may leave it blank, everyone else needs one. An under-16 applicant needs one as well; that
// case depends on the booking and is enforced by BookingDto::validateFirstParticipantApplicant()
// and ParticipantEditDto::validateApplicant(). It stays a property constraint so the missing-field
// labels keep their order (see BookingEditPreFlightChecker).
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])] #[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
#[Assert\When( #[Assert\When(
expression: 'this.requiresOwnEmail()', expression: 'not this.isChild()',
constraints: [new Assert\NotBlank(message: 'Bitte angeben')], constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
groups: ['strict_required'] groups: ['strict_required'],
)] )]
public ?string $email = null; public ?string $email = null;
@@ -369,22 +373,6 @@ class ParticipantDto
* *
* @return bool True if participant is under the age threshold, false otherwise or if age unknown * @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 public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool
{ {
$age = $this->getAge(); $age = $this->getAge();
@@ -495,28 +483,95 @@ 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. * Runs on its own in the 'booking_applicant' group, which only the applicant form activates,
* The edit overview preflight checks address fields separately for the applicant only. * 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'])] #[Assert\Callback(groups: ['booking_applicant'])]
public function validateApplicantAddress(ExecutionContextInterface $context): void public function validateAsApplicant(ExecutionContextInterface $context): void
{ {
// Only validate applicant's address in regular create/edit submission flows. // The applicant record is the only ParticipantDto without an index. Travellers carry one,
if (0 !== $this->index) { // 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; 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, as the booking's contact, has an email address.
*
* The property constraint on $email already holds everyone aged 16 or older (and anyone
* without a date of birth) to it, so this only adds the under-16 contact and never reports
* the same field twice. Like validateAddressFields(), the callers decide who the contact is.
*/
public function validateApplicantEmail(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
if (false === $this->isChild()) {
return;
}
if (null !== $this->email && false === u($this->email)->trim()->isEmpty()) {
return;
}
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.'email')
->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 // Address object is required for the validated participant
if (null === $this->address) { if (null === $this->address) {
$context->buildViolation('Bitte angeben') $context->buildViolation('Bitte angeben')
->atPath('address') ->atPath($pathPrefix.'address')
->addViolation(); ->addViolation();
return; return;
@@ -525,25 +580,25 @@ class ParticipantDto
// Validate address subfields // Validate address subfields
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) { if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben') $context->buildViolation('Bitte angeben')
->atPath('address.street') ->atPath($pathPrefix.'address.street')
->addViolation(); ->addViolation();
} }
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) { if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben') $context->buildViolation('Bitte angeben')
->atPath('address.postCode') ->atPath($pathPrefix.'address.postCode')
->addViolation(); ->addViolation();
} }
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) { if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben') $context->buildViolation('Bitte angeben')
->atPath('address.city') ->atPath($pathPrefix.'address.city')
->addViolation(); ->addViolation();
} }
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) { if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben') $context->buildViolation('Bitte angeben')
->atPath('address.country') ->atPath($pathPrefix.'address.country')
->addViolation(); ->addViolation();
} }
} }
+43 -5
View File
@@ -92,12 +92,37 @@ class ParticipantEditDto
} }
} }
/**
* Requires a complete address and an email from the participant form's applicant only.
*
* Mirrors BookingDto::validateFirstParticipantApplicant() for the per-participant form, which
* binds this wrapper rather than the booking.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateApplicant(ExecutionContextInterface $context): void
{
if (false === $this->participant->isApplicant()) {
return;
}
if (false === $this->bookingContext->isFirstParticipantApplicant()) {
return;
}
$this->participant->validateApplicantEmail($context, 'participant.');
$this->participant->validateAddressFields($context, 'participant.');
}
/** /**
* Validates that non-applicant adult participants have unique email addresses. * Validates that non-applicant adult participants have unique email addresses.
* *
* Exempt from validation: * 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 * - Children (under 16): Can share email addresses with adults, or have none at all
*
* Where the applicant does not travel they are not among the participants, so their address
* is checked separately: an adult traveller may not reuse it, just as on an ordinary booking
* an adult may not reuse participant[0]'s.
* *
* Email comparison is case-insensitive and whitespace is normalized. * Email comparison is case-insensitive and whitespace is normalized.
*/ */
@@ -109,8 +134,9 @@ class ParticipantEditDto
return; return;
} }
// In regular create/edit submission flows, the applicant can share an email with dependents. // The applicant can share an email with dependents. On a split booking participant[0] is
if (true === $this->participant->isApplicant()) { // an ordinary traveller, so an adult there is held to uniqueness like everyone else.
if (true === $this->participant->isApplicant() && true === $this->bookingContext->isFirstParticipantApplicant()) {
return; return;
} }
@@ -119,7 +145,7 @@ class ParticipantEditDto
return; return;
} }
// Skip if email is null or empty (handled by @Email and @NotBlank constraints) // Nothing to compare. Whether a blank is allowed is the property constraint's business.
if (null === $this->participant->email || u($this->participant->email)->trim()->isEmpty()) { if (null === $this->participant->email || u($this->participant->email)->trim()->isEmpty()) {
return; return;
} }
@@ -157,5 +183,17 @@ class ParticipantEditDto
return; return;
} }
} }
if (true === $this->bookingContext->isFirstParticipantApplicant()) {
return;
}
$applicantEmail = $this->bookingContext->getApplicantEmail();
if (null !== $applicantEmail && $normalizedEmail === u($applicantEmail)->lower()->toString()) {
$context->buildViolation('Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet')
->atPath('participant.email')
->addViolation();
}
} }
} }
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition for "participant[0] is the booking's applicant".
*
* 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).
*
* 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 FirstParticipantIsApplicantCondition implements FieldConditionInterface
{
/**
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return 0 === $participantIndex && $bookingDto->isFirstParticipantApplicant();
}
/**
* @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] is the applicant';
}
}
@@ -10,15 +10,19 @@ use App\Form\Service\Contract\FieldConditionInterface;
/** /**
* Condition that determines if the first participant's personal data should be read-only. * 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, * It fires only where participant[0] IS the booking's applicant, which
* replacing the previous personId-based detection with agency code-based detection. * BookingDto::isFirstParticipantApplicant() decides: the booking-level choice in create mode,
* and anmelder vs. participant[0] person id in edit mode, where internal agency bookings
* always answer false.
* *
* Business Rules: * Business Rules:
* - CREATE mode (anonymous user): First participant data is editable (user must fill it in) * - 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, participant[0] is the applicant): data is prepopulated, read-only
* - CREATE mode (logged-in, internal agency): First participant ≠ applicant, data is editable * - CREATE mode (applicant does not travel): participant[0] is an ordinary traveller, data is
* - EDIT mode (internal agency): First participant ≠ applicant, data is editable (mutability applies separately) * editable
* - EDIT mode (non-internal agency): First participant = applicant, always read-only * - 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 * Non-first participants are never affected by this condition - they return false here
* but may still be read-only via PersonalDataMutabilityCondition in edit mode. * 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 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 // Non-first participants: return false here, but may still be read-only via PersonalDataMutabilityCondition
if (0 !== $participantIndex) { if (0 !== $participantIndex) {
return false; return false;
} }
$participant = $bookingDto->getParticipant($participantIndex); // participant[0] is a traveller in their own right: nothing to protect here.
if (false === $bookingDto->isFirstParticipantApplicant()) {
return false;
}
// CREATE mode logic
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Anonymous user (no personId) = must fill in data = editable // Anonymous user (no personId) = must fill in data = editable
if (null === $participant?->personId) { return null !== $bookingDto->getParticipant($participantIndex)?->personId;
return false;
} }
// Internal agency = first participant ≠ applicant = editable // EDIT mode: participant[0] is the applicant, whose record is owned by the account.
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()) {
return false;
}
// Non-internal agency = first participant = applicant = always read-only
return true; return true;
} }
/** /**
* Returns field names that this condition depends on. * Returns field names that this condition depends on.
* *
* This condition is based on agency code and personId which are set during initialization * This condition is based on the applicant split and personId, which are set during
* and don't change during form interaction, so no field dependencies. * initialization and don't change during form interaction, so no field dependencies.
* *
* @return string[] Empty array - no field dependencies * @return string[] Empty array - no field dependencies
*/ */
@@ -90,6 +80,6 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
*/ */
public function getDescription(): string 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';
} }
} }
@@ -8,12 +8,11 @@ use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface; use App\Form\Service\Contract\FieldConditionInterface;
/** /**
* Condition that mirrors ParticipantDto::requiresOwnEmail() into the field state layer. * Condition that mirrors BookingDto::requiresParticipantEmail() into the field state layer.
* *
* It deliberately holds no logic of its own. The rule for who needs an email address is * It deliberately holds no logic of its own, so the mandatory marker rendered next to the field
* enforced by the Assert\When on ParticipantDto::$email; this condition exists so the * is derived from the same method that describes what validation enforces, rather than from a
* mandatory marker rendered next to the field is derived from that same method rather than * second, independently maintained composition of age and applicant conditions.
* from a second, independently maintained composition of age and applicant conditions.
*/ */
class RequiresOwnEmailCondition implements FieldConditionInterface class RequiresOwnEmailCondition implements FieldConditionInterface
{ {
@@ -28,11 +27,7 @@ class RequiresOwnEmailCondition implements FieldConditionInterface
*/ */
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{ {
$participant = $bookingDto->getParticipant($participantIndex); return $bookingDto->requiresParticipantEmail($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();
} }
/** /**
@@ -16,6 +16,7 @@ use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\FinalBookingOnlyCondition; use App\Form\Service\Condition\FinalBookingOnlyCondition;
use App\Form\Service\Condition\FirstParticipantCondition; use App\Form\Service\Condition\FirstParticipantCondition;
use App\Form\Service\Condition\FirstParticipantIsApplicantCondition;
use App\Form\Service\Condition\FirstParticipantReadOnlyCondition; use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
use App\Form\Service\Condition\MultipleParticipantsCondition; use App\Form\Service\Condition\MultipleParticipantsCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition; use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
@@ -88,7 +89,7 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
); );
// First participant read-only condition // First participant read-only condition
// Render personal data fields as static text for first participant in non-internal agency bookings // Render personal data fields as static text for the first participant where they are the applicant
// when user is logged in (data is prepopulated from BPN account) // when user is logged in (data is prepopulated from BPN account)
$firstParticipantReadOnlyCondition = new FirstParticipantReadOnlyCondition(); $firstParticipantReadOnlyCondition = new FirstParticipantReadOnlyCondition();
@@ -117,10 +118,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'required' => new RequiresOwnEmailCondition(), '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'] = [ $this->fieldStateConditions['mobile'] = [
'static_text' => $firstParticipantReadOnlyCondition, 'static_text' => $firstParticipantReadOnlyCondition,
'required' => new FirstParticipantCondition(), 'required' => new FirstParticipantIsApplicantCondition(),
]; ];
// Address subfields - must render as static text to prevent creating duplicate BPN records // 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), 'hidden' => FieldValueCondition::equals('parking', false),
]; ];
// Make address field required for first participant (index 0) // Address is mandatory for the applicant only; BusPro accepts an empty anschrift from an
// Also render as static text for first participant when read-only // ordinary participant. Also rendered as static text for the applicant when read-only.
$this->fieldStateConditions['address'] = [ $this->fieldStateConditions['address'] = [
'static_text' => $firstParticipantReadOnlyCondition, 'static_text' => $firstParticipantReadOnlyCondition,
'required' => new FirstParticipantCondition(), 'required' => new FirstParticipantIsApplicantCondition(),
]; ];
// Room assignment field: // Room assignment field:
@@ -203,7 +203,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
// Add notification for unassigned participant // Add notification for unassigned participant
// For internal agency bookings, first participant is not the applicant // 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); $participantLabel = $isApplicant ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1);
$participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel)); $participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel));
@@ -677,7 +677,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'attr' => [ 'attr' => [
'data-description' => sprintf( 'data-description' => sprintf(
'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.', 'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
$bookingDto->isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'den/die Anmelder:in' $bookingDto->isFirstParticipantApplicant() ? 'den/die Anmelder:in' : 'Teilnehmer:in 1'
), ),
], ],
]; ];
+1 -1
View File
@@ -83,7 +83,7 @@ class BookingEditPreFlightChecker
} }
} }
if (false === $bookingDto->isInternalAgencyBooking() && true === $participant->isApplicant()) { if (true === $bookingDto->isFirstParticipantApplicant() && true === $participant->isApplicant()) {
foreach ($this->getMissingValueAddressLabels($participant) as $valueLabel) { foreach ($this->getMissingValueAddressLabels($participant) as $valueLabel) {
$valueLabels[] = $valueLabel; $valueLabels[] = $valueLabel;
} }
+18 -5
View File
@@ -33,8 +33,8 @@ class BookingNewsletterOptIn
$email = $currentUser->getEmail(); $email = $currentUser->getEmail();
} }
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) { if (null === $email || '' === trim((string) $email)) {
$email = $bookingDto->participants[0]->email; $email = $this->resolveApplicantParticipant($bookingDto)?->email;
} }
if (null === $email) { if (null === $email) {
@@ -46,9 +46,16 @@ class BookingNewsletterOptIn
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null; 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 public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
{ {
$participant = $bookingDto->participants[0] ?? null; $participant = $bookingDto->applicantTravels
? ($bookingDto->participants[0] ?? null)
: $bookingDto->applicant;
if (null === $participant) { if (null === $participant) {
return null; return null;
} }
@@ -63,7 +70,7 @@ class BookingNewsletterOptIn
/** /**
* Companions eligible for a third-party opt-in offer. * 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 * 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. * lookups here: whether the checkbox appears must not disclose who is already subscribed.
* *
@@ -75,7 +82,13 @@ class BookingNewsletterOptIn
$seenEmails = []; $seenEmails = [];
foreach ($bookingDto->participants as $participant) { 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; continue;
} }
+1 -1
View File
@@ -99,7 +99,7 @@ class ParticipantCardAssembler
if ('' === $name) { if ('' === $name) {
// For internal agency bookings, first participant is not the applicant // For internal agency bookings, first participant is not the applicant
if (0 === $index && false === $bookingDto->isInternalAgencyBooking()) { if (0 === $index && $bookingDto->isFirstParticipantApplicant()) {
return 'Anmelder:in'; return 'Anmelder:in';
} }
+28 -1
View File
@@ -132,6 +132,33 @@ class ParticipantDataPrefiller
return self::TOKEN === $participant->lastName; return self::TOKEN === $participant->lastName;
} }
/**
* Fills the applicant DTO with generated dummy contact data.
*
* Same `#KUN#` shortcut as fillDummyParticipant(), but applicant-shaped: the applicant has to
* be a complete adult, so gender and nationality are filled too and the date of birth is well
* clear of 18. Used for manual testing against the BusPro test system.
*/
public function fillDummyApplicant(ParticipantDto $applicant): void
{
$now = CarbonImmutable::now();
$applicant->firstName = 'Probeelternteil';
$applicant->lastName = sprintf('Elternteil %s', $now->format('H:i'));
$applicant->gender = 'W';
$applicant->nationality = 'D';
$applicant->mobile = '0171/222222';
$applicant->email = '[email protected]';
$applicant->dateOfBirth = $now->subYears(45)->toImmutable();
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
$applicant->address = $address;
}
/** /**
* Fills the participant DTO with generated dummy personal data. * Fills the participant DTO with generated dummy personal data.
*/ */
@@ -150,7 +177,7 @@ class ParticipantDataPrefiller
$address->street = 'Musterstr. 123'; $address->street = 'Musterstr. 123';
$address->postCode = '99999'; $address->postCode = '99999';
$address->city = 'MusterOrt'; $address->city = 'MusterOrt';
$address->country = 'DE'; $address->country = 'D';
$participant->address = $address; $participant->address = $address;
} }
} }
+31
View File
@@ -40,6 +40,37 @@ class ParticipantFormSupport
return $participant; 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 public function createParticipantEditDto(BookingDto $bookingDto, int $index): ParticipantEditDto
{ {
return new ParticipantEditDto( return new ParticipantEditDto(
@@ -161,7 +161,7 @@
{{ participantIndex + 1 }} {{ participantIndex + 1 }}
</div> </div>
<div class="text-2xl"> <div class="text-2xl">
{{ participantIndex == 0 and not bookingDto.isInternalAgencyBooking() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }} {{ participantIndex == 0 and bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
</div> </div>
</div> </div>
@@ -506,7 +506,7 @@
<div class="pb-4"> <div class="pb-4">
<fieldset class="border border-primary-bg"> <fieldset class="border border-primary-bg">
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase"> <legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic">– wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}</span> Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic">– wie {{ bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in 1' }}</span>
</legend> </legend>
{% if applicantInsurance %} {% if applicantInsurance %}
<table class="w-full table-fixed border-collapse"> <table class="w-full table-fixed border-collapse">
@@ -541,7 +541,7 @@
{% else %} {% else %}
<table class="w-full table-fixed border-collapse"> <table class="w-full table-fixed border-collapse">
<tr> <tr>
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie {{ bookingDto.isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'Anmelder:in' }}</td> <td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie {{ bookingDto.isFirstParticipantApplicant() ? 'Anmelder:in' : 'Teilnehmer:in 1' }}</td>
<td class="border border-primary-bg p-2 align-top w-24"></td> <td class="border border-primary-bg p-2 align-top w-24"></td>
<td class="border border-primary-bg p-2 align-top w-12"></td> <td class="border border-primary-bg p-2 align-top w-12"></td>
</tr> </tr>
@@ -0,0 +1,63 @@
{# Applicant block, shown only when the applicant does not travel along. #}
{% set isSubmitted = isSubmitted|default(false) %}
{% set applicantErrors = applicantErrors|default([]) %}
{% set isComplete = applicant is not null
and applicant.firstName is not empty
and applicant.lastName is not empty
and applicant.email is not empty
and applicant.mobile is not empty
and applicant.dateOfBirth is not null
and applicant.address is not null %}
{% set showAsError = applicantErrors is not empty or (not isComplete and isSubmitted) %}
<div class="mt-4 border-t border-gray-200 pt-4" id="applicant-card">
<div class="font-bold pb-2">Anmelder:in</div>
{% if applicantErrors is not empty %}
<ul class="mb-2 list-disc pl-5 text-sm text-red-500" {{ qa_attribute('applicant-errors') }}>
{% for message in applicantErrors %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% if is_granted('IS_AUTHENTICATED_FULLY') %}
{# Logged in: the data comes from the account. Editing it here would create a duplicate
BusPro record, which is the same reason participant[0] is static when they travel. #}
<div class="text-sm leading-5" {{ qa_attribute('applicant-static') }}>
{% if applicant is not null and applicant.firstName is not empty %}
<div>{{ applicant.firstName }} {{ applicant.lastName }}</div>
{% if applicant.address is not null %}
<div>{{ applicant.address.street }}</div>
<div>{{ applicant.address.postCode }} {{ applicant.address.city }}</div>
{% endif %}
<div>{{ applicant.email }}</div>
{% if applicant.mobile is not empty %}<div>{{ applicant.mobile }}</div>{% endif %}
{% else %}
<div class="italic text-gray-600">Deine Daten werden aus deinem Konto übernommen.</div>
{% endif %}
</div>
{% else %}
<div class="flex items-start justify-between gap-4">
<div class="{{ html_classes('text-sm leading-5', { 'italic': not isComplete }) }}"
{{ qa_attribute('applicant-summary') }}>
{% if isComplete %}
<div>{{ applicant.firstName }} {{ applicant.lastName }}</div>
<div>{{ applicant.address.postCode }} {{ applicant.address.city }}</div>
<div>{{ applicant.email }}</div>
{% elseif showAsError %}
<div class="font-medium text-red-500" {{ qa_attribute('applicant-invalid') }}>
unvollständige/fehlerhafte Angaben
</div>
{% else %}
<div class="text-gray-600">Bitte gib deine Kontaktdaten an.</div>
{% endif %}
</div>
<a href="{{ path('app_booking_create_applicant') }}"
class="button button--secondary shrink-0"
{{ qa_attribute('btn-edit-applicant') }}>
{{ isComplete ? 'Bearbeiten' : 'Angeben' }}
</a>
</div>
{% endif %}
</div>
@@ -0,0 +1,80 @@
{% extends 'layout_booking.html.twig' %}
{% block content %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate'
}
}) }}
{# Pagination - mobile only (above form) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Summary - hidden on mobile, visible on desktop #}
<div id="booking-summary"
class="hidden lg:block lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2">
{% include 'booking/_summary.html.twig' with {
'bookingDto': bookingCreateContext.bookingDto,
'summaryData': bookingCreateContext.summaryData
} %}
</div>
{# Form area - full width mobile, 3 cols desktop #}
<div class="flex-1 order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div id="main-content" class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
{# Flash messages - must be inside main-content for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
<h2 class="pb-2">Anmelder:in</h2>
<p class="pb-4 text-sm leading-5">
Du reist nicht selbst mit. Bitte gib deine Kontaktdaten an &ndash; wir
verwenden sie für die Buchungsbestätigung und für Rückfragen zur Reise.
</p>
{% if form.vars.submitted and not form.vars.valid %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe deine Angaben',
messages: ['Einige Angaben fehlen noch oder sind fehlerhaft.']
} %}
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.gender) }}
{{ form_row(form.dateOfBirth) }}
{{ form_row(form.nationality) }}
</div>
<div class="pt-4">
{{ form_row(form.address) }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
{{ form_row(form.email) }}
{{ form_row(form.mobile) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
<a href="{{ path('app_booking_create_step_2') }}"
class="button button--secondary">
Zurück
</a>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }}>
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+33
View File
@@ -36,13 +36,46 @@
</h2> </h2>
{# Display form-level validation errors #} {# Display form-level validation errors #}
{% set applicantErrors = applicantErrors|default([]) %}
{% if form.vars.submitted and not form.vars.valid %} {% if form.vars.submitted and not form.vars.valid %}
{% 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 { {% include '_partials/_alert.html.twig' with {
level: 'error', level: 'error',
title: 'Bitte überprüfe die Teilnehmerdaten', title: 'Bitte überprüfe die Teilnehmerdaten',
messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.'] messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
} %} } %}
{% endif %} {% endif %}
{% endif %}
{% set bookingDto = bookingCreateContext.bookingDto %}
{# The choice is only offered where the travel date allows it. #}
{% if bookingDto.travel.allowsNonTravellingApplicant %}
{# No form_start here: this sits inside the step 2 form and nested
form tags are invalid. htmx posts the inputs enclosed below,
including the CSRF token, to the toggle route. #}
<div class="mb-6 rounded border border-gray-200 bg-white p-4"
hx-post="{{ path('app_booking_create_step_2_applicant_toggle') }}"
hx-trigger="change"
hx-include="this"
{{ qa_attribute('applicant-travels') }}>
{{ form_row(applicantTravelsForm.applicantTravels) }}
{{ form_rest(applicantTravelsForm) }}
{% if not bookingDto.applicantTravels %}
{% include 'booking/create/_applicant_card.html.twig' with {
'applicant': bookingDto.applicant,
'isSubmitted': bookingCreateContext.isSubmitted,
'applicantErrors': applicantErrors
} %}
{% endif %}
</div>
{% endif %}
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{% for cardData in bookingCreateContext.cardsData %} {% for cardData in bookingCreateContext.cardsData %}
+39 -1
View File
@@ -116,6 +116,44 @@
</div> </div>
{% endif %} {% endif %}
{% if not bookingCreateContext.bookingDto.applicantTravels
and bookingCreateContext.bookingDto.applicant is not null %}
{% set applicant = bookingCreateContext.bookingDto.applicant %}
<h3 class="text-xl font-semibold mb-4">
Anmelder:in
</h3>
<div class="border border-primary-bg mb-4" {{ qa_attribute('summary-applicant') }}>
<div class="p-2 bg-primary-bg uppercase font-semibold">
{{ applicant.firstName }} {{ applicant.lastName }}
<span class="text-sm font-normal normal-case">(reist nicht mit)</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 p-2 text-sm">
{% if applicant.dateOfBirth %}
<div>
<span class="text-gray-600 font-semibold">Geburtsdatum:</span>
{{ applicant.dateOfBirth|date('d.m.Y') }}
</div>
{% endif %}
{% if applicant.address is not null %}
<div>
<span class="text-gray-600 font-semibold">Adresse:</span>
{{ applicant.address.street }}, {{ applicant.address.postCode }} {{ applicant.address.city }}
</div>
{% endif %}
<div>
<span class="text-gray-600 font-semibold">E-Mail:</span>
{{ applicant.email }}
</div>
{% if applicant.mobile %}
<div>
<span class="text-gray-600 font-semibold">Telefon (mobil):</span>
{{ applicant.mobile }}
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Participants Summary #} {# Participants Summary #}
<h3 class="text-xl font-semibold mb-4"> <h3 class="text-xl font-semibold mb-4">
Teilnehmer Teilnehmer
@@ -126,7 +164,7 @@
<div class="p-2 bg-primary-bg uppercase font-semibold flex justify-between items-center"> <div class="p-2 bg-primary-bg uppercase font-semibold flex justify-between items-center">
<span> <span>
{{ participant.firstName }} {{ participant.lastName }} {{ participant.firstName }} {{ participant.lastName }}
{% if loop.first and not bookingCreateContext.bookingDto.isInternalAgencyBooking() %}<span class="text-sm font-normal">(Anmelder:in)</span>{% endif %} {% if loop.first and bookingCreateContext.bookingDto.isFirstParticipantApplicant() %}<span class="text-sm font-normal">(Anmelder:in)</span>{% endif %}
</span> </span>
{% if bookingCreateContext.participantPrices is defined and bookingCreateContext.participantPrices[loop.index0] is defined %} {% if bookingCreateContext.participantPrices is defined and bookingCreateContext.participantPrices[loop.index0] is defined %}
<span>{{ bookingCreateContext.participantPrices[loop.index0]|format_currency('EUR') }}</span> <span>{{ bookingCreateContext.participantPrices[loop.index0]|format_currency('EUR') }}</span>
@@ -155,6 +155,79 @@ class BookingDataProcessorTest extends TestCase
$this->assertSame(194672, $dto->participants[0]->veg?->id); $this->assertSame(194672, $dto->participants[0]->veg?->id);
} }
public function testApplicantAddressIsCopiedOntoFirstParticipantOnAnOrdinaryBooking(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
$booking->applicant->personId = 4711;
$booking->participants[0]->personId = 4711;
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$booking->participants[0]->height = null;
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertSame(
'Elternweg 7',
$dto->participants[0]->address?->street,
'BPN returns the full record only in <anmelder>, so participant[0] is filled from it'
);
$this->assertSame('175', $dto->participants[0]->height);
}
public function testApplicantAddressIsNotCopiedOntoFirstParticipantOnASplitBooking(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
// The applicant is a different person: a parent who booked for their child.
$booking->applicant->personId = 4711;
$booking->participants[0]->personId = 4712;
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$booking->participants[0]->height = null;
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertNull(
$dto->participants[0]->address?->street,
"The parent's address must not be written onto the child at the next edit submit"
);
$this->assertNull(
$dto->participants[0]->height,
"The parent's body dimensions must not be written onto the child either"
);
}
public function testApplicantAddressIsCopiedWhenPersonIdsAreUnknown(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertSame(
'Elternweg 7',
$dto->participants[0]->address?->street,
'Incomplete BPN data falls back to the established, stricter behaviour'
);
}
private function createFilledAddress(string $street): Address
{
$address = new Address();
$address->street = $street;
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
return $address;
}
public function testTransportationServicesProcessing(): void public function testTransportationServicesProcessing(): void
{ {
$formData = $this->createFormDataWithTransportation(); $formData = $this->createFormDataWithTransportation();
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\DataProcessor;
use App\BusProNet\DataProcessor\BookingPayloadBuilder;
use App\BusProNet\DataProcessor\ServiceMappingCollector;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
/**
* Covers who the <anmelder> block is built from, and that a participant's blank email stays blank.
*/
class BookingPayloadBuilderTest extends TestCase
{
private BookingPayloadBuilder $builder;
protected function setUp(): void
{
$mappingCollector = $this->createStub(ServiceMappingCollector::class);
$mappingCollector->method('getPromoVoucherCodeForParticipant')->willReturn(null);
$this->builder = new BookingPayloadBuilder($mappingCollector);
}
public function testAnmelderComesFromFirstParticipantWhenTheApplicantTravels(): void
{
$bookingDto = $this->createBookingDto();
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame('Kind', $payload['anmelder']['vorname']);
$this->assertSame('Kind', $payload['teilnehmerliste']['teilnehmer'][0]['vorname']);
}
public function testAnmelderComesFromTheApplicantRecordWhenTheyDoNotTravel(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame('Elternteil', $payload['anmelder']['vorname']);
$this->assertSame('[email protected]', $payload['anmelder']['kommunikation']['email']);
$this->assertSame(
'Kind',
$payload['teilnehmerliste']['teilnehmer'][0]['vorname'],
'participant[0] stays an ordinary traveller'
);
}
public function testAnmelderStillCarriesNoIdsOnCreate(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$applicant = $this->createPerson('Elternteil', '[email protected]');
$applicant->personId = 4711;
$applicant->addressId = 1234;
$bookingDto->applicant = $applicant;
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertArrayNotHasKey('idadresseperson', $payload['anmelder']);
$this->assertArrayNotHasKey('idadresse', $payload['anmelder']);
}
public function testParticipantWithoutEmailIsSentWithoutOne(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$bookingDto->participants[0]->email = null;
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertArrayNotHasKey(
'email',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation'],
'BusPro accepts a participant without a Mail-Adresse; the applicant\'s must not be borrowed'
);
}
public function testParticipantKeepsTheirOwnEmail(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame(
'[email protected]',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation']['email']
);
}
public function testOrdinaryBookingPayloadIsUnchangedByTheApplicantSwitch(): void
{
$withApplicantTravelling = $this->builder->buildCreatePayload($this->createBookingDto(), 'BUCHUNG');
$bookingDto = $this->createBookingDto();
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$this->assertSame(
$withApplicantTravelling,
$this->builder->buildCreatePayload($bookingDto, 'BUCHUNG'),
'A stale applicant record must not leak into a booking where the applicant travels'
);
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 12500;
$travel->hotelId = 223055;
$bookingDto = new BookingDto($travel, 223055);
$bookingDto->agencyId = 77883;
$participant = $this->createPerson('Kind', '[email protected]');
$participant->index = 0;
$bookingDto->participants = [$participant];
return $bookingDto;
}
private function createPerson(string $firstName, string $email): ParticipantDto
{
$person = new ParticipantDto();
$person->firstName = $firstName;
$person->lastName = 'Probe';
$person->gender = 'W';
$person->nationality = 'D';
$person->dateOfBirth = new \DateTimeImmutable('2014-09-16');
$person->email = $email;
$person->mobile = '0171/222222';
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
$person->address = $address;
return $person;
}
}
+21
View File
@@ -134,6 +134,27 @@ class TravelTest extends TestCase
$this->assertFalse($travel->hasSelectionId(1473)); $this->assertFalse($travel->hasSelectionId(1473));
} }
public function testAllowsNonTravellingApplicantWithSelection1488(): void
{
$travel = new Travel();
$travel->selectionGroups = [
10 => $this->createSelectionGroup(10, [2001, 1488]),
];
$this->assertTrue($travel->allowsNonTravellingApplicant());
}
public function testDoesNotAllowNonTravellingApplicantWithoutSelection1488(): void
{
$travel = new Travel();
$travel->selectionGroups = [
10 => $this->createSelectionGroup(10, [1473, 1475]),
];
$this->assertFalse($travel->allowsNonTravellingApplicant());
$this->assertFalse((new Travel())->allowsNonTravellingApplicant(), 'A travel without any selections');
}
/** /**
* @param list<int> $selectionIds * @param list<int> $selectionIds
*/ */
@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\Tests\Controller\Booking\Create; namespace App\Tests\Controller\Booking\Create;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Model\Room; use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\Controller\Booking\Create\Step2Controller; use App\Controller\Booking\Create\Step2Controller;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto; use App\Form\Model\RoomSelectionDto;
use App\Model\RoomSelectionAdjustment; use App\Model\RoomSelectionAdjustment;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
@@ -181,6 +184,63 @@ class Step2ControllerTest extends TestCase
$this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants'); $this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants');
} }
public function testEndsASplitTheTravelDateDoesNotOffer(): void
{
$bookingDto = $this->createSplitBookingDto($this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]));
$applicant = $bookingDto->applicant;
$controller = $this->createControllerForFullRun($bookingDto);
$controller->index(Request::create('/bookings/create/participants', 'GET'));
$this->assertTrue($bookingDto->applicantTravels);
$this->assertNull($bookingDto->applicant);
$this->assertSame($applicant, $bookingDto->participants[0]);
}
public function testKeepsASplitTheTravelDateOffers(): void
{
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]);
$selection = new CrmSelection();
$selection->id = 1488;
$group = new CrmSelectionGroup();
$group->id = 10;
$group->selections = [$selection];
$travel->selectionGroups = [10 => $group];
$bookingDto = $this->createSplitBookingDto($travel);
$applicant = $bookingDto->applicant;
$controller = $this->createControllerForFullRun($bookingDto);
$controller->index(Request::create('/bookings/create/participants', 'GET'));
$this->assertFalse($bookingDto->applicantTravels);
$this->assertSame($applicant, $bookingDto->applicant);
$this->assertNotSame($applicant, $bookingDto->participants[0]);
}
private function createSplitBookingDto(Travel $travel): BookingDto
{
$bookingDto = $this->createBookingDto($travel);
$bookingDto->roomSelections = [
1409 => $this->createRoomSelection(1409, 'Einzelzimmer', quantity: 1, capacity: 1),
];
$bookingDto->applicantTravels = false;
$bookingDto->applicant = new ParticipantDto();
return $bookingDto;
}
private function createControllerForFullRun(BookingDto $bookingDto): TestableStep2Controller
{
$bookingService = $this->createStub(BookingConfigurator::class);
$bookingService->method('reconcileRoomSelections')->willReturn([]);
$bookingSessionService = $this->createStub(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
return $this->createController($bookingService, $bookingSessionService, $this->createStub(LoggerInterface::class));
}
private function createController( private function createController(
BookingConfigurator $bookingService, BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService, BookingSessionManager $bookingSessionService,
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Booking\Create;
use App\BusProNet\Model\Travel;
use App\Controller\Booking\Create\Step2ParticipantController;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\ApplicantInsuranceCascade;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\ParticipantDataPrefiller;
use App\Service\ParticipantFormSupport;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class Step2ParticipantControllerTest extends TestCase
{
public function testToggleChangesNothingWhereTheTravelDateDoesNotOfferTheChoice(): void
{
$request = Request::create('/bookings/create/applicant-toggle', 'POST');
$bookingDto = $this->createBookingDto();
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getBookingDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->never())->method('saveBookingDto');
$controller = $this->createController($bookingSessionService);
$response = $controller->toggleApplicantTravels($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_step_2', $response->getTargetUrl());
$this->assertSame(0, $controller->createdForms, 'The submitted choice is never bound');
$this->assertTrue($bookingDto->applicantTravels);
}
public function testApplicantFormIsNotServedWhereTheTravelDateDoesNotOfferTheChoice(): void
{
$request = Request::create('/bookings/create/applicant', 'GET');
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = new ParticipantDto();
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getBookingDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->never())->method('saveBookingDto');
$controller = $this->createController($bookingSessionService);
$response = $controller->editApplicant($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_step_2', $response->getTargetUrl());
$this->assertSame(0, $controller->createdForms);
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 4711;
$participant = new ParticipantDto();
$participant->index = 0;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
return $bookingDto;
}
private function createController(BookingSessionManager $bookingSessionService): TestableStep2ParticipantController
{
return new TestableStep2ParticipantController(
$this->createStub(BookingConfigurator::class),
$bookingSessionService,
$this->createStub(BookingCreateContextFactory::class),
$this->createStub(TravelDataProvider::class),
$this->createStub(ParticipantDataPrefiller::class),
$this->createStub(ParticipantFormSupport::class),
$this->createStub(ApplicantInsuranceCascade::class),
);
}
}
final class TestableStep2ParticipantController extends Step2ParticipantController
{
public int $createdForms = 0;
/**
* @return FormInterface<mixed>
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
++$this->createdForms;
throw new \LogicException('No form is expected to be built.');
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return '/'.$route;
}
protected function redirect(string $url, int $status = 302): RedirectResponse
{
return new RedirectResponse($url, $status);
}
/**
* @param array<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
return new RedirectResponse('/'.$route, $status);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\BusProNet\Model\Travel;
use App\Form\BookingApplicantTravelsType;
use App\Form\Model\BookingDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Forms;
class BookingApplicantTravelsTypeTest extends TestCase
{
public function testBooleanChoicesRenderAsRadioValues(): void
{
$form = $this->createForm($this->createBookingDto());
$values = [];
foreach ($form->get('applicantTravels')->createView()->vars['choices'] as $choice) {
$values[] = $choice->value;
}
// Symfony derives these from the booleans on its own, so the type needs no choice_value.
// Pinned because the whole toggle silently stops working if that ever changes.
$this->assertSame(['1', '0'], $values);
}
public function testSubmittingTheSplitChoiceFlipsTheFlag(): void
{
$bookingDto = $this->createBookingDto();
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '0']);
$this->assertTrue($form->isSynchronized());
$this->assertTrue($form->isValid());
$this->assertFalse($bookingDto->applicantTravels);
}
public function testSubmittingTheTravellingChoiceRestoresTheFlag(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '1']);
$this->assertTrue($form->isValid());
$this->assertTrue($bookingDto->applicantTravels);
}
public function testCurrentChoiceIsPreselected(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$view = $this->createForm($bookingDto)->get('applicantTravels')->createView();
$this->assertSame('0', $view->vars['value']);
}
public function testNoValidationRunsOnTheToggle(): void
{
$bookingDto = $this->createBookingDto();
// Deliberately incomplete: flipping the radio must not report participant gaps.
$bookingDto->participants = [];
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '0']);
$this->assertCount(0, $form->getErrors(true));
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-06-01');
return new BookingDto($travel, 1);
}
/** @return \Symfony\Component\Form\FormInterface<BookingDto> */
private function createForm(BookingDto $bookingDto): \Symfony\Component\Form\FormInterface
{
return Forms::createFormFactory()->create(BookingApplicantTravelsType::class, $bookingDto);
}
}
+421 -1
View File
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace App\Tests\Form\Model; namespace App\Tests\Form\Model;
use App\BusProNet\Model\Travel;
use App\BusProNet\Model\Address; 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\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
/** /**
* Tests for BookingDto validation and business logic. * Tests for BookingDto validation and business logic.
@@ -128,4 +132,420 @@ class BookingDtoTest extends TestCase
$this->assertSame('Test City', $restored->participants[0]->address->city); $this->assertSame('Test City', $restored->participants[0]->address->city);
$this->assertSame('DE', $restored->participants[0]->address->country); $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 testRestoringTheTravellingApplicantEndsTheSplit(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Elternteil';
$dto->applicant = $applicant;
$child = new ParticipantDto();
$child->index = 0;
$child->firstName = 'Kind';
$child->assignedRoomId = 1408;
$sibling = new ParticipantDto();
$sibling->index = 1;
$dto->participants = [$child, $sibling];
$dto->restoreTravellingApplicant();
$this->assertTrue($dto->applicantTravels);
$this->assertNull($dto->applicant);
$this->assertSame($applicant, $dto->participants[0]);
$this->assertSame(0, $applicant->index);
$this->assertSame(1408, $applicant->assignedRoomId, 'The slot keeps its room');
$this->assertSame($sibling, $dto->participants[1]);
}
public function testRestoringWithoutAnApplicantRecordKeepsTheFirstParticipant(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$traveller = new ParticipantDto();
$traveller->index = 0;
$dto->participants = [$traveller];
$dto->restoreTravellingApplicant();
$this->assertTrue($dto->applicantTravels);
$this->assertSame($traveller, $dto->participants[0]);
}
public function testInternalAgencyBookingNeverHasTheFirstParticipantAsApplicantInEditMode(): void
{
$dto = $this->createEditModeDto(100, 100);
$dto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE;
$this->assertFalse(
$dto->isFirstParticipantApplicant(),
'Their applicant is a customer who is not the first traveller, whatever the ids say'
);
}
public function testAgencyCodeDoesNotDecideAnythingInCreateMode(): void
{
$dto = $this->createCreateModeDto();
$dto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE;
$this->assertTrue(
$dto->isFirstParticipantApplicant(),
'Internal agency bookings are never created here, so the code is only passed on'
);
}
public function testEditModeDerivesTheSplitFromPersonIds(): void
{
$dto = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: 4711);
$this->assertTrue($dto->isFirstParticipantApplicant());
$split = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: 4712);
$this->assertFalse($split->isFirstParticipantApplicant());
}
public function testEditModeTreatsAnUnknownPersonIdAsTheSamePerson(): void
{
$dto = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: null);
$this->assertTrue(
$dto->isFirstParticipantApplicant(),
'Incomplete BPN data must fall back to the established, locked-down behaviour'
);
}
public function testFamilyBookingRequiresTheApplicantToTravel(): void
{
$dto = $this->createCreateModeDto();
$dto->participants = [
$this->createParticipantAged(40),
$this->createParticipantAged(12),
];
$this->assertTrue($dto->isFamilyBooking());
$dto->applicantTravels = false;
$this->assertFalse(
$dto->isFamilyBooking(),
'Family insurance is not offered when the applicant does not travel'
);
}
public function testAdultSiblingWithAChildIsNotAFamilyBookingOnceTheApplicantStaysHome(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants = [
$this->createParticipantAged(18),
$this->createParticipantAged(12),
];
$this->assertFalse(
$dto->isFamilyBooking(),
'The age distribution alone would still qualify, which is why the flag is checked first'
);
}
public function testOldSessionsWithoutTheApplicantFieldsRestoreToTheDefaults(): void
{
$travel = new Travel();
$travel->id = 42;
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$dto = new BookingDto($travel, 1);
$data = $dto->__serialize();
unset($data['applicantTravels'], $data['applicant']);
$restored = new BookingDto($travel, 1);
$restored->__unserialize($data);
$this->assertTrue($restored->applicantTravels);
$this->assertNull($restored->applicant);
}
private function createCreateModeDto(): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
$dto = new BookingDto($travel, 1);
$dto->participants = [$this->createParticipantAged(40)];
return $dto;
}
private function createEditModeDto(?int $applicantPersonId, ?int $firstParticipantPersonId): BookingDto
{
$dto = $this->createCreateModeDto();
$applicant = new PersonalData();
$applicant->personId = $applicantPersonId;
$booking = new Booking();
$booking->applicant = $applicant;
$dto->booking = $booking;
$dto->participants[0]->personId = $firstParticipantPersonId;
return $dto;
}
private function createParticipantAged(int $years): ParticipantDto
{
$participant = new ParticipantDto();
$participant->dateOfBirth = (new \DateTimeImmutable('2025-06-01'))->modify(sprintf('-%d years', $years));
return $participant;
}
public function testApplicantRulesDoNotLeakOntoTravellers(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants = [$this->createParticipantAged(15)];
$dto->participants[0]->index = 0;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->gender = 'W';
$applicant->nationality = 'D';
$applicant->email = '[email protected]';
$applicant->mobile = '0171/222222';
$applicant->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$applicant->address = $this->createAddress();
$dto->applicant = $applicant;
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_applicant']);
$paths = [];
foreach ($violations as $violation) {
$paths[] = $violation->getPropertyPath();
}
$this->assertSame(
[],
$paths,
'A 15-year-old traveller must not be held to the applicant rules through the group cascade'
);
}
public function testIncompleteApplicantIsReportedUnderTheApplicantPathOnlyOnce(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants[0]->index = 0;
$applicant = new ParticipantDto();
$applicant->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$dto->applicant = $applicant;
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_applicant']);
$paths = [];
foreach ($violations as $violation) {
$paths[] = $violation->getPropertyPath();
}
$this->assertContains('applicant.firstName', $paths);
$this->assertContains(
'applicant.address.street',
$paths,
'A fresh ParticipantDto carries an empty Address, so the gaps surface per subfield'
);
$this->assertSame(
array_unique($paths),
$paths,
'Explicit delegation plus a cascade would report each gap twice'
);
}
public function testMinorApplicantIsRejected(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants[0]->index = 0;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->gender = 'W';
$applicant->nationality = 'D';
$applicant->email = '[email protected]';
$applicant->mobile = '0171/222222';
$applicant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-17 years');
$applicant->address = $this->createAddress();
$dto->applicant = $applicant;
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_applicant']);
$messages = [];
foreach ($violations as $violation) {
$messages[] = $violation->getMessage();
}
$this->assertContains('Die anmeldende Person muss volljährig sein.', $messages);
}
public function testEditModeDoesNotAskASplitBookingsFirstParticipantForAnAddress(): void
{
$dto = $this->createEditModeDto(100, 200);
$dto->participants[0]->index = 0;
$dto->participants[0]->address = null;
$this->assertSame([], $this->collectFirstParticipantAddressPaths($dto));
}
public function testEditModeStillAsksTheApplicantForAnAddress(): void
{
$dto = $this->createEditModeDto(100, 100);
$dto->participants[0]->index = 0;
$dto->participants[0]->address = null;
$this->assertSame(['participants[0].address'], $this->collectFirstParticipantAddressPaths($dto));
}
public function testChildApplicantWithoutEmailIsReportedOnce(): void
{
$dto = $this->createCreateModeDto();
$dto->participants[0] = $this->createParticipantWithoutEmail(10);
$this->assertSame(
['participants[0].email'],
$this->collectFirstParticipantEmailPaths($dto),
'The applicant is the booking contact and needs an email at any age'
);
}
public function testChildAtIndexZeroOfASplitBookingMayLeaveTheEmailBlank(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants[0] = $this->createParticipantWithoutEmail(10);
$this->assertSame([], $this->collectFirstParticipantEmailPaths($dto));
}
public function testAdultAtIndexZeroOfASplitBookingStillNeedsAnEmail(): void
{
$dto = $this->createCreateModeDto();
$dto->applicantTravels = false;
$dto->participants[0] = $this->createParticipantWithoutEmail(30);
$this->assertSame(['participants[0].email'], $this->collectFirstParticipantEmailPaths($dto));
}
public function testApplicantEmailComesFromParticipantZeroWhenTheApplicantTravels(): void
{
$dto = $this->createCreateModeDto();
$dto->participants[0]->email = ' [email protected] ';
$dto->applicant = new ParticipantDto();
$dto->applicant->email = '[email protected]';
$this->assertSame('[email protected]', $dto->getApplicantEmail());
}
public function testApplicantEmailComesFromTheApplicantBlockOnACreateModeSplit(): void
{
$dto = $this->createCreateModeDto();
$dto->participants[0]->email = '[email protected]';
$dto->applicantTravels = false;
$dto->applicant = new ParticipantDto();
$dto->applicant->email = '[email protected]';
$this->assertSame('[email protected]', $dto->getApplicantEmail());
}
public function testApplicantEmailComesFromTheAnmelderOnAnEditModeSplit(): void
{
$dto = $this->createEditModeDto(100, 200);
$dto->participants[0]->email = '[email protected]';
$dto->booking->applicant->communication->email = '[email protected]';
$this->assertSame('[email protected]', $dto->getApplicantEmail());
}
/**
* Ages relative to today, since isChild() measures against the current date.
*/
private function createParticipantWithoutEmail(int $years): ParticipantDto
{
$participant = new ParticipantDto();
$participant->index = 0;
$participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify(sprintf('-%d years', $years));
$participant->address = $this->createAddress();
return $participant;
}
/**
* @return list<string>
*/
private function collectFirstParticipantEmailPaths(BookingDto $dto): array
{
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_create', 'strict_required']);
$paths = [];
foreach ($violations as $violation) {
if ('participants[0].email' === $violation->getPropertyPath()) {
$paths[] = $violation->getPropertyPath();
}
}
return $paths;
}
/**
* @return list<string>
*/
private function collectFirstParticipantAddressPaths(BookingDto $dto): array
{
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($dto, null, ['booking_edit', 'strict_required']);
$paths = [];
foreach ($violations as $violation) {
if (str_starts_with($violation->getPropertyPath(), 'participants[0].address')) {
$paths[] = $violation->getPropertyPath();
}
}
return $paths;
}
private function createAddress(): Address
{
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
return $address;
}
} }
+9 -21
View File
@@ -205,46 +205,34 @@ class ParticipantDtoTest extends TestCase
return $insurance; return $insurance;
} }
public function testRequiresOwnEmailIsFalseForNonApplicantChild(): void public function testIsChildIsTrueBelowTheAgeThreshold(): void
{ {
$participant = new ParticipantDto(); $participant = new ParticipantDto();
$participant->index = 1; $participant->index = 1;
$participant->dateOfBirth = new \DateTimeImmutable('2015-01-01'); $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 = new ParticipantDto();
$participant->index = 1; $participant->index = 1;
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->assertTrue($participant->requiresOwnEmail()); $this->assertFalse($participant->isChild());
} }
public function testRequiresOwnEmailIsTrueForTheApplicantEvenAsAChild(): void public function testIsChildTreatsUnknownAgeAsAdult(): 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
{ {
$participant = new ParticipantDto(); $participant = new ParticipantDto();
$participant->index = 1; $participant->index = 1;
$participant->dateOfBirth = null; $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 = new ParticipantDto();
$exactlyThreshold->index = 1; $exactlyThreshold->index = 1;
@@ -257,7 +245,7 @@ class ParticipantDtoTest extends TestCase
->modify(sprintf('-%d years', ParticipantDto::CHILD_AGE_THRESHOLD)) ->modify(sprintf('-%d years', ParticipantDto::CHILD_AGE_THRESHOLD))
->modify('+1 day'); ->modify('+1 day');
$this->assertTrue($exactlyThreshold->requiresOwnEmail()); $this->assertFalse($exactlyThreshold->isChild());
$this->assertFalse($oneDayShort->requiresOwnEmail()); $this->assertTrue($oneDayShort->isChild());
} }
} }
+142 -1
View File
@@ -8,6 +8,7 @@ use App\BusProNet\Constants;
use App\BusProNet\Model\Address; use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader; use App\BusProNet\XmlLoader\AgencyLoader;
@@ -703,7 +704,6 @@ class ParticipantEditDtoTest extends TestCase
$bookingDto = new BookingDto($travel, 1); $bookingDto = new BookingDto($travel, 1);
$bookingDto->booking = new Booking(); $bookingDto->booking = new Booking();
$bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE;
$participant0 = $this->createAdultParticipant('[email protected]'); $participant0 = $this->createAdultParticipant('[email protected]');
$participant0->index = 0; $participant0->index = 0;
@@ -740,6 +740,38 @@ class ParticipantEditDtoTest extends TestCase
$this->assertCount(0, $addressViolations1, 'Non-applicant address should not be required in edit submission'); $this->assertCount(0, $addressViolations1, 'Non-applicant address should not be required in edit submission');
} }
public function testEditSubmissionDoesNotAskASplitBookingsFirstParticipantForAnAddress(): void
{
$travel = $this->createTravelOfferingSkiPasses();
$applicant = new PersonalData();
$applicant->personId = 100;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->booking = new Booking();
$bookingDto->booking->applicant = $applicant;
$participant0 = $this->createAdultParticipant('[email protected]');
$participant0->index = 0;
$participant0->personId = 200;
$participant0->address = null;
$bookingDto->participants = [$participant0];
$wrapper0 = new ParticipantEditDto(
participant: $bookingDto->participants[0],
bookingContext: $bookingDto,
);
$violations0 = $this->validator->validate($wrapper0, null, ['booking_edit', 'strict_required']);
$addressViolations0 = array_filter(
iterator_to_array($violations0),
fn ($v) => 'address' === $v->getPropertyPath() || 'participant.address' === $v->getPropertyPath()
);
$this->assertCount(0, $addressViolations0, 'A traveller who is not the applicant owes no address');
}
public function testSkiPassAgeCalculatedAtTravelDate(): void public function testSkiPassAgeCalculatedAtTravelDate(): void
{ {
$travel = $this->createTravelOfferingSkiPasses(); $travel = $this->createTravelOfferingSkiPasses();
@@ -904,6 +936,22 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame([], $this->emailRequiredMessages($violations)); $this->assertSame([], $this->emailRequiredMessages($violations));
} }
public function testChildSharingTheApplicantsEmailPassesStrictValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame(
[],
$this->emailRequiredMessages($violations),
'A child may use the applicant\'s address instead of leaving it blank'
);
}
public function testChildWithMalformedEmailStillFailsEmailFormatValidation(): void public function testChildWithMalformedEmailStillFailsEmailFormatValidation(): void
{ {
$bookingDto = $this->createBookingDtoWithParticipants([ $bookingDto = $this->createBookingDtoWithParticipants([
@@ -969,6 +1017,99 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
} }
public function testChildAtIndexZeroOfASplitBookingMayLeaveTheEmailBlank(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createChildParticipant(''),
]);
$bookingDto->applicantTravels = false;
$violations = $this->validateStrict($bookingDto, 0);
$this->assertSame(
[],
$this->emailRequiredMessages($violations),
'Where the applicant does not travel, participant[0] is an ordinary traveller'
);
}
public function testAdultAtIndexZeroOfASplitBookingStillNeedsAnEmail(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant(''),
]);
$bookingDto->applicantTravels = false;
$violations = $this->validateStrict($bookingDto, 0);
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testAdultTravellerMayNotReuseTheNonTravellingApplicantsEmail(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
]);
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createValidParticipant(new \DateTimeImmutable('1980-01-01'), '[email protected]');
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(1, $violations);
$this->assertSame('participant.email', $violations->get(0)->getPropertyPath());
$this->assertSame(
'Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet',
$violations->get(0)->getMessage()
);
}
public function testChildTravellerMayReuseTheNonTravellingApplicantsEmail(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createChildParticipant('[email protected]'),
]);
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createValidParticipant(new \DateTimeImmutable('1980-01-01'), '[email protected]');
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(0, $violations);
}
public function testAdultTravellerMayNotReuseTheAnmelderEmailOfASplitBookingInEditMode(): void
{
$applicant = new PersonalData();
$applicant->personId = 100;
$applicant->communication->email = '[email protected]';
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
]);
$bookingDto->booking = new Booking();
$bookingDto->booking->applicant = $applicant;
$bookingDto->participants[0]->personId = 200;
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_edit']
);
$this->assertCount(1, $violations);
$this->assertSame(
'Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet',
$violations->get(0)->getMessage()
);
}
public function testDependentTurningExactlySixteenRequiresEmail(): void public function testDependentTurningExactlySixteenRequiresEmail(): void
{ {
$participant = $this->createAdultParticipant(''); $participant = $this->createAdultParticipant('');
@@ -102,6 +102,18 @@ class CreateFieldStateProviderTest extends TestCase
); );
} }
public function testEmailIsNotRequiredForAChildAtIndexZeroWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto->applicantTravels = false;
$this->assertFalse(
$this->provider->getFieldState('email', $bookingDto, 0)['required'],
'On a split booking participant[0] is an ordinary traveller'
);
}
public function testEmailIsRequiredWhenDateOfBirthIsUnknown(): void public function testEmailIsRequiredWhenDateOfBirthIsUnknown(): void
{ {
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01')); $bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
@@ -128,6 +140,47 @@ class CreateFieldStateProviderTest extends TestCase
); );
} }
public function testMobileIsRequiredForTheApplicantOnly(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('1990-01-01'));
$this->assertTrue($this->provider->getFieldState('mobile', $bookingDto, 0)['required']);
$this->assertFalse($this->provider->getFieldState('mobile', $bookingDto, 1)['required']);
}
public function testMobileAndAddressAreNotRequiredOfParticipantZeroWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->applicantTravels = false;
$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 testParticipantZeroIsEditableWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[0]->personId = 4711;
$this->assertTrue(
$this->provider->shouldRenderAsStaticText('firstName', $bookingDto, 0),
'A logged-in applicant who travels keeps their prefilled data locked'
);
$bookingDto->applicantTravels = false;
$this->assertFalse(
$this->provider->shouldRenderAsStaticText('firstName', $bookingDto, 0),
'Once the applicant does not travel, participant[0] is an ordinary traveller'
);
}
/** /**
* Guards the switch from add-only to toggling 'required' state: the two fields that already * Guards the switch from add-only to toggling 'required' state: the two fields that already
* used a 'required' condition must keep behaving exactly as before, now reporting false * used a 'required' condition must keep behaving exactly as before, now reporting false
@@ -172,6 +172,59 @@ class BookingNewsletterOptInTest extends TestCase
); );
} }
public function testApplicantIsTheSeparateRecordWhenTheyDoNotTravel(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 12]]);
$bookingDto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->email = '[email protected]';
$bookingDto->applicant = $applicant;
$service = $this->createService($this->createStub(NewsletterManager::class));
$this->assertSame('[email protected]', $service->resolveApplicantEmail($bookingDto, null));
$this->assertSame($applicant, $service->resolveApplicantParticipant($bookingDto));
$this->assertSame('Probe Elternteil', $service->resolveRequestedByName($bookingDto));
}
public function testAdultAtIndexZeroIsOfferedACompanionOptInOnASplitBooking(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 25]]);
$bookingDto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->email = '[email protected]';
$bookingDto->applicant = $applicant;
$service = $this->createService($this->createStub(NewsletterManager::class));
$companions = $service->resolveCompanions($bookingDto, '[email protected]');
$this->assertCount(
1,
$companions,
'participant[0] is an ordinary companion once the applicant stays home'
);
$this->assertSame('[email protected]', $companions[0]->email);
}
public function testChildCarryingTheApplicantAddressIsNotOfferedACompanionOptIn(): void
{
$bookingDto = $this->createBookingDto([
['[email protected]', 40],
['[email protected]', 12],
]);
$service = $this->createService($this->createStub(NewsletterManager::class));
$companions = $service->resolveCompanions($bookingDto, '[email protected]');
$this->assertSame([], $companions);
}
private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn
{ {
return new BookingNewsletterOptIn($newsletterManager, new NullLogger()); return new BookingNewsletterOptIn($newsletterManager, new NullLogger());
+27 -1
View File
@@ -300,7 +300,33 @@ class ParticipantDataPrefillerTest extends TestCase
$this->assertSame('[email protected]', $participant->email); $this->assertSame('[email protected]', $participant->email);
$this->assertSame('2006-01-15', $participant->dateOfBirth?->format('Y-m-d')); $this->assertSame('2006-01-15', $participant->dateOfBirth?->format('Y-m-d'));
$this->assertSame('Musterstr. 123', $participant->address?->street); $this->assertSame('Musterstr. 123', $participant->address?->street);
$this->assertSame('DE', $participant->address?->country); $this->assertSame(
'D',
$participant->address?->country,
"BusPro's country tokens are not ISO codes: 'DE' matches no choice and leaves the select empty"
);
CarbonImmutable::setTestNow();
}
public function testFillDummyApplicantSetsACompleteAdult(): void
{
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 15, 14, 30));
$applicant = new ParticipantDto();
$this->service()->fillDummyApplicant($applicant);
$this->assertSame('Probeelternteil', $applicant->firstName);
$this->assertSame('Elternteil 14:30', $applicant->lastName);
$this->assertSame('[email protected]', $applicant->email);
$this->assertSame('0171/222222', $applicant->mobile);
$this->assertSame('Probeweg 7', $applicant->address?->street);
// The applicant block requires these, unlike a participant.
$this->assertSame('W', $applicant->gender);
$this->assertSame('D', $applicant->nationality);
$this->assertSame('D', $applicant->address?->country);
$this->assertGreaterThanOrEqual(18, $applicant->getAge());
CarbonImmutable::setTestNow(); CarbonImmutable::setTestNow();
} }
+47 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service; namespace App\Tests\Service;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\Exception\ParticipantNotFoundException; use App\Exception\ParticipantNotFoundException;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
@@ -80,8 +81,53 @@ class ParticipantFormSupportTest extends TestCase
} }
/** /**
* array<string, int>|null $bodyDimensionRanges * array<string, int>|null $bodyDimensionRanges.
*/ */
public function testBlankTravellerKeepsTheRoomAssignmentAndDropsTheRest(): void
{
$previous = new ParticipantDto();
$previous->index = 0;
$previous->assignedRoomId = 4711;
$previous->firstName = 'Elternteil';
$previous->email = '[email protected]';
$previous->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$traveller = $this->createService()->createBlankTravellerFrom($previous);
$this->assertSame(0, $traveller->index);
$this->assertSame(4711, $traveller->assignedRoomId);
$this->assertNull($traveller->firstName);
$this->assertNull($traveller->email);
$this->assertNull(
$traveller->dateOfBirth,
'The services were derived from an age that no longer applies to this slot'
);
}
public function testClearFamilyInsuranceRemovesOnlyFamilyPolicies(): void
{
$bookingDto = $this->createBookingDto();
$familyInsurance = new Insurance();
$familyInsurance->familyInsurance = true;
$bookingDto->participants[0]->insurance = $familyInsurance;
$bookingDto->participants[0]->bulkInsuranceBooking = true;
$ordinaryInsurance = new Insurance();
$ordinaryInsurance->familyInsurance = false;
$bookingDto->participants[1]->insurance = $ordinaryInsurance;
$this->createService()->clearFamilyInsurance($bookingDto);
$this->assertNull($bookingDto->participants[0]->insurance);
$this->assertFalse($bookingDto->participants[0]->bulkInsuranceBooking);
$this->assertSame(
$ordinaryInsurance,
$bookingDto->participants[1]->insurance,
'Only the family policy goes; an ordinary one is still valid on a split booking'
);
}
private function createService(?array $bodyDimensionRanges = null): ParticipantFormSupport private function createService(?array $bodyDimensionRanges = null): ParticipantFormSupport
{ {
$bodyDimensionRanges ??= [ $bodyDimensionRanges ??= [