*/ #[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ähle eine gültige Zahlungsart.' )] public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER; public ?BankAccountDto $bankAccount = null; public ?int $agencyId = null; public ?string $agencyCode = null; /** * Booking status code for API submission. * Values: 'F' (Fest), 'O' (Option), 'A' (Anfrage/Inquiry). */ public string $bookingStatus = 'F'; /** * Reference to booking entity (only populated in edit mode). */ public ?Booking $booking = 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) { } /** * Builds AcceptedVouchers from validated participant voucher data. * * Computes voucher discounts from validated vouchers stored on participants. * This allows displaying voucher savings as soon as vouchers are validated * in Step 2, rather than waiting for Step 3 API confirmation. * * For promo vouchers with percentage discounts, we need the participant price * to calculate the actual discount amount. * * @param array|null $participantPrices Prices per participant index for percentage calculation */ public function getAcceptedVouchers(?array $participantPrices = null): ?AcceptedVouchersDto { $acceptedVouchers = new AcceptedVouchersDto(); $processedPromoCodes = []; foreach ($this->participants as $index => $participant) { // Process purchase voucher if (null !== $participant->validatedPurchaseVoucher) { $voucher = $participant->validatedPurchaseVoucher; $acceptedVouchers->addVoucher(new AcceptedVoucherDto( type: $voucher->isGoodwill() ? AcceptedVoucherDto::TYPE_GOODWILL : AcceptedVoucherDto::TYPE_PURCHASE, code: $voucher->voucherNumber, amount: $voucher->remainingBalance, description: $voucher->isGoodwill() ? 'Kulanzgutschein' : 'Kaufgutschein', )); } // Process promo voucher if (null !== $participant->validatedPromoVoucher) { $voucher = $participant->validatedPromoVoucher; // For per-booking vouchers, only count once if ($voucher->isPerBooking()) { if (isset($processedPromoCodes[$voucher->code])) { continue; } $processedPromoCodes[$voucher->code] = true; } // Calculate discount amount $discountAmount = $voucher->discountAmount; if ($voucher->discountPercentage > 0 && null !== $participantPrices && isset($participantPrices[$index])) { $discountAmount = round($participantPrices[$index] * ($voucher->discountPercentage / 100), 2); } $acceptedVouchers->addVoucher(new AcceptedVoucherDto( type: AcceptedVoucherDto::TYPE_PROMOTIONAL, code: $voucher->code, amount: $discountAmount, description: $voucher->description, )); } } return $acceptedVouchers->hasVouchers() ? $acceptedVouchers : null; } 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; }); } /** @return array */ public function getParticipants(): array { return $this->participants; } 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); } #[Assert\Callback] public function validateBankAccount(ExecutionContextInterface $context): void { if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) { return; } if (null === $this->bankAccount) { $context->buildViolation('Bitte gib deine Bankverbindung an.') ->atPath('bankAccount') ->addViolation(); return; } if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) { $context->buildViolation('Bitte gib deine IBAN ein.') ->atPath('bankAccount.iban') ->addViolation(); } if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) { $context->buildViolation('Bitte gib den Kontoinhaber ein.') ->atPath('bankAccount.accountHolder') ->addViolation(); } if (false === $this->bankAccount->sepaMandateAccepted) { $context->buildViolation('Bitte akzeptiere 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 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 ($participant->hasGoodwillVoucher()) { return true; } } return false; } /** * Calculates the number of participants assigned to each room ID. * * @return array Array where key is room ID and value is count of assigned participants */ public function getRoomAssignmentCounts(): array { $counts = []; foreach ($this->participants as $participant) { if (null !== $participant->assignedRoomId) { $counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1; } } return $counts; } /** * Resets all participant room assignments. * * Clears room assignments when room selections change to prevent * invalid assignments. */ public function resetParticipantAssignments(): void { foreach ($this->participants as $participant) { $participant->assignedRoomId = null; } } /** * Creates a snapshot of the current room selection state. * * @return array Array of [roomId, quantity] pairs */ public function createRoomSelectionSnapshot(): array { return array_map( fn ($roomSelection) => [(int) $roomSelection->id, (int) $roomSelection->quantity], $this->roomSelections ); } /** * Checks if room selection has changed compared to a previous snapshot. * * @param array $oldSnapshot The baseline room selection snapshot * * @return bool True if room selections have changed, false otherwise */ public function hasRoomSelectionChanged(array $oldSnapshot): bool { $newSnapshot = $this->createRoomSelectionSnapshot(); return $oldSnapshot !== $newSnapshot; } /** * Determines if this booking is initiated by the internal agency. * * Internal agency bookings have a different relationship between applicant * and first participant: the applicant is a staff member, not the first * participant. This affects personal data editability rules. */ public function isInternalAgencyBooking(): bool { return AgencyLoader::INTERNAL_AGENCY_CODE === $this->agencyCode; } /** * Controls session serialization to exclude the heavy Travel object graph. * * Replaces the full Travel object with just its integer ID. The Booking's * travelData reference is also removed since it points to the same object. * BookingConfigurator::hydrate() restores the Travel from cache after session read. * * @return array */ public function __serialize(): array { $data = get_object_vars($this); // Replace the full Travel object graph with just its ID $data['travel'] = $this->travel->id; // Clone Booking to avoid mutating the live object, then strip its // travelData reference which points to the same heavy Travel graph if (null !== $this->booking) { $booking = clone $this->booking; $booking->travelData = null; $data['booking'] = $booking; } return $data; } /** * Restores the DTO from session data with a minimal Travel placeholder. * * Creates a Travel object containing only the ID. BookingConfigurator::hydrate() * replaces this with the full Travel from cache on every session read. * * @param array $data */ public function __unserialize(array $data): void { // Extract the travel ID before the property loop — 'travel' in the // serialized data is an int, not a Travel object $travelId = $data['travel']; unset($data['travel']); // Skip keys that no longer exist as declared properties to avoid // dynamic property creation (deprecated since PHP 8.2) foreach ($data as $key => $value) { if (false === property_exists($this, $key)) { continue; } $this->$key = $value; } foreach ($this->participants as $participant) { if ($participant instanceof ParticipantDto) { // Temporary cleanup for older serialized participants restored from session. $participant->normalizeBodyDimensions(); } } // Old sessions (before c373a989) still carry a full Travel object; // new sessions carry only the int ID. Handle both formats. // TODO: Remove Travel instance branch once all pre-deploy sessions have expired if ($travelId instanceof Travel) { $this->travel = $travelId; } else { $travel = new Travel(); $travel->id = $travelId; $this->travel = $travel; } } }