*/ public array $courses = []; /** @var list */ public array $additionalServices = []; /** @var list */ public array $autoBookOptOutServiceIds = []; /** @var list */ public array $autoBookOptOutSkiPassIds = []; /** @var list */ public array $autoBookOptOutBoardIds = []; /** @var list */ public array $autoBookOptOutRentalIds = []; // Note: Ski pass validation is conditional - see App\Validator\Constraints\SkiPassSelectionValidator. // Babies (0-2 years) and travels that offer no ski passes at all are exempt. public ?Service $skiPass = null; /** @var list */ public array $board = []; public ?Service $veg = null; /** @var list */ public array $rentals = []; public ?Service $rentalInsurance = null; // Rental insurance checkbox state (boolean: true if rental insurance requested) public bool $rentalInsuranceSelected = false; // Transportation services with improved naming (outbound/inbound) #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] public ?Service $transportationOutbound = null; #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] public ?Service $transportationInbound = null; // Pickup location (applies to both directions) public ?Pickup $pickup = null; // Drop-off location (for inbound/return direction) public ?Pickup $dropOff = null; // UI-only flag: true when user selected a different drop-off location (BUS+BUS scenario) public bool $differentDropOff = false; // Parking service for self-organized transportation (boolean: true if parking requested) public bool $parking = false; // Parking service object for pricing calculation (new, contains actual service with pricing) public ?Service $parkingService = null; // License plate for participants with parking (optional, visible only when parking is selected) public ?string $licensePlate = null; // Selected insurance for this participant (individual insurance selection per participant) // Note: Insurance validation is conditional - see ParticipantEditDto::validateInsuranceRequired() public ?Insurance $insurance = null; // Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants) public bool $bulkInsuranceBooking = false; /** * The voucher field as typed: a purchase or a promo code, told apart by VoucherCodeClassifier. * ParticipantVoucherFieldHandler applies it and clears it; a rejected code stays for correction. */ public ?string $voucherCode = null; /** * Why the typed voucher code was rejected. Kept on the DTO because the htmx refresh rebuilds * the form from the DTO, which drops form errors; the Voucher constraint turns it into a violation. */ public ?string $voucherCodeError = null; /** * Applied code the customer asked to remove. Consumed and reset by ParticipantVoucherFieldHandler. */ public ?string $removeVoucherCode = null; /** * Applied purchase voucher redemption code (Kauf or Kulanz). * Kauf codes are aggregated into the booking's , a Kulanz code is sent as the participant's . */ public ?string $purchaseVoucherCode = null; /** * Validated purchase voucher from API. * Set by ParticipantVoucherFieldHandler during form processing. * Contains voucher number, remaining balance, and type (Kauf/Kulanz). */ public ?PurchaseVoucher $validatedPurchaseVoucher = null; /** * Applied promo voucher code. * Applied per participant in booking payload as . */ public ?string $promoVoucherCode = null; /** * Validated promo voucher from API. * Set by ParticipantVoucherFieldHandler during form processing. * Contains discount amount/percentage and applicability (per person/booking). * Null while promoVoucherCode is set means BusPro rejected the applied code on a later check. */ public ?PromoVoucher $validatedPromoVoucher = null; /** * Why BusPro rejected the applied promo code on a later check, e.g. the price fell below its threshold. */ public ?string $promoVoucherError = null; /** * @var array Notification messages for user feedback */ public array $notifications = []; public function __construct() { $this->address = new Address(); } public static function fromPersonalData(PersonalData $personalData): self { $instance = new self(); $instance->status = $personalData->status; $instance->addressId = $personalData->addressId; $instance->personId = $personalData->personId; $instance->mutable = $personalData->mutable; $instance->firstName = $personalData->firstName; $instance->lastName = $personalData->name; $instance->title = $personalData->title; $instance->gender = $personalData->gender; $instance->nationality = $personalData->nationality ?: 'D'; $instance->email = $personalData->communication->email; $instance->mobile = $personalData->communication->mobile; $instance->loadedMobile = $personalData->communication->mobile; $instance->dateOfBirth = $personalData->dateOfBirth; $instance->height = $personalData->height; $instance->weight = $personalData->weight; $instance->shoeSize = $personalData->shoeSize; // Clone address to prevent shared object references that could cause mutations $instance->address = clone $personalData->address; $instance->remarksRoom = $personalData->remarksRoom; $instance->licensePlate = $personalData->licensePlate; return $instance; } public function isMobileNewlyEntered(): bool { return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile); } public function isApplicant(): bool { return 0 === $this->index; } public function isCanceled(): bool { return 'S' === $this->status; } /** * Checks if participant status is 'Option' (O). */ public function isOption(): bool { return 'O' === $this->status; } /** * Calculates the participant's age in complete years. * * Uses the same logic as existing age evaluators in the system * for consistency across age-related calculations. * * @param \DateTimeImmutable|null $referenceDate The date to calculate age at (defaults to current date) * * @return int|null The calculated age in complete years, or null if no birth date */ public function getAge(?\DateTimeImmutable $referenceDate = null): ?int { if (null === $this->dateOfBirth) { return null; } $referenceDate = $referenceDate ?? new \DateTimeImmutable(); return $this->dateOfBirth->diff($referenceDate)->y; } /** * Checks if any transportation-related data is present for summary display. */ public function hasTransportationData(): bool { return null !== $this->transportationOutbound || null !== $this->transportationInbound || null !== $this->pickup || null !== $this->dropOff || true === $this->parking || null !== $this->licensePlate; } /** * Checks if the participant has selected an insurance. */ public function hasInsuranceSelected(): bool { return null !== $this->insurance; } /** * Gets the insurance label for display purposes. */ public function getInsuranceLabel(): ?string { return $this->insurance?->label; } /** * Gets the insurance price for pricing calculations. */ public function getInsurancePrice(): float { return $this->insurance->price ?? 0.0; } /** * Adds a notification message for user feedback. * * Uses an optional ID to prevent duplicate notifications. If no ID is provided, * generates one from the type and message combination. Duplicate IDs will * overwrite previous notifications, ensuring each unique notification appears once. * * @param string $type The notification type (info, warning, success) * @param string $message The notification message * @param string|null $id Optional unique identifier (auto-generated if null) */ public function addNotification(string $type, string $message, ?string $id = null): void { // Generate ID from type and message if not provided $notificationId = $id ?? md5($type.'_'.$message); // Use ID as key to automatically prevent duplicates $this->notifications[$notificationId] = [ 'type' => $type, 'message' => $message, ]; } /** * Checks if this participant has a goodwill (Kulanz) voucher. * * Goodwill vouchers are sent as the participant's , * not in the booking's collection. */ public function hasGoodwillVoucher(): bool { return null !== $this->validatedPurchaseVoucher && $this->validatedPurchaseVoucher->isGoodwill(); } /** * Determines if the participant is a child based on age at current date. * * A child is defined as someone under the specified age threshold (default: 16 years). * This classification is used for email uniqueness validation (children can share emails with adults). * * @return bool True if participant is under the age threshold, false otherwise or if age unknown */ public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool { $age = $this->getAge(); // Treat unknown age as adult for safety (requires email uniqueness) if (null === $age) { return false; } return $ageThreshold > $age; } /** * Normalizes body-dimension fields to canonical string values. * * Keeps plain integers and digit-only strings, converting them to a * normalized string representation. Anything else is treated as legacy * garbage and reset to null so form hydration can continue safely. */ public function normalizeBodyDimensions(): void { $this->height = $this->normalizeBodyDimensionValue($this->height); $this->weight = $this->normalizeBodyDimensionValue($this->weight); $this->shoeSize = $this->normalizeBodyDimensionValue($this->shoeSize); } /** * Normalizes participant data loaded from API/session sources. * * This is deliberately limited to mechanical cleanup: * - trim whitespace * - convert empty strings to null * - normalize legacy body dimension values * * It does not fill business-required values. */ public function normalizeLoadedData(): void { $this->firstName = $this->normalizeNullableString($this->firstName); $this->lastName = $this->normalizeNullableString($this->lastName); $this->title = $this->normalizeNullableString($this->title); $this->gender = $this->normalizeNullableString($this->gender); $this->nationality = $this->normalizeNullableString($this->nationality); $this->email = $this->normalizeNullableString($this->email); $this->mobile = $this->normalizeNullableString($this->mobile); $this->remarksRoom = $this->normalizeNullableString($this->remarksRoom); $this->licensePlate = $this->normalizeNullableString($this->licensePlate); $this->voucherCode = $this->normalizeNullableString($this->voucherCode); $this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode); $this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode); if (null !== $this->address) { $this->address->normalize(); } $this->normalizeBodyDimensions(); } /** * Restores the DTO from session/serialization data. * * @param array $data */ public function __unserialize(array $data): void { foreach ($data as $key => $value) { if (false === property_exists($this, $key)) { continue; } $this->$key = $value; } if (false === isset($this->address)) { $this->address = new Address(); } $this->normalizeLoadedData(); } private function normalizeBodyDimensionValue(mixed $value): ?string { if (true === is_int($value)) { return (string) $value; } if (false === is_string($value)) { return null; } $value = u($value)->trim()->toString(); if ('' === $value || false === ctype_digit($value)) { return null; } return (string) (int) $value; } private function normalizeNullableString(?string $value): ?string { if (null === $value) { return null; } $trimmed = u($value)->trim()->toString(); return '' === $trimmed ? null : $trimmed; } /** * Validates that this person is usable as the booking's applicant. * * 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: ['booking_applicant'])] public function validateAsApplicant(ExecutionContextInterface $context): void { // 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->validateApplicantCompleteness($context); } /** * 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 if (null === $this->address) { $context->buildViolation('Bitte angeben') ->atPath($pathPrefix.'address') ->addViolation(); return; } // Validate address subfields if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) { $context->buildViolation('Bitte angeben') ->atPath($pathPrefix.'address.street') ->addViolation(); } if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) { $context->buildViolation('Bitte angeben') ->atPath($pathPrefix.'address.postCode') ->addViolation(); } if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) { $context->buildViolation('Bitte angeben') ->atPath($pathPrefix.'address.city') ->addViolation(); } if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) { $context->buildViolation('Bitte angeben') ->atPath($pathPrefix.'address.country') ->addViolation(); } } }