) * - For internal agency bookings, this copy is skipped: the applicant is a customer, not * the first participant, so copying applicant data into participant[0] would be incorrect */ public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto { $dto = new BookingDto($travel, $booking->hotelId); $dto->booking = $booking; $dto->agencyId = $booking->agencyId; // Map payment ID from API to form payment method $dto->paymentMethod = match ($booking->paymentId) { (string) Constants::PAYMENT_TYPE_ID_DEBIT => Constants::PAYMENT_METHOD_DEBIT, (string) Constants::PAYMENT_TYPE_ID_TRANSFER => Constants::PAYMENT_METHOD_TRANSFER, default => Constants::PAYMENT_METHOD_TRANSFER, }; if (null !== $booking->bankAccount) { $dto->bankAccount = BankAccountDto::fromBankAccount($booking->bankAccount); } foreach ($booking->participants as $index => $participant) { $participantData = ParticipantDto::fromPersonalData($participant); $participantData->index = $index; // For normal bookings, applicant === participant[0]: copy address and body dimensions // from applicant when BPN returns full data only in and minimal data in // . Skipped for internal agency bookings where the applicant is // a customer (a different entity from the first traveler). if (0 === $index && false === $isInternalAgencyBooking) { // Copy address if first participant has no street (indicating empty/incomplete address) $isEmpty = null === $participantData->address || null === $participantData->address->street || u($participantData->address->street)->trim()->isEmpty(); if ($isEmpty) { $participantData->address = clone $booking->applicant->address; } // Copy body dimensions from applicant if not present in participant if (null === $participantData->height && null !== $booking->applicant->height) { $participantData->height = $booking->applicant->height; } if (null === $participantData->weight && null !== $booking->applicant->weight) { $participantData->weight = $booking->applicant->weight; } if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) { $participantData->shoeSize = $booking->applicant->shoeSize; } } // Extract service selections from booking and assign to participant DTO $participantData->courses = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); $participantData->skiPass = $booking->getSkiPassForParticipant($index); $participantData->additionalServices = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL); $participantData->board = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_BOARD); $vegServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_VEG); $participantData->veg = false === empty($vegServices) ? reset($vegServices) : null; $participantData->rentals = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_RENTALS); // Rental insurance (single service, not array) $participantData->rentalInsurance = $booking->getRentalInsuranceForParticipant($index); $participantData->rentalInsuranceSelected = null !== $participantData->rentalInsurance; // Transportation services $participantData->transportationOutbound = $booking ->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::OUTBOUND_BOOKING); $participantData->transportationInbound = $booking ->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::INBOUND_BOOKING); // Pickup location $participantData->pickup = $booking->getPickupForParticipant($index); // Drop-off location $participantData->dropOff = $booking->getDropOffForParticipant($index); $participantData->differentDropOff = null !== $participantData->dropOff; // Parking service (for self-organized PKW transportation) $parkingServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_PARKING); if (false === empty($parkingServices)) { $participantData->parking = true; $participantData->parkingService = reset($parkingServices); } else { $participantData->parking = false; $participantData->parkingService = null; } // License plate (not stored in booking entity) $participantData->licensePlate = null; // Insurance $participantData->insurance = $booking->getInsuranceForParticipant($index); // Room assignment $room = $booking->getRoomForParticipant($index); $participantData->assignedRoomId = $room?->id; // Enrich services with data from travel (especially prices) $this->enrichParticipantServicesFromTravel($participantData, $travel); // Normalize loaded participant data so the edit flow sees canonical values. $participantData->normalizeLoadedData(); $dto->participants[$index] = $participantData; } // Rebuild roomSelections array from booking room data // This is needed for conditions like SingleRoomTypeCondition that rely on roomSelections // The booking already contains rooms with actual quantities (anzahl from XML) $availableRooms = $travel->getAvailableRooms(); foreach ($booking->rooms as $bookingRoom) { $room = $availableRooms[$bookingRoom->id] ?? null; if (null !== $room && null !== $bookingRoom->totalCount && $bookingRoom->totalCount > 0) { $roomSelection = new RoomSelectionDto(); $roomSelection->id = $bookingRoom->id; $roomSelection->label = $room->label; $roomSelection->price = $room->price; $roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) $roomSelection->capacity = $room->minPax ?? 1; $roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0); $dto->roomSelections[] = $roomSelection; } } return $dto; } /** * Enriches participant service selections with data from travel model. * * Services extracted from booking API responses might not include all necessary data * (especially prices). This method looks up each service in the travel data and copies * over missing properties to ensure proper pricing calculations. * * @param ParticipantDto $participant The participant with service selections * @param Travel $travel The travel data containing full service information */ private function enrichParticipantServicesFromTravel(ParticipantDto $participant, Travel $travel): void { // Enrich courses foreach ($participant->courses as $key => $course) { if (isset($travel->additionalServices[$course->id])) { $participant->courses[$key] = $travel->additionalServices[$course->id]; } } // Enrich ski pass if (null !== $participant->skiPass && isset($travel->additionalServices[$participant->skiPass->id])) { $participant->skiPass = $travel->additionalServices[$participant->skiPass->id]; } // Enrich additional services foreach ($participant->additionalServices as $key => $service) { if (isset($travel->additionalServices[$service->id])) { $participant->additionalServices[$key] = $travel->additionalServices[$service->id]; } } // Enrich board foreach ($participant->board as $key => $board) { if (isset($travel->additionalServices[$board->id])) { $participant->board[$key] = $travel->additionalServices[$board->id]; } } // Enrich veg if (null !== $participant->veg && isset($travel->additionalServices[$participant->veg->id])) { $participant->veg = $travel->additionalServices[$participant->veg->id]; } // Enrich rentals foreach ($participant->rentals as $key => $rental) { if (isset($travel->additionalServices[$rental->id])) { $participant->rentals[$key] = $travel->additionalServices[$rental->id]; } } // Enrich rental insurance if (null !== $participant->rentalInsurance && isset($travel->additionalServices[$participant->rentalInsurance->id])) { $participant->rentalInsurance = $travel->additionalServices[$participant->rentalInsurance->id]; } // Enrich parking service if (null !== $participant->parkingService && isset($travel->additionalServices[$participant->parkingService->id])) { $participant->parkingService = $travel->additionalServices[$participant->parkingService->id]; } // Enrich transportation services if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) { $participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id]; } if (null !== $participant->transportationInbound && isset($travel->transportationServices[$participant->transportationInbound->id])) { $participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id]; } // Enrich pickup with data from travel (includes both outbound and inbound prices) // The pickups already have inbound prices merged in from TravelParser if (null !== $participant->pickup && isset($travel->pickups[$participant->pickup->id])) { $enrichedPickup = $travel->pickups[$participant->pickup->id]; // Copy all properties from enriched pickup to participant's pickup $participant->pickup->price = $enrichedPickup->price; $participant->pickup->priceOutbound = $enrichedPickup->priceOutbound; $participant->pickup->priceInbound = $enrichedPickup->priceInbound; $participant->pickup->time = $enrichedPickup->time; $participant->pickup->city = $enrichedPickup->city; $participant->pickup->street = $enrichedPickup->street; $participant->pickup->postalCode = $enrichedPickup->postalCode; $participant->pickup->code = $enrichedPickup->code; } // Enrich drop-off with data from travel if (null !== $participant->dropOff && isset($travel->dropOffs[$participant->dropOff->id])) { $enrichedDropOff = $travel->dropOffs[$participant->dropOff->id]; $participant->dropOff->price = $enrichedDropOff->price; $participant->dropOff->priceOutbound = $enrichedDropOff->priceOutbound; $participant->dropOff->priceInbound = $enrichedDropOff->priceInbound; $participant->dropOff->time = $enrichedDropOff->time; $participant->dropOff->city = $enrichedDropOff->city; $participant->dropOff->street = $enrichedDropOff->street; $participant->dropOff->postalCode = $enrichedDropOff->postalCode; $participant->dropOff->code = $enrichedDropOff->code; } // Enrich insurance if (null !== $participant->insurance && isset($travel->insurances[$participant->insurance->id])) { $participant->insurance = $travel->insurances[$participant->insurance->id]; } } /** * Creates an update request payload for the BusProNet API from booking form data. * * This method processes booking edit form data and transforms it into the structured array * format expected by the BusProNet XML API. It handles service mappings, participant data * updates, and generates the complete payload structure including booking metadata, * participant information, services, transportation, and accommodation details. * * @param BookingDto|null $formData The booking edit form data containing updated participant and service selections * * @return array The structured payload array ready for BusProNet API submission */ public function createUpdateRequestPayload(?BookingDto $formData): array { // Apply bulk or family insurance if applicable (modifies DTO in place) $this->applyApplicantInsuranceToParticipants($formData); $bookingData = $formData->booking; $travelData = $formData->travel; // CRITICAL: The applicant address may have been lost during form binding because ParticipantDto::fromPersonalData() // stores a reference to the address object, and Symfony's form binding can modify it in place. // Since we can't easily restore it here without the original booking, we rely on the EditController // to preserve the original booking's applicant data by fetching fresh data before calling updateBooking(). // The applicant data should NEVER be modified in edit mode. $this->serviceProcessor->resetServiceMappings($bookingData); foreach ($formData->participants as $participant) { $this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData); } $this->serviceProcessor->removeUnusedServices($bookingData); $this->personalDataSynchronizer->updateParticipantPersonalData( $formData->participants, $bookingData, $formData->isInternalAgencyBooking() ); $payload = $this->payloadBuilder->buildBasePayload($bookingData); $this->payloadBuilder->addBankAccountToPayload($payload, $bookingData); $this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants); $this->payloadBuilder->buildServicesPayload($payload, $bookingData); $this->payloadBuilder->buildPickupPayload($payload, $bookingData); $this->payloadBuilder->buildDropOffPayload($payload, $bookingData); // Add purchase vouchers if any exist $purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData); if (false === empty($purchaseVouchers)) { $payload['gutscheine']['gutschein'] = []; foreach ($purchaseVouchers as $code) { $payload['gutscheine']['gutschein'][] = [ '@einloesecode' => $code, ]; } } return $payload; } /** * Creates a booking request payload for new bookings (inquiry or final booking). * * Generates the array payload structure for creating new bookings through the BusProNet API. * This includes all participant data, room selections, services (including insurance), and * payment information. The booking type determines whether this is an inquiry validation * ('Anfrage') or a final booking commit ('Buchung'). * * @param BookingDto $bookingDto The booking creation form data * @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking) * * @return array The structured payload array for BusProNet API submission */ public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array { // Apply bulk or family insurance if applicable (modifies DTO in place) $this->applyApplicantInsuranceToParticipants($bookingDto); return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType); } /** * Applies the applicant's insurance to other participants if their selection requires it. * * Runs when either: * - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the * applicant's insurance TYPE to all dependent participants with automatic price tier * adjustment based on each participant's individual cost. * - The applicant selected a family insurance: family insurance covers the whole family * under a single policy, so dependents must never hold their own insurance, regardless * of whether the bulk checkbox was checked. * * IMPORTANT: In edit mode, non-family bulk insurance only applies to participants who: * 1. Currently have NO insurance assigned (insurance === null) * 2. OR whose insurance was already assigned via previous bulk operation * * This prevents overriding individually selected insurances that are locked. Family * insurance is exempt from this protection: dependents can never legitimately hold * their own policy alongside it, so their insurance is always cleared, in edit mode too. * * @param BookingDto $bookingDto The booking DTO (create or edit flow) */ private function applyApplicantInsuranceToParticipants(BookingDto $bookingDto): void { $applicant = $bookingDto->getParticipant(0); if (null === $applicant || null === $applicant->insurance) { return; } $isFamilyInsurance = true === $applicant->insurance->familyInsurance; // Check if bulk insurance is enabled OR the applicant's selection is a family // insurance (which always applies to dependents, independent of the bulk checkbox) if (false === $applicant->bulkInsuranceBooking && false === $isFamilyInsurance) { return; } // Get selectable (non-complementary) insurances with caching $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); // Calculate travel prices for all participants (excluding insurance) $participantPrices = []; foreach ($bookingDto->getParticipants() as $index => $participant) { $participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index); } // Use InsuranceManager for proper type-based assignment with price tier matching $assignments = $this->insuranceService->batchAssignInsuranceToParticipants( $selectableInsurances, $applicant->insurance, $bookingDto, $participantPrices ); // Apply assignments to all participants; for non-family insurance skip the applicant // (they already hold the insurance they selected themselves) foreach ($assignments as $index => $insurance) { if (0 === $index && false === $isFamilyInsurance) { continue; } $participant = $bookingDto->getParticipant($index); if (null === $participant) { continue; } // In edit mode: non-family bulk assignment must not overwrite an individually // locked insurance. Family insurance always overrides, since dependents can // never legitimately keep their own policy alongside it. if (BookingDto::MODE_EDIT === $bookingDto->getMode() && false === $isFamilyInsurance && null !== $participant->insurance) { continue; // Skip participants with existing insurance assignment } $participant->insurance = $insurance; } } }