543 lines
18 KiB
PHP
543 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Form\Model;
|
|
|
|
use App\BusProNet\Model\Address;
|
|
use App\BusProNet\Model\Insurance;
|
|
use App\BusProNet\Model\PersonalData;
|
|
use App\BusProNet\Model\Pickup;
|
|
use App\BusProNet\Model\PromoVoucher;
|
|
use App\BusProNet\Model\PurchaseVoucher;
|
|
use App\BusProNet\Model\Service;
|
|
use App\Validator\Constraints as AppAssert;
|
|
use Symfony\Component\Validator\Constraints as Assert;
|
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|
|
|
use function Symfony\Component\String\u;
|
|
|
|
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
|
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.
|
|
*/
|
|
public const CHILD_AGE_THRESHOLD = 16;
|
|
|
|
/**
|
|
* List of dynamic participant fields that can be conditionally hidden/shown.
|
|
*/
|
|
public const DYNAMIC_FIELDS = [
|
|
'assignedRoomId',
|
|
'remarksRoom',
|
|
'courses',
|
|
'additionalServices',
|
|
'board',
|
|
'veg',
|
|
'rentals',
|
|
'rentalInsurance',
|
|
'skiPass',
|
|
'transportationOutbound',
|
|
'transportationInbound',
|
|
'pickup',
|
|
'differentDropOff',
|
|
'dropOff',
|
|
'parking',
|
|
'licensePlate',
|
|
'bulkInsuranceBooking',
|
|
'insurance',
|
|
'purchaseVoucherCode',
|
|
'promoVoucherCode',
|
|
];
|
|
|
|
public ?int $index = null;
|
|
public ?int $addressId = null;
|
|
public ?int $personId = null;
|
|
public ?string $status = null;
|
|
public bool $mutable = false;
|
|
public bool $touched = false;
|
|
|
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
|
public ?string $firstName = null;
|
|
|
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
|
public ?string $lastName = null;
|
|
public ?string $title = null;
|
|
|
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
|
public ?string $gender = null;
|
|
|
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
|
public ?string $nationality = null;
|
|
|
|
public ?string $height = null;
|
|
public ?string $shoeSize = null;
|
|
public ?string $weight = null;
|
|
|
|
#[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.
|
|
#[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']
|
|
)]
|
|
public ?string $email = null;
|
|
|
|
public ?string $mobile = null;
|
|
|
|
#[Assert\Valid(groups: ['booking_edit', 'booking_create'])]
|
|
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
|
|
public ?Address $address = null;
|
|
|
|
#[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['strict_required'])]
|
|
public ?int $assignedRoomId = null;
|
|
|
|
#[Assert\Length(
|
|
max: 200,
|
|
maxMessage: 'Der Zimmerwunsch darf maximal {{ limit }} Zeichen lang sein'
|
|
)]
|
|
public ?string $remarksRoom = null;
|
|
|
|
/** @var list<Service> */
|
|
public array $courses = [];
|
|
/** @var list<Service> */
|
|
public array $additionalServices = [];
|
|
/** @var list<int> */
|
|
public array $autoBookOptOutServiceIds = [];
|
|
/** @var list<int> */
|
|
public array $autoBookOptOutSkiPassIds = [];
|
|
/** @var list<int> */
|
|
public array $autoBookOptOutBoardIds = [];
|
|
/** @var list<int> */
|
|
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<Service> */
|
|
public array $board = [];
|
|
public ?Service $veg = null;
|
|
/** @var list<Service> */
|
|
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;
|
|
|
|
/**
|
|
* Purchase voucher redemption code.
|
|
* Collected from all participants and aggregated into single <gutscheine> collection in booking payload.
|
|
*/
|
|
#[Assert\Length(
|
|
max: 50,
|
|
maxMessage: 'Der Gutscheincode darf maximal {{ limit }} Zeichen lang sein'
|
|
)]
|
|
#[Assert\Regex(
|
|
pattern: '/^[A-Za-z0-9\-]+$/',
|
|
message: 'Der Gutscheincode darf nur Buchstaben, Zahlen und Bindestriche enthalten'
|
|
)]
|
|
public ?string $purchaseVoucherCode = null;
|
|
|
|
/**
|
|
* Validated purchase voucher from API.
|
|
* Set by ParticipantPurchaseVoucherFieldHandler during form processing.
|
|
* Contains voucher number, remaining balance, and type (Kauf/Kulanz).
|
|
*/
|
|
public ?PurchaseVoucher $validatedPurchaseVoucher = null;
|
|
|
|
/**
|
|
* Promo voucher code.
|
|
* Applied per participant in booking payload as <aktionscode>.
|
|
*/
|
|
#[Assert\Length(
|
|
max: 50,
|
|
maxMessage: 'Der Aktionscode darf maximal {{ limit }} Zeichen lang sein'
|
|
)]
|
|
#[Assert\Regex(
|
|
pattern: '/^[A-Za-z0-9\-]+$/',
|
|
message: 'Der Aktionscode darf nur Buchstaben, Zahlen und Bindestriche enthalten'
|
|
)]
|
|
public ?string $promoVoucherCode = null;
|
|
|
|
/**
|
|
* Validated promo voucher from API.
|
|
* Set by ParticipantPromoVoucherFieldHandler during form processing.
|
|
* Contains discount amount/percentage and applicability (per person/booking).
|
|
*/
|
|
public ?PromoVoucher $validatedPromoVoucher = null;
|
|
|
|
/**
|
|
* @var array<array{type: string, message: string}> 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->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 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 treated as promotional vouchers in BPN XML
|
|
* (sent as <aktionscode> per participant, not in <gutscheine> 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
|
|
*/
|
|
/**
|
|
* 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();
|
|
|
|
// 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->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<string, mixed> $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 the participant has a complete address when required.
|
|
*
|
|
* In regular create/edit submission flows this only applies to participant 0.
|
|
* The edit overview preflight checks address fields separately for the applicant only.
|
|
*/
|
|
#[Assert\Callback(groups: ['strict_required'])]
|
|
public function validateApplicantAddress(ExecutionContextInterface $context): void
|
|
{
|
|
// Only validate applicant's address in regular create/edit submission flows.
|
|
if (0 !== $this->index) {
|
|
return;
|
|
}
|
|
|
|
$this->validateAddressFields($context);
|
|
}
|
|
|
|
private function validateAddressFields(ExecutionContextInterface $context): void
|
|
{
|
|
// Address object is required for the validated participant
|
|
if (null === $this->address) {
|
|
$context->buildViolation('Bitte angeben')
|
|
->atPath('address')
|
|
->addViolation();
|
|
|
|
return;
|
|
}
|
|
|
|
// Validate address subfields
|
|
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
|
|
$context->buildViolation('Bitte angeben')
|
|
->atPath('address.street')
|
|
->addViolation();
|
|
}
|
|
|
|
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
|
|
$context->buildViolation('Bitte angeben')
|
|
->atPath('address.postCode')
|
|
->addViolation();
|
|
}
|
|
|
|
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
|
|
$context->buildViolation('Bitte angeben')
|
|
->atPath('address.city')
|
|
->addViolation();
|
|
}
|
|
|
|
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
|
|
$context->buildViolation('Bitte angeben')
|
|
->atPath('address.country')
|
|
->addViolation();
|
|
}
|
|
}
|
|
}
|