*/ #[Assert\Valid] public array $roomSelections = []; /** * @var array */ #[Assert\Valid] public array $participants = []; #[Assert\Choice( choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT], message: 'Bitte wählen Sie eine gültige Zahlungsart.' )] public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER; public ?BankAccountDto $bankAccount = null; public ?int $agencyId = null; /** * Booking status code for API submission. * Values: 'F' (Final/Frei), 'A' (Anfrage/Inquiry). */ public string $bookingStatus = 'F'; /** * Reference to booking entity (only populated in edit mode). */ public ?Booking $booking = null; /** * Timestamp of last session update (for staleness detection). * Updated automatically by BookingService::saveBookingDto(). */ public ?\DateTimeImmutable $lastSessionUpdate = 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. * Used to detect unsaved changes in edit mode by comparing with current state. */ public ?string $originalFingerprint = null; public function __construct(public Travel $travel, public int $hotelId) { } public function getMode(): string { return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE; } /** * @return array */ public function getSelectedRooms(): array { // In edit mode, rooms are fixed - return empty array if (self::MODE_EDIT === $this->getMode()) { return []; } return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) { return 0 < $roomSelection->quantity; }); } public function getParticipants(): array { return $this->participants; } public function hasParticipant(int $index): bool { return isset($this->participants[$index]); } public function getParticipant(int $index): ?ParticipantDto { return $this->participants[$index] ?? null; } /** * Determines if this is a family booking based on participant age distribution. * * A family booking is defined as: * - 1 or 2 participants aged 18 or older (adults) * - At least 1 participant younger than 18 (children) */ public function isFamilyBooking(): bool { $adults = 0; $children = 0; $travelStartDate = $this->travel->dateFrom; foreach ($this->participants as $participant) { $age = $participant->getAge($travelStartDate); if (null === $age) { continue; } if ($age >= 18) { ++$adults; } else { ++$children; } } return ($adults >= 1 && $adults <= 2) && ($children >= 1); } public function isCanceled(): bool { return null !== $this->booking && 'S' === $this->booking->status; } public function isOption(): bool { return null !== $this->booking && 'O' === $this->booking->status; } /** * Gets the label of the single selected room type. * * Returns the room label when exactly one room type is selected (auto-assignment scenario). * Used by templates to display the room assignment when the dropdown is hidden. * * @return string|null The room label or null if not a single room type scenario */ public function getSingleRoomLabel(): ?string { $selectedRooms = $this->getSelectedRooms(); // Only return label when exactly one room type selected if (1 !== count($selectedRooms)) { return null; } $roomSelection = reset($selectedRooms); $availableRooms = $this->travel->getAvailableRooms(); $room = $availableRooms[$roomSelection->roomId] ?? null; return $room?->label; } #[Assert\Callback] public function validateBankAccount(ExecutionContextInterface $context): void { if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) { return; } if (null === $this->bankAccount) { $context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.') ->atPath('bankAccount') ->addViolation(); return; } if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) { $context->buildViolation('Bitte geben Sie Ihre IBAN ein.') ->atPath('bankAccount.iban') ->addViolation(); } if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) { $context->buildViolation('Bitte geben Sie den Kontoinhaber ein.') ->atPath('bankAccount.accountHolder') ->addViolation(); } if (false === $this->bankAccount->sepaMandateAccepted) { $context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.') ->atPath('bankAccount.sepaMandateAccepted') ->addViolation(); } } /** * Checks if this is an inquiry booking (status='A'). * * Inquiry bookings occur when a travel is fully booked but still accepts inquiries. */ public function isInquiryBooking(): bool { return 'A' === $this->bookingStatus; } /** * Checks if any participants have entered voucher codes. * * @return bool True if any promotional, purchase, or goodwill vouchers are present */ public function hasVouchers(): bool { foreach ($this->participants as $participant) { if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { return true; } if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { return true; } } return false; } /** * Checks if any participants have entered goodwill vouchers. * * Goodwill vouchers (Kulanz) are a special type of purchase voucher that cannot be * redeemed in inquiry bookings. This method is used to display appropriate warnings. * * @return bool True if any goodwill vouchers are present */ public function hasGoodwillVouchers(): bool { foreach ($this->participants as $participant) { if (true === $participant->hasGoodwillVoucher) { return true; } } return false; } }