feat: split booking between participants and applicant
addresses #869f2q1vu
This commit is contained in:
1 parent
2d49212b08
commit
1ff293f41f
44 files changed
+2024
-316
No files matched your search
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* The booking-level choice "do I travel along myself?".
|
||||
*
|
||||
* Rendered inside the step 2 form, so the template emits the widget alone and never a second
|
||||
* <form> tag; htmx posts the enclosed inputs, the CSRF token among them, to the toggle route.
|
||||
*
|
||||
* Validation is switched off: this form only carries the choice, and running the step 2 groups
|
||||
* here would report every gap in the participant data the moment somebody flips the radio.
|
||||
*
|
||||
* @extends AbstractType<BookingDto>
|
||||
*/
|
||||
class BookingApplicantTravelsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('applicantTravels', ChoiceType::class, [
|
||||
'label' => 'Wer reist mit?',
|
||||
'choices' => [
|
||||
'Ich reise selbst mit' => true,
|
||||
// Note the literal non-breaking space in "z. B." - it keeps the abbreviation
|
||||
// from wrapping, and replacing it with a plain space is a silent regression.
|
||||
'Ich buche für andere (z. B. mein Kind)' => false,
|
||||
],
|
||||
'expanded' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingDto::class,
|
||||
'validation_groups' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Contact data of an applicant who does not travel along.
|
||||
*
|
||||
* Bound to ParticipantDto rather than a contact DTO of its own: the fields are the same
|
||||
* personal-data set, and a second near-identical type would only have to be kept in step with
|
||||
* this one. Only shown to guests; a logged-in applicant's data comes from their account and is
|
||||
* rendered as static text, because editing it here would create a duplicate BusPro record.
|
||||
*
|
||||
* Validation runs in the dedicated 'booking_applicant' group, which BookingDto::validateApplicant()
|
||||
* owns, so the applicant is checked independently of the participant cards.
|
||||
*
|
||||
* @extends AbstractType<ParticipantDto>
|
||||
*/
|
||||
class BookingApplicantType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('firstName', TextType::class, [
|
||||
'label' => 'Vorname',
|
||||
'required' => true,
|
||||
])
|
||||
->add('lastName', TextType::class, [
|
||||
'label' => 'Nachname',
|
||||
'required' => true,
|
||||
])
|
||||
->add('gender', ChoiceType::class, [
|
||||
'label' => 'Gender',
|
||||
'required' => true,
|
||||
'placeholder' => false,
|
||||
'choices' => [
|
||||
'männlich' => 'M',
|
||||
'weiblich' => 'W',
|
||||
'divers' => 'D',
|
||||
],
|
||||
])
|
||||
->add('nationality', CountryType::class, [
|
||||
'label' => 'Nationalität',
|
||||
'required' => true,
|
||||
'preferred_choices' => ['D', 'A', 'CH'],
|
||||
])
|
||||
->add('dateOfBirth', BirthdayType::class, [
|
||||
'label' => 'Geburtsdatum',
|
||||
'required' => true,
|
||||
'widget' => 'text',
|
||||
'input' => 'datetime_immutable',
|
||||
'html5' => false,
|
||||
])
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'E-Mail',
|
||||
'required' => true,
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
],
|
||||
])
|
||||
->add('mobile', TextType::class, [
|
||||
'label' => 'Telefon (mobil)',
|
||||
'required' => true,
|
||||
'sanitize_html' => true,
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
],
|
||||
])
|
||||
->add('address', AddressType::class, [
|
||||
'label' => 'Adresse',
|
||||
'required' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ParticipantDto::class,
|
||||
'validation_groups' => ['booking_applicant', 'booking_create'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,9 @@ class BookingCreateStep2Type extends AbstractType
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingDto::class,
|
||||
'validation_groups' => function (FormInterface $form) {
|
||||
// Create mode: always strict validation
|
||||
return ['strict_required', 'booking_create'];
|
||||
// Create mode: always strict validation. 'booking_applicant' additionally
|
||||
// requires a complete applicant when they do not travel along.
|
||||
return ['strict_required', 'booking_create', 'booking_applicant'];
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,9 @@ class BookingParticipantType extends AbstractType
|
||||
if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) {
|
||||
$form->add('email', EmailType::class, $this->mergeFieldState([
|
||||
'label' => 'E-Mail',
|
||||
'required' => false,
|
||||
// Required for every participant (BPN 650); EditFieldStateProvider clears it
|
||||
// again for internal agency bookings, where staff may leave data blank.
|
||||
'required' => true,
|
||||
'property_path' => 'participant.email',
|
||||
'attr' => [
|
||||
'autocomplete' => 'leave-me-alone-chrome',
|
||||
|
||||
@@ -63,6 +63,26 @@ class BookingDto
|
||||
*/
|
||||
public ?Booking $booking = null;
|
||||
|
||||
/**
|
||||
* Whether the applicant travels along, i.e. is also participant[0].
|
||||
*
|
||||
* Create mode only: it is the booking-level choice behind "Ich reise selbst mit".
|
||||
* In edit mode the split is derived from the loaded booking instead, see
|
||||
* isFirstParticipantApplicant(). Internal agency bookings never flip this.
|
||||
*/
|
||||
public bool $applicantTravels = true;
|
||||
|
||||
/**
|
||||
* The applicant's own personal data, used only when they do not travel.
|
||||
*
|
||||
* Reuses ParticipantDto for the personal fields rather than introducing a second,
|
||||
* near-identical contact DTO. Its index stays null, which is what tells the shared
|
||||
* 'booking_applicant' rules apart from an ordinary traveller, and validateApplicant()
|
||||
* below drives its checks explicitly rather than by cascade, so the violations land
|
||||
* under 'applicant.' exactly once.
|
||||
*/
|
||||
public ?ParticipantDto $applicant = null;
|
||||
|
||||
/**
|
||||
* Fingerprint of the booking state when loaded from API (edit mode only).
|
||||
* This property stores the original state and is never updated after initial load.
|
||||
@@ -173,6 +193,39 @@ class BookingDto
|
||||
return $this->participants[$index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether participant[0] is also the applicant.
|
||||
*
|
||||
* This is the single predicate for "the first participant IS the booking's contact".
|
||||
* It replaces the former practice of reading isInternalAgencyBooking() to mean the
|
||||
* same thing, which only held because the internal agency was the one case where
|
||||
* applicant and participant[0] differed.
|
||||
*
|
||||
* - internal agency: always false, their applicant is a staff member and never travels
|
||||
* - create: the booking-level choice
|
||||
* - edit: derived from the loaded booking. A null person id on either side counts as
|
||||
* "same", so incomplete BPN data keeps today's stricter, locked-down behaviour.
|
||||
*/
|
||||
public function isFirstParticipantApplicant(): bool
|
||||
{
|
||||
if ($this->isInternalAgencyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::MODE_CREATE === $this->getMode()) {
|
||||
return $this->applicantTravels;
|
||||
}
|
||||
|
||||
$applicantPersonId = $this->booking?->applicant?->personId;
|
||||
$firstParticipantPersonId = $this->getParticipant(0)?->personId;
|
||||
|
||||
if (null === $applicantPersonId || null === $firstParticipantPersonId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $applicantPersonId === $firstParticipantPersonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this is a family booking based on participant age distribution.
|
||||
*
|
||||
@@ -182,6 +235,13 @@ class BookingDto
|
||||
*/
|
||||
public function isFamilyBooking(): bool
|
||||
{
|
||||
// Family insurance is not offered when the applicant does not travel. The age
|
||||
// distribution alone would not rule it out: an 18-year-old sibling travelling with a
|
||||
// younger child still satisfies the adult/child counts below.
|
||||
if (false === $this->applicantTravels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$adults = 0;
|
||||
$children = 0;
|
||||
|
||||
@@ -204,6 +264,48 @@ class BookingDto
|
||||
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a complete, adult applicant whenever the applicant does not travel.
|
||||
*
|
||||
* Delegates the field checks to ParticipantDto::validateApplicantCompleteness(), which the
|
||||
* applicant form runs directly through the 'booking_applicant' group.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['booking_applicant'])]
|
||||
public function validateApplicant(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (true === $this->applicantTravels) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->applicant) {
|
||||
$context->buildViolation('Bitte gib die Daten der anmeldenden Person an.')
|
||||
->atPath('applicant')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applicant->validateApplicantCompleteness($context, 'applicant.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a complete address from participant[0] only where they supply the anmelder block.
|
||||
*
|
||||
* BusPro does not require an anschrift from an ordinary participant, so on a split booking
|
||||
* the traveller at index 0 is not asked for one. Keyed on $applicantTravels rather than
|
||||
* isFirstParticipantApplicant() so internal agency bookings, whose payload still builds the
|
||||
* anmelder from participant[0], keep asking for it.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateFirstParticipantAddress(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (false === $this->applicantTravels) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->getParticipant(0)?->validateAddressFields($context, 'participants[0].');
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateBankAccount(ExecutionContextInterface $context): void
|
||||
{
|
||||
|
||||
@@ -21,8 +21,9 @@ class ParticipantDto
|
||||
/**
|
||||
* Age below which a participant counts as a child.
|
||||
*
|
||||
* Children may share an email address with an adult and are not required to have one of
|
||||
* their own; both rules go through isChild(), so this is the only place the age lives.
|
||||
* Children may share an email address with an adult, and have the applicant's address
|
||||
* pre-filled for them; both rules go through isChild(), so this is the only place the
|
||||
* age lives.
|
||||
*/
|
||||
public const CHILD_AGE_THRESHOLD = 16;
|
||||
|
||||
@@ -79,14 +80,11 @@ class ParticipantDto
|
||||
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||
|
||||
// Requiredness is delegated to requiresOwnEmail() so the constraint and the field state
|
||||
// conditions cannot state the rule differently.
|
||||
// Every participant needs an email address: BusPro rejects the step-3 Anfrage with 650
|
||||
// otherwise, children included. Under-16s have the applicant's address pre-filled by
|
||||
// ParticipantEmailFieldHandler and may override it, but may not leave it blank.
|
||||
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
|
||||
#[Assert\When(
|
||||
expression: 'this.requiresOwnEmail()',
|
||||
constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
|
||||
groups: ['strict_required']
|
||||
)]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||
public ?string $email = null;
|
||||
|
||||
public ?string $mobile = null;
|
||||
@@ -369,22 +367,6 @@ class ParticipantDto
|
||||
*
|
||||
* @return bool True if participant is under the age threshold, false otherwise or if age unknown
|
||||
*/
|
||||
/**
|
||||
* Decides whether this participant has to supply an email address of their own.
|
||||
*
|
||||
* The applicant is the booking's contact and always has to be reachable. Everyone else only
|
||||
* needs an address once they are old enough to have one, so children are exempt. An unknown
|
||||
* date of birth counts as an adult, following isChild().
|
||||
*
|
||||
* Single source for the rule: the Assert\When on $email enforces it, and the 'required'
|
||||
* field state conditions in CreateFieldStateProvider/EditFieldStateProvider render the
|
||||
* matching mandatory marker.
|
||||
*/
|
||||
public function requiresOwnEmail(): bool
|
||||
{
|
||||
return $this->isApplicant() || false === $this->isChild();
|
||||
}
|
||||
|
||||
public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool
|
||||
{
|
||||
$age = $this->getAge();
|
||||
@@ -495,28 +477,73 @@ class ParticipantDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the participant has a complete address when required.
|
||||
* Validates that this person is usable as the booking's applicant.
|
||||
*
|
||||
* In regular create/edit submission flows this only applies to participant 0.
|
||||
* The edit overview preflight checks address fields separately for the applicant only.
|
||||
* Runs on its own in the 'booking_applicant' group, which only the applicant form activates,
|
||||
* the way 'applicant_address' already scopes the address NotNull above. BookingDto delegates
|
||||
* to the same body for the step-2 submit, so the rule has one home.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateApplicantAddress(ExecutionContextInterface $context): void
|
||||
#[Assert\Callback(groups: ['booking_applicant'])]
|
||||
public function validateAsApplicant(ExecutionContextInterface $context): void
|
||||
{
|
||||
// Only validate applicant's address in regular create/edit submission flows.
|
||||
if (0 !== $this->index) {
|
||||
// The applicant record is the only ParticipantDto without an index. Travellers carry one,
|
||||
// and BookingDto cascades the 'booking_applicant' group into $participants as well, so
|
||||
// without this guard every traveller would be held to the applicant's rules - an adult
|
||||
// address and a minimum age of 18.
|
||||
if (null !== $this->index) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateAddressFields($context);
|
||||
$this->validateApplicantCompleteness($context);
|
||||
}
|
||||
|
||||
private function validateAddressFields(ExecutionContextInterface $context): void
|
||||
/**
|
||||
* Shared body. $pathPrefix places the violations in the caller's object graph; it is not a
|
||||
* callback itself, because Symfony passes the constraint payload as the second argument.
|
||||
*/
|
||||
public function validateApplicantCompleteness(ExecutionContextInterface $context, string $pathPrefix = ''): void
|
||||
{
|
||||
foreach (['firstName', 'lastName', 'gender', 'nationality', 'email', 'mobile'] as $field) {
|
||||
$value = $this->$field;
|
||||
if (null === $value || '' === trim($value)) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath($pathPrefix.$field)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
$this->validateAddressFields($context, $pathPrefix);
|
||||
|
||||
if (null === $this->dateOfBirth) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath($pathPrefix.'dateOfBirth')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$age = $this->getAge();
|
||||
if (null !== $age && 18 > $age) {
|
||||
$context->buildViolation('Die anmeldende Person muss volljährig sein.')
|
||||
->atPath($pathPrefix.'dateOfBirth')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that this person has a complete address.
|
||||
*
|
||||
* Only the booking's contact needs one: BusPro does not require an anschrift from an
|
||||
* ordinary participant (measured on 2036, an empty anschrift passes). Deciding who the
|
||||
* contact is needs the booking, so the callers own the gate and this method only ever
|
||||
* runs the field checks. $pathPrefix places the violations in the caller's object graph.
|
||||
*/
|
||||
public function validateAddressFields(ExecutionContextInterface $context, string $pathPrefix = ''): void
|
||||
{
|
||||
// Address object is required for the validated participant
|
||||
if (null === $this->address) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address')
|
||||
->atPath($pathPrefix.'address')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
@@ -525,25 +552,25 @@ class ParticipantDto
|
||||
// Validate address subfields
|
||||
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.street')
|
||||
->atPath($pathPrefix.'address.street')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.postCode')
|
||||
->atPath($pathPrefix.'address.postCode')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.city')
|
||||
->atPath($pathPrefix.'address.city')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
|
||||
$context->buildViolation('Bitte angeben')
|
||||
->atPath('address.country')
|
||||
->atPath($pathPrefix.'address.country')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +92,31 @@ class ParticipantEditDto
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a complete address from the participant form's applicant only.
|
||||
*
|
||||
* Mirrors BookingDto::validateFirstParticipantAddress() for the per-participant form, which
|
||||
* binds this wrapper rather than the booking.
|
||||
*/
|
||||
#[Assert\Callback(groups: ['strict_required'])]
|
||||
public function validateApplicantAddress(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (false === $this->participant->isApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === $this->bookingContext->applicantTravels) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->participant->validateAddressFields($context, 'participant.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that non-applicant adult participants have unique email addresses.
|
||||
*
|
||||
* Exempt from validation:
|
||||
* - Applicant (index 0): Can share email with dependents they're booking for
|
||||
* - The applicant: can share an email with dependents they're booking for
|
||||
* - Children (under 16): Can share email addresses with adults
|
||||
*
|
||||
* Email comparison is case-insensitive and whitespace is normalized.
|
||||
@@ -109,8 +129,9 @@ class ParticipantEditDto
|
||||
return;
|
||||
}
|
||||
|
||||
// In regular create/edit submission flows, the applicant can share an email with dependents.
|
||||
if (true === $this->participant->isApplicant()) {
|
||||
// The applicant can share an email with dependents. On a split booking participant[0] is
|
||||
// an ordinary traveller, so an adult there is held to uniqueness like everyone else.
|
||||
if (true === $this->participant->isApplicant() && true === $this->bookingContext->isFirstParticipantApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,19 @@ use App\Form\Service\Contract\FieldConditionInterface;
|
||||
/**
|
||||
* Condition that determines if the first participant's personal data should be read-only.
|
||||
*
|
||||
* This condition evaluates read-only state based on agency type and authentication state,
|
||||
* replacing the previous personId-based detection with agency code-based detection.
|
||||
* It fires only where participant[0] IS the booking's applicant, which
|
||||
* BookingDto::isFirstParticipantApplicant() decides: the booking-level choice in create mode,
|
||||
* and anmelder vs. participant[0] person id in edit mode. Internal agency bookings answer
|
||||
* false there, which preserves the behaviour this class used to spell out itself.
|
||||
*
|
||||
* Business Rules:
|
||||
* - CREATE mode (anonymous user): First participant data is editable (user must fill it in)
|
||||
* - CREATE mode (logged-in, non-internal agency): First participant = applicant, data is read-only
|
||||
* - CREATE mode (logged-in, internal agency): First participant ≠ applicant, data is editable
|
||||
* - EDIT mode (internal agency): First participant ≠ applicant, data is editable (mutability applies separately)
|
||||
* - EDIT mode (non-internal agency): First participant = applicant, always read-only
|
||||
* - CREATE mode (logged-in, participant[0] is the applicant): data is prepopulated, read-only
|
||||
* - CREATE mode (applicant does not travel, or internal agency): participant[0] is an ordinary
|
||||
* traveller, data is editable
|
||||
* - EDIT mode (participant[0] is the applicant): always read-only
|
||||
* - EDIT mode (split booking, or internal agency): participant[0] ≠ applicant, editable here
|
||||
* (PersonalDataMutabilityCondition still applies separately)
|
||||
*
|
||||
* Non-first participants are never affected by this condition - they return false here
|
||||
* but may still be read-only via PersonalDataMutabilityCondition in edit mode.
|
||||
@@ -36,45 +40,31 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
// This condition only applies to first participant (agency-based read-only)
|
||||
// This condition only applies to the first participant.
|
||||
// Non-first participants: return false here, but may still be read-only via PersonalDataMutabilityCondition
|
||||
if (0 !== $participantIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
|
||||
// CREATE mode logic
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
// Anonymous user (no personId) = must fill in data = editable
|
||||
if (null === $participant?->personId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Internal agency = first participant ≠ applicant = editable
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Logged-in user with non-internal agency = data prepopulated = read-only
|
||||
return true;
|
||||
}
|
||||
|
||||
// EDIT mode logic
|
||||
// Internal agency = first participant ≠ applicant = editable (mutability check applies separately)
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
// participant[0] is a traveller in their own right: nothing to protect here.
|
||||
if (false === $bookingDto->isFirstParticipantApplicant()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-internal agency = first participant = applicant = always read-only
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
// Anonymous user (no personId) = must fill in data = editable
|
||||
return null !== $bookingDto->getParticipant($participantIndex)?->personId;
|
||||
}
|
||||
|
||||
// EDIT mode: participant[0] is the applicant, whose record is owned by the account.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns field names that this condition depends on.
|
||||
*
|
||||
* This condition is based on agency code and personId which are set during initialization
|
||||
* and don't change during form interaction, so no field dependencies.
|
||||
* This condition is based on the applicant split and personId, which are set during
|
||||
* initialization and don't change during form interaction, so no field dependencies.
|
||||
*
|
||||
* @return string[] Empty array - no field dependencies
|
||||
*/
|
||||
@@ -90,6 +80,6 @@ class FirstParticipantReadOnlyCondition implements FieldConditionInterface
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'First participant personal data is read-only for non-internal agency bookings with logged-in users';
|
||||
return 'First participant personal data is read-only when they are also the applicant';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition for "participant[0] is the person the <anmelder> block is built from".
|
||||
*
|
||||
* Use it where a field is only mandatory because BusPro asks it of the Anmelder rather than of
|
||||
* an ordinary participant, such as mobile and address (an empty anschrift passes for a
|
||||
* participant, measured on 2036).
|
||||
*
|
||||
* It deliberately tracks $applicantTravels, the same condition
|
||||
* BookingPayloadBuilder::buildCreatePayload() keys its $applicantSource on. That keeps internal
|
||||
* agency bookings unchanged: their applicant never travels, but their payload still takes the
|
||||
* anmelder from participant[0], so participant[0] still owes the Anmelder's fields. This is why
|
||||
* it is not isFirstParticipantApplicant(), which answers false for them.
|
||||
*
|
||||
* FirstParticipantCondition remains the right choice where plain index 0 is meant, such as
|
||||
* showing a field on the first card regardless of who that person is.
|
||||
*/
|
||||
class FirstParticipantSuppliesApplicantDataCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* @param BookingDto $bookingDto The current booking data
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
return 0 === $participantIndex && $bookingDto->applicantTravels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] Empty array - the applicant choice does not change during form interaction
|
||||
*/
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Checks if participant[0] supplies the anmelder block';
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition that mirrors ParticipantDto::requiresOwnEmail() into the field state layer.
|
||||
*
|
||||
* It deliberately holds no logic of its own. The rule for who needs an email address is
|
||||
* enforced by the Assert\When on ParticipantDto::$email; this condition exists so the
|
||||
* mandatory marker rendered next to the field is derived from that same method rather than
|
||||
* from a second, independently maintained composition of age and applicant conditions.
|
||||
*/
|
||||
class RequiresOwnEmailCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates whether the participant has to supply an email address of their own.
|
||||
*
|
||||
* @param BookingDto $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*
|
||||
* @return bool True if the participant needs their own email address
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
|
||||
// A participant that does not exist yet is treated as needing an address, matching the
|
||||
// "unknown counts as an adult" stance of ParticipantDto::isChild().
|
||||
return null === $participant || $participant->requiresOwnEmail();
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule reads the date of birth, so the state has to be recalculated when it changes.
|
||||
*
|
||||
* @return string[] Array containing 'dateOfBirth'
|
||||
*/
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return ['dateOfBirth'];
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Checks if the participant needs an email address of their own';
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
|
||||
use App\Form\Service\Condition\FirstParticipantSuppliesApplicantDataCondition;
|
||||
use App\Form\Service\Condition\MultipleParticipantsCondition;
|
||||
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
|
||||
use App\Form\Service\Condition\RentalSelectionCondition;
|
||||
use App\Form\Service\Condition\RequiresOwnEmailCondition;
|
||||
use App\Form\Service\Condition\RoomSelectionCondition;
|
||||
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
||||
use App\Form\Service\Condition\SingleRoomTypeCondition;
|
||||
@@ -112,15 +112,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
];
|
||||
|
||||
// Every participant needs an email address, children included (BPN 650), so
|
||||
// requiredness is the form type's base option and not a condition here.
|
||||
$this->fieldStateConditions['email'] = [
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new RequiresOwnEmailCondition(),
|
||||
];
|
||||
|
||||
// Mobile field: static text for first participant (when read-only), required for first participant (guest bookings)
|
||||
// Mobile field: static text for first participant (when read-only). Mandatory only for the
|
||||
// applicant (guest bookings) — BusPro does not ask a telefonmobil of an ordinary participant.
|
||||
$this->fieldStateConditions['mobile'] = [
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new FirstParticipantCondition(),
|
||||
'required' => new FirstParticipantSuppliesApplicantDataCondition(),
|
||||
];
|
||||
|
||||
// Address subfields - must render as static text to prevent creating duplicate BPN records
|
||||
@@ -313,11 +315,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
'hidden' => FieldValueCondition::equals('parking', false),
|
||||
];
|
||||
|
||||
// Make address field required for first participant (index 0)
|
||||
// Also render as static text for first participant when read-only
|
||||
// Address is mandatory for the applicant only; BusPro accepts an empty anschrift from an
|
||||
// ordinary participant. Also rendered as static text for the applicant when read-only.
|
||||
$this->fieldStateConditions['address'] = [
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => new FirstParticipantCondition(),
|
||||
'required' => new FirstParticipantSuppliesApplicantDataCondition(),
|
||||
];
|
||||
|
||||
// Room assignment field:
|
||||
|
||||
@@ -20,7 +20,6 @@ use App\Form\Service\Condition\PersonalDataMutabilityCondition;
|
||||
use App\Form\Service\Condition\PickupsMutabilityCondition;
|
||||
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
|
||||
use App\Form\Service\Condition\RentalSelectionCondition;
|
||||
use App\Form\Service\Condition\RequiresOwnEmailCondition;
|
||||
use App\Form\Service\Condition\RoomSelectionCondition;
|
||||
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
||||
use App\Form\Service\Condition\SkiPassSelectionCondition;
|
||||
@@ -93,10 +92,7 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
||||
// data optional throughout.
|
||||
$this->fieldStateConditions['email'] = [
|
||||
'static_text' => $firstParticipantReadOnlyCondition,
|
||||
'required' => CompositeCondition::and(
|
||||
CompositeCondition::not($internalAgencyBookingCondition),
|
||||
new RequiresOwnEmailCondition(),
|
||||
),
|
||||
'required' => CompositeCondition::not($internalAgencyBookingCondition),
|
||||
];
|
||||
|
||||
// Address fields - render as static text if first participant in non-internal agency OR participant not mutable
|
||||
|
||||
@@ -203,7 +203,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
|
||||
|
||||
// Add notification for unassigned participant
|
||||
// For internal agency bookings, first participant is not the applicant
|
||||
$isApplicant = $participant->isApplicant() && false === $bookingDto->isInternalAgencyBooking();
|
||||
$isApplicant = $participant->isApplicant() && $bookingDto->isFirstParticipantApplicant();
|
||||
$participantLabel = $isApplicant ? 'Anmelder:in' : 'Teilnehmer:in '.($participant->index + 1);
|
||||
$participant->addNotification('warning', sprintf('%s wurde von %s entfernt', $roomLabel, $participantLabel));
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
||||
|
||||
/**
|
||||
* Handles the email field, pre-filling the applicant's address for under-16 participants.
|
||||
*
|
||||
* BusPro requires a Mail-Adresse for every participant once a room is present and rejects the
|
||||
* step-3 Anfrage with 650 otherwise, children included. It does accept an address identical to
|
||||
* the Anmelder's, so children inherit the applicant's rather than being asked for one they
|
||||
* typically do not have. The value stays editable and stays mandatory.
|
||||
*
|
||||
* The copy only ever fills a blank, so an address somebody typed is never overwritten. A later
|
||||
* correction to the applicant's own address therefore does not propagate to children who already
|
||||
* inherited the previous one.
|
||||
*
|
||||
* Depends on dateOfBirth: whether a participant counts as a child is only known once their date
|
||||
* of birth has been processed.
|
||||
*/
|
||||
class ParticipantEmailFieldHandler extends AbstractParticipantFieldHandler
|
||||
{
|
||||
public function getFieldName(): string
|
||||
{
|
||||
return 'email';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return ['dateOfBirth'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
|
||||
* @param int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
|
||||
{
|
||||
$participant = $this->getParticipant($bookingDto, $participantIndex);
|
||||
|
||||
if (false === $participant instanceof ParticipantDto) {
|
||||
return;
|
||||
}
|
||||
|
||||
$submitted = $this->getFieldValue($submittedData, $this->getFieldName());
|
||||
|
||||
if (true === is_string($submitted)) {
|
||||
$participant->email = '' !== trim($submitted) ? trim($submitted) : null;
|
||||
}
|
||||
|
||||
$this->applyApplicantEmail($bookingDto, $participant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the applicant's address onto a child who has none of their own.
|
||||
*/
|
||||
public function applyApplicantEmail(BookingDto $bookingDto, ParticipantDto $participant): void
|
||||
{
|
||||
if (false === $participant->isChild()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicantEmail = $this->resolveApplicantEmail($bookingDto);
|
||||
|
||||
if (null === $applicantEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The applicant themselves never inherits, and on an ordinary booking that is participant[0].
|
||||
if (true === $participant->isApplicant() && true === $bookingDto->applicantTravels) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $participant->email || '' === trim($participant->email)) {
|
||||
$participant->email = $applicantEmail;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveApplicantEmail(BookingDto $bookingDto): ?string
|
||||
{
|
||||
$applicant = $bookingDto->applicantTravels
|
||||
? ($bookingDto->participants[0] ?? null)
|
||||
: $bookingDto->applicant;
|
||||
|
||||
$email = $applicant?->email;
|
||||
|
||||
if (null === $email || '' === trim($email)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($email);
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,9 @@ class ParticipantFieldHandlerRegistry
|
||||
{
|
||||
// Define fields that may be reassigned by handlers and need syncing
|
||||
$serviceFields = [
|
||||
// Not a service: ParticipantEmailFieldHandler pre-fills a child's blank email from
|
||||
// the applicant, and without syncing it back the form would bind the blank over it.
|
||||
'email',
|
||||
'transportationOutbound',
|
||||
'transportationInbound',
|
||||
'insurance',
|
||||
|
||||
@@ -677,7 +677,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'attr' => [
|
||||
'data-description' => sprintf(
|
||||
'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
|
||||
$bookingDto->isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'den/die Anmelder:in'
|
||||
$bookingDto->isFirstParticipantApplicant() ? 'den/die Anmelder:in' : 'Teilnehmer:in 1'
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user