draftRepository->findByUserAndBooking($user, $bookingId); } /** * Saves or updates a draft for the given booking. * * Uses an upsert pattern: creates a new draft if none exists, * or updates the existing draft with new form data. * * @param User $user The user who owns the draft * @param int $bookingId The BusProNet booking ID * @param BookingDto $bookingDto The booking DTO containing user edits */ public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void { $formData = $this->fingerprintService->extractUserData($bookingDto); $travelDate = $bookingDto->travel->dateFrom; $existingDraft = $this->findDraft($user, $bookingId); $bookingNumber = $bookingDto->booking?->bookingNumber; $dateId = $bookingDto->travel->id; $hotelId = $bookingDto->travel->hotelId; if (null !== $existingDraft) { $existingDraft->setFormData($formData); if (null === $existingDraft->getBookingNumber() && null !== $bookingNumber) { $existingDraft->setBookingNumber($bookingNumber); } if (null === $existingDraft->getDateId() && null !== $dateId) { $existingDraft->setDateId($dateId); } if (null === $existingDraft->getHotelId() && null !== $hotelId) { $existingDraft->setHotelId($hotelId); } } else { $draft = new BookingEditDraft($user, $bookingId, $travelDate, $formData); $draft->setBookingNumber($bookingNumber); $draft->setDateId($dateId); $draft->setHotelId($hotelId); $this->entityManager->persist($draft); } $this->entityManager->flush(); $this->logger->debug('Saved booking edit draft', [ 'user_id' => $user->getId(), 'booking_id' => $bookingId, ]); } /** * Deletes a draft after successful booking submission. * * @param User $user The user who owns the draft * @param int $bookingId The BusProNet booking ID */ public function deleteDraft(User $user, int $bookingId): void { $this->draftRepository->deleteByUserAndBooking($user, $bookingId); $this->logger->debug('Deleted booking edit draft', [ 'user_id' => $user->getId(), 'booking_id' => $bookingId, ]); } /** * Applies draft data to a fresh BookingDto loaded from the API. * * This method merges user input from the draft onto fresh API data. * The draft contains personal data, service selections, and payment info * that the user previously entered. Services are resolved by ID against * the current Travel data to ensure prices and availability are current. * * @param BookingEditDraft $draft The draft containing saved user data * @param BookingDto $dto The fresh BookingDto from API (modified in place) * @param Travel $travel The current travel data for service resolution * * @return bool True if draft was applied successfully, false if draft data was invalid */ public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool { try { $formData = $draft->getFormData(); // Apply payment data if (isset($formData['paymentMethod'])) { $dto->paymentMethod = $formData['paymentMethod']; } if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) { $this->applyBankAccountData($dto, $formData['bankAccount']); } // Apply participant data if (isset($formData['participants']) && true === is_array($formData['participants'])) { foreach ($formData['participants'] as $index => $participantData) { // Only apply to participants that exist in fresh DTO if (false === isset($dto->participants[$index])) { continue; } $this->applyParticipantData($dto, $index, $participantData, $travel); } } $this->logger->info('Applied draft to booking DTO', [ 'booking_id' => $draft->getBookingId(), 'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'), ]); return true; } catch (\Throwable $e) { $this->logger->error('Failed to apply draft to booking DTO', [ 'booking_id' => $draft->getBookingId(), 'error' => $e->getMessage(), ]); return false; } } /** * Applies bank account data from draft to BookingDto. */ private function applyBankAccountData(BookingDto $dto, array $bankAccountData): void { $iban = $bankAccountData['iban'] ?? null; $accountHolder = $bankAccountData['accountHolder'] ?? null; if (null === $iban && null === $accountHolder) { return; } if (null === $dto->bankAccount) { $dto->bankAccount = new BankAccountDto(); } $dto->bankAccount->iban = $iban; $dto->bankAccount->accountHolder = $accountHolder; } /** * Applies participant data from draft to a ParticipantDto. */ private function applyParticipantData(BookingDto $bookingDto, int $participantIndex, array $data, Travel $travel): void { $participant = $bookingDto->participants[$participantIndex]; $canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant); // Personal data if (true === $canApplyPersonalDataDraft && isset($data['personalData']) && true === is_array($data['personalData'])) { $this->applyPersonalData($participant, $data['personalData']); } // Address if (true === $canApplyPersonalDataDraft && isset($data['address']) && true === is_array($data['address'])) { $this->applyAddressData($participant, $data['address']); } // Body dimensions if (true === isset($data['bodyDimensions']) && true === is_array($data['bodyDimensions'])) { $this->applyBodyDimensions($participant, $data['bodyDimensions']); } // Room assignment if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) { $this->applyRoomAssignment($participant, $data['roomAssignment']); } // License plate if (true === array_key_exists('licensePlate', $data)) { $participant->licensePlate = $data['licensePlate']; } // Services if (true === isset($data['services']) && true === is_array($data['services'])) { $this->applyServiceSelections($participant, $data['services'], $travel); } // Vouchers if (true === isset($data['vouchers']) && true === is_array($data['vouchers'])) { $this->applyVoucherCodes($participant, $data['vouchers']); } } /** * Determines whether personal/address draft data may be applied. * * Edit rules mirror UI mutability behavior: * - Internal agency bookings may always update participant personal data * - Non-internal agency: first participant is always read-only in edit flow * - Other participants require participant-level mutability from BPN */ private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool { if (true === $bookingDto->isInternalAgencyBooking()) { return true; } if (0 === $participantIndex) { return false; } return $participant->mutable; } /** * Applies personal data fields to participant. */ private function applyPersonalData(ParticipantDto $participant, array $data): void { if (true === array_key_exists('firstName', $data)) { $participant->firstName = $data['firstName']; } if (true === array_key_exists('lastName', $data)) { $participant->lastName = $data['lastName']; } if (true === array_key_exists('dateOfBirth', $data) && null !== $data['dateOfBirth']) { $participant->dateOfBirth = new \DateTimeImmutable($data['dateOfBirth']); } if (true === array_key_exists('email', $data)) { $participant->email = $data['email']; } if (true === array_key_exists('mobile', $data)) { $participant->mobile = $data['mobile']; } if (true === array_key_exists('gender', $data)) { $participant->gender = $data['gender']; } if (true === array_key_exists('nationality', $data) && '' !== $data['nationality'] && null !== $data['nationality']) { $participant->nationality = $data['nationality']; } } /** * Applies address data to participant. */ private function applyAddressData(ParticipantDto $participant, array $data): void { $hasAddressData = null !== ($data['street'] ?? null) || null !== ($data['postCode'] ?? null) || null !== ($data['city'] ?? null) || null !== ($data['country'] ?? null); if (false === $hasAddressData) { return; } if (null === $participant->address) { $participant->address = new Address(); } if (true === array_key_exists('street', $data)) { $participant->address->street = $data['street']; } if (true === array_key_exists('postCode', $data)) { $participant->address->postCode = $data['postCode']; } if (true === array_key_exists('city', $data)) { $participant->address->city = $data['city']; } if (true === array_key_exists('country', $data)) { $participant->address->country = $data['country']; } } /** * Applies body dimension data to participant. */ private function applyBodyDimensions(ParticipantDto $participant, array $data): void { if (true === array_key_exists('height', $data)) { $participant->height = $data['height']; } if (true === array_key_exists('weight', $data)) { $participant->weight = $data['weight']; } if (true === array_key_exists('shoeSize', $data)) { $participant->shoeSize = $data['shoeSize']; } } /** * Applies room assignment data to participant. */ private function applyRoomAssignment(ParticipantDto $participant, array $data): void { if (true === array_key_exists('assignedRoomId', $data)) { $participant->assignedRoomId = $data['assignedRoomId']; } if (true === array_key_exists('remarksRoom', $data)) { $participant->remarksRoom = $data['remarksRoom']; } } /** * Applies service selections to participant, resolving IDs against Travel data. * * Single-select fields (radio buttons) use a merge strategy: draft values are only applied * if they resolve to a valid service. This preserves API data when: * - The draft was saved before certain services were assigned * - The draft contains service IDs that no longer exist in current travel data * * Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values * always replace API data, since users can intentionally clear these selections. */ private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void { // Additional services category — only apply draft data when services are mutable. // When immutable, the booking's current services must be preserved as-is to avoid // API rejection (stale draft data could differ from the locked booking state). if (true === $travel->additionalServicesMutable) { // Ski pass (single service) - merge strategy: only apply if resolves to valid service if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) { $draftSkiPassId = $data['skiPass']; $resolved = $this->resolveService($draftSkiPassId, $travel->additionalServices); if (null !== $resolved) { $participant->skiPass = $resolved; } } // Courses (array) - overwrite strategy: user can deselect all if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) { $participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices); } // Board (array) - overwrite strategy: user can deselect all if (true === array_key_exists('board', $data) && true === is_array($data['board'])) { $participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices); } // Rentals (array) - overwrite strategy: user can deselect all if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) { $participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices); } // Rental insurance (single) - merge strategy: only apply if resolves to valid service if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) { $resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices); if (null !== $resolved) { $participant->rentalInsurance = $resolved; $participant->rentalInsuranceSelected = true; } } // Additional services (array) - overwrite strategy with mandatory service preservation // User can deselect optional services, but mandatory services from API must be preserved if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) { $resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices); $participant->additionalServices = $this->preserveMandatoryServices( $resolvedFromDraft, $participant->additionalServices, $travel ); } } // Transportation category — only apply draft data when transportation is mutable if (true === $travel->transportationServicesMutable) { // Transportation outbound (single) - merge strategy: only apply if resolves to valid service if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) { $resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices); if (null !== $resolved) { $participant->transportationOutbound = $resolved; } } // Transportation inbound (single) - merge strategy: only apply if resolves to valid service if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) { $resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices); if (null !== $resolved) { $participant->transportationInbound = $resolved; } } // Parking (boolean) - overwrite strategy: user can uncheck if (true === array_key_exists('parking', $data)) { $participant->parking = (bool) $data['parking']; } } // Pickups category — only apply draft data when pickups are mutable if (true === $travel->pickupsMutable) { // Pickup (single) - merge strategy: only apply if resolves to valid pickup if (true === array_key_exists('pickup', $data) && null !== $data['pickup']) { $resolved = $this->resolvePickup($data['pickup'], $travel); if (null !== $resolved) { $participant->pickup = $resolved; } } // Drop-off (single) - merge strategy: only apply if resolves to valid drop-off if (true === array_key_exists('dropOff', $data) && null !== $data['dropOff']) { $resolved = $this->resolveDropOff($data['dropOff'], $travel); if (null !== $resolved) { $participant->dropOff = $resolved; $participant->differentDropOff = true; } } } // Insurance (not governed by mutability categories — always apply from draft) if (true === array_key_exists('insurance', $data) && null !== $data['insurance']) { $resolved = $this->resolveInsurance($data['insurance'], $travel); if (null !== $resolved) { $participant->insurance = $resolved; } } // Bulk insurance booking (boolean) - overwrite strategy: user can uncheck if (true === array_key_exists('bulkInsuranceBooking', $data)) { $participant->bulkInsuranceBooking = (bool) $data['bulkInsuranceBooking']; } } /** * Applies voucher codes to participant. */ private function applyVoucherCodes(ParticipantDto $participant, array $data): void { if (true === array_key_exists('purchaseVoucherCode', $data)) { $participant->purchaseVoucherCode = $data['purchaseVoucherCode']; } if (true === array_key_exists('promoVoucherCode', $data)) { $participant->promoVoucherCode = $data['promoVoucherCode']; } } /** * Resolves a single service ID to a Service object from the available services. * * @param int|null $serviceId The service ID to resolve * @param array $services Available services indexed by ID * * @return object|null The service object if found, null otherwise */ private function resolveService(?int $serviceId, array $services): ?object { if (null === $serviceId) { return null; } return $services[$serviceId] ?? null; } /** * Resolves an array of service IDs to Service objects. * * @param array $serviceIds Array of service IDs * @param array $services Available services indexed by ID * * @return array Array of resolved Service objects (missing services are skipped) */ private function resolveServiceArray(array $serviceIds, array $services): array { $resolved = []; $addedIds = []; foreach ($serviceIds as $serviceId) { if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) { $resolved[] = $services[$serviceId]; $addedIds[$serviceId] = true; } } return $resolved; } /** * Resolves a pickup ID to a Pickup object. */ private function resolvePickup(?int $pickupId, Travel $travel): ?object { if (null === $pickupId) { return null; } return $travel->pickups[$pickupId] ?? $travel->dropOffs[$pickupId] ?? null; } /** * Resolves a drop-off ID to a Pickup object from the travel drop-offs list. */ private function resolveDropOff(?int $dropOffId, Travel $travel): ?object { if (null === $dropOffId) { return null; } return $travel->dropOffs[$dropOffId] ?? null; } /** * Resolves an insurance ID to an Insurance object. */ private function resolveInsurance(?string $insuranceId, Travel $travel): ?object { if (null === $insuranceId || '' === $insuranceId) { return null; } return $travel->insurances[$insuranceId] ?? null; } /** * Preserves mandatory services from API data when applying draft. * * Mandatory services (like Ortstaxe) cannot be deselected by users and must * always be present in the booking. When a draft was created before the agency * assigned mandatory services, this method ensures those services are preserved * from the fresh API data rather than being overwritten with stale draft data. * * The mandatory flag must be looked up from travel data since booking data * doesn't include the pflicht attribute. * * @param array $draftServices Services resolved from draft data * @param array $originalServices Services from fresh API data (booking assignments) * @param Travel $travel Travel data containing mandatory flag on services * * @return array Merged array with draft services plus any missing mandatory services */ private function preserveMandatoryServices(array $draftServices, array $originalServices, Travel $travel): array { // Build lookup of service IDs already in the draft $draftServiceIds = []; foreach ($draftServices as $service) { if (null !== $service->id) { $draftServiceIds[$service->id] = true; } } // Add mandatory services from original API data that aren't in draft // Check mandatory status from travel data (pflicht attribute) foreach ($originalServices as $service) { if (false === isset($draftServiceIds[$service->id])) { // Look up mandatory status from travel data $travelService = $travel->additionalServices[$service->id] ?? null; $isMandatory = null !== $travelService && true === $travelService->mandatory; if ($isMandatory) { $draftServices[] = $service; $this->logger->debug('Preserved mandatory service from API during draft application', [ 'service_id' => $service->id, 'service_label' => $service->label, ]); } } } return $draftServices; } }