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) { /** @var PersonalData $participant */ $participantData = ParticipantDto::fromPersonalData($participant); $participantData->index = $index; // First participant (applicant): copy address from applicant if participant address is empty // BPN API may return full address only in but minimal/empty address in // Only copy if first participant has no street (indicating empty/incomplete address) // This allows applicant and first participant to be different people with different addresses if (0 === $index && null !== $booking->applicant->address) { $isEmpty = null === $participantData->address || null === $participantData->address->street || '' === trim($participantData->address->street); if ($isEmpty) { $participantData->address = clone $booking->applicant->address; } } // 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); $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); // Parking (stored in form data, not in booking entity - needs special handling) // For now, leave as false - may need to extract from transportation services $participantData->parking = false; // 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); $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->roomId = $bookingRoom->id; $roomSelection->roomLabel = $room->label; $roomSelection->roomPrice = $room->price; $roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) $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 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 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 if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) { $participant->pickup = $travel->pickupsOutbound[$participant->pickup->id]; } // 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. * * The process involves: * - Resetting existing service-to-participant mappings * - Rebuilding mappings based on current form selections * - Adding new services from travel data when participants select them * - Removing services with no participant mappings * - Updating participant personal data from form input * - Building the final API payload structure * * IMPORTANT: The applicant's address must be preserved from the original booking data stored in the DTO, * as it can be lost during form binding when ParticipantDto stores address references. * * @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 insurance if enabled (modifies DTO in place) $this->applyBulkInsuranceIfActive($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->resetServiceMappings($bookingData); foreach ($formData->participants as $participant) { $this->processParticipantServices($participant, $bookingData, $travelData); } $this->removeUnusedServices($bookingData); $this->updateParticipantPersonalData($formData->participants, $bookingData); $payload = $this->buildBasePayload($bookingData); $this->addBankAccountToPayload($payload, $bookingData); $this->buildParticipantPayload($payload, $bookingData, $formData->participants); $this->buildServicesPayload($payload, $bookingData); $this->buildPickupPayload($payload, $bookingData); // Add purchase vouchers if any exist $purchaseVouchers = $this->collectPurchaseVouchers($formData); if (false === empty($purchaseVouchers)) { $payload['gutscheine']['gutschein'] = []; foreach ($purchaseVouchers as $code) { $payload['gutscheine']['gutschein'][] = [ '@einloesecode' => $code, ]; } } return $payload; } /** * Resets all existing participant-to-service mappings to start with a clean slate. * * This ensures that service assignments are rebuilt from scratch based on current form selections. * Includes resetting room mappings to allow room reassignments during edit. * * @param Booking $bookingData The booking data object containing services to reset */ private function resetServiceMappings(Booking $bookingData): void { $servicesToReset = [ ...$bookingData->additionalServices, ...$bookingData->transportationServices, ...$bookingData->pickupsOutbound, ...$bookingData->pickupsInbound, ...$bookingData->rooms, ...$bookingData->insurances, ]; foreach ($servicesToReset as $service) { $service->mapping = []; } } /** * Processes all services for a single participant. * * This orchestrator method handles the complete service assignment workflow for one participant, * including additional services, transportation services, pickup locations, and room assignments. * * @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update * @param Travel $travelData The travel data containing available services */ private function processParticipantServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void { if (true === $participant->isCanceled()) { return; } $this->processAdditionalServices($participant, $bookingData, $travelData); $this->processTransportationServices($participant, $bookingData, $travelData); $this->processPickupLocations($participant, $bookingData); $this->processRoomAssignment($participant, $bookingData); $this->processInsurance($participant, $bookingData, $travelData); } /** * Collects additional services from a participant for mapping. * * Extracts all additional services (courses, board, rentals, ski pass, rental insurance, * additional services) from a participant into a flat array of Service objects. * * @param ParticipantDto $participant The participant data * * @return array Array of services selected by this participant */ private function collectParticipantAdditionalServices(ParticipantDto $participant): array { $services = [ ...$participant->courses, ...$participant->additionalServices, ...$participant->board, ...$participant->rentals, ]; // Add ski pass if selected (single service, not an array) if (null !== $participant->skiPass) { $services[] = $participant->skiPass; } // Add rental insurance if selected (single service, not an array) if (null !== $participant->rentalInsurance) { $services[] = $participant->rentalInsurance; } return $services; } /** * Processes additional services for a participant. * * Maps additional services (courses, ski passes, board options, rentals) to the participant. * Adds new services to the booking if they don't already exist and sets individual pricing. * * @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update * @param Travel $travelData The travel data containing available services */ private function processAdditionalServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void { $servicesToMap = $this->collectParticipantAdditionalServices($participant); foreach ($servicesToMap as $service) { if (false === isset($bookingData->additionalServices[$service->id])) { $serviceToAdd = $travelData->additionalServices[$service->id] ?? null; if (null !== $serviceToAdd) { $bookingData->additionalServices[$service->id] = $serviceToAdd; $bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; } } $bookingData->additionalServices[$service->id]->mapping[] = $participant->index; } } /** * Processes transportation services for a participant. * * Maps transportation services (both directions: to and from destination) to the participant. * Adds new transportation services to the booking if they don't already exist. * * @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update * @param Travel $travelData The travel data containing available services */ private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void { foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) { if (false === isset($bookingData->transportationServices[$service->id])) { $serviceToAdd = $travelData->transportationServices[$service->id] ?? null; if (null !== $serviceToAdd) { $bookingData->transportationServices[$service->id] = $serviceToAdd; $bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price; } } $bookingData->transportationServices[$service->id]->mapping[] = $participant->index; } } /** * Processes pickup locations for participants using bus transportation. * * Only processes pickup locations for bus transportation services and maps the participant * to their selected pickup location. * * @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update */ private function processPickupLocations(ParticipantDto $participant, Booking $bookingData): void { // Check if either transportation direction is BUS and pickup is selected $hasOutboundBus = null !== $participant->transportationOutbound && 'BUS' === $participant->transportationOutbound->subType; $hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType; if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) { if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) { $bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup; } $bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index; } } /** * Processes room assignment for a participant. * * Maps the participant to their assigned room. Allows room reassignment during * booking edits while maintaining the constraint that participants can only be * assigned to room types that have already been booked. * * @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update */ private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void { if (null === $participant->assignedRoomId) { return; } // Find the room in the existing booking by ID foreach ($bookingData->rooms as $room) { if ($room->id === $participant->assignedRoomId) { $room->mapping[] = $participant->index; break; } } } /** * Processes travel insurance for a participant. * * Maps insurance to the participant. Adds new insurances to the booking if they don't exist. * Insurance can be either individual or package-based, with automatic price-tier adjustment. * * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. * * @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update * @param Travel $travelData The travel data containing available insurances */ private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void { // Skip if no insurance or if participant selected "keine Versicherung gewünscht" if (null === $participant->insurance || $participant->insurance->isNoInsurance()) { return; } $insurance = $participant->insurance; if (false === isset($bookingData->insurances[$insurance->id])) { $insuranceToAdd = $travelData->insurances[$insurance->id] ?? null; if (null !== $insuranceToAdd) { $bookingData->insurances[$insurance->id] = $insuranceToAdd; $bookingData->insurances[$insurance->id]->individualPrice[$participant->index] = $insuranceToAdd->price; } } // Only add mapping if insurance exists in booking data // This can legitimately be false if the insurance is no longer available in current travel data if (isset($bookingData->insurances[$insurance->id])) { $bookingData->insurances[$insurance->id]->mapping[] = $participant->index; } } /** * Removes services and pickups with no participant mappings. * * Cleans up unused services to prevent empty services from being sent to the API. * This includes additional services, transportation services, pickup locations, and insurances. * * @param Booking $bookingData The booking data object to clean up */ private function removeUnusedServices(Booking $bookingData): void { foreach ($bookingData->additionalServices as $service) { if (0 === count($service->mapping)) { unset($bookingData->additionalServices[$service->id]); } } foreach ($bookingData->transportationServices as $service) { if (0 === count($service->mapping)) { unset($bookingData->transportationServices[$service->id]); } } foreach ($bookingData->pickupsOutbound as $pickup) { if (0 === count($pickup->mapping)) { unset($bookingData->pickupsOutbound[$pickup->id]); } } foreach ($bookingData->pickupsInbound as $pickup) { if (0 === count($pickup->mapping)) { unset($bookingData->pickupsInbound[$pickup->id]); } } foreach ($bookingData->insurances as $insurance) { if (0 === count($insurance->mapping)) { unset($bookingData->insurances[$insurance->id]); } } } /** * Updates participant personal data from form input. * * Only processes participants with status 'F' (active/confirmed participants). * Updates all personal data fields and communication information. * * IMPORTANT: The applicant's address must never be modified. This method updates * participant addresses independently to ensure applicant data remains intact. * * @param array $participants The participants array from the form * @param Booking $bookingData The booking data object to update */ private function updateParticipantPersonalData(array $participants, Booking $bookingData): void { foreach ($participants as $participant) { if ('F' !== $participant->status) { continue; } $bookingData->participants[$participant->index]->firstName = $participant->firstName; $bookingData->participants[$participant->index]->name = $participant->lastName; $bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth; $bookingData->participants[$participant->index]->gender = $participant->gender; $bookingData->participants[$participant->index]->nationality = $participant->nationality; $bookingData->participants[$participant->index]->height = $participant->height; $bookingData->participants[$participant->index]->weight = $participant->weight; $bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize; // Update address if provided // Create new Address instance to avoid modifying any shared object references if (null !== $participant->address) { $newAddress = new Address(); $newAddress->street = $participant->address->street; $newAddress->postCode = $participant->address->postCode; $newAddress->city = $participant->address->city; $newAddress->district = $participant->address->district; $newAddress->country = $participant->address->country; $bookingData->participants[$participant->index]->address = $newAddress; } if ($participant->email || $participant->mobile) { if (null === $bookingData->participants[$participant->index]->communication) { $bookingData->participants[$participant->index]->communication = new Communication(); } $bookingData->participants[$participant->index]->communication->email = $participant->email; $bookingData->participants[$participant->index]->communication->mobile = $participant->mobile; } } } /** * Builds the base API payload structure with booking information. * * Creates the main structure that will be populated with detailed data sections. * * @param Booking $bookingData The booking data object * * @return array The base payload structure */ private function buildBasePayload(Booking $bookingData): array { return [ 'idbuchung' => $bookingData->id, 'status' => $bookingData->status, 'idagentur' => $bookingData->agencyId, 'idreise' => $bookingData->dateId, 'idpartner' => $bookingData->hotelId, 'anmelder' => $bookingData->applicant->toPayload(), 'zahlung' => [ '@idzahlungsart' => $bookingData->paymentId, '@bezeichnung' => $bookingData->paymentLabel, '@art' => $bookingData->paymentType, ], 'teilnehmerliste' => [ 'teilnehmer' => [], ], 'zusatzleistungen' => [ 'zusatzleistung' => [], ], 'beförderungen' => [ 'beförderung' => [], ], 'ferienzielunterbringungen' => [ 'ferienzielunterbringung' => [], ], ]; } /** * Adds bank account information to the payload if present. * * Bank account information is required for direct debit payments. * * @param array $payload The payload array to modify * @param Booking $bookingData The booking data object */ private function addBankAccountToPayload(array &$payload, Booking $bookingData): void { if (null !== $bookingData->bankAccount) { $payload['zahlung']['bankverbindung'] = [ '@kreditinstitut' => $bookingData->bankAccount->bankName, '@iban' => $bookingData->bankAccount->iban, '@bic' => $bookingData->bankAccount->bic, '@kontoinhaber' => $bookingData->bankAccount->holder, ]; } } /** * Builds the participant list section of the payload. * * Includes status, personal data, and wishes (room remarks, license plate) for each participant. * * @param array $payload The payload array to modify * @param Booking $bookingData The booking data object * @param array $participantDtos The participant DTOs from the form (for wishes data) */ private function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void { foreach ($bookingData->participants as $index => $participant) { $participantPayload = [ '@id' => $index + 1, 'status' => $bookingData->participantsStatus[$index], ...$participant->toPayload(), ]; // Add wishes (room remarks, license plate) from form DTO if (isset($participantDtos[$index])) { $dto = $participantDtos[$index]; if (null !== $dto->remarksRoom || null !== $dto->licensePlate) { $wishes = []; if (null !== $dto->remarksRoom && '' !== trim($dto->remarksRoom)) { $wishes['unterbringungswunsch'] = $dto->remarksRoom; } if (null !== $dto->licensePlate && '' !== trim($dto->licensePlate)) { $wishes['beförderungswunsch'] = $dto->licensePlate; } if (false === empty($wishes)) { $participantPayload['wünsche'] = $wishes; } } // Add promo voucher or goodwill voucher as aktionscode // Goodwill vouchers (Kulanz) take precedence over promo vouchers $aktionscode = $this->getPromoVoucherCodeForParticipant($dto); if (null !== $aktionscode) { $participantPayload['aktionscode'] = $aktionscode; } } $payload['teilnehmerliste']['teilnehmer'][] = $participantPayload; } } /** * Builds the services sections of the payload. * * Includes additional services, transportation services, and accommodation details * with participant mappings and quantities. * * @param array $payload The payload array to modify * @param Booking $bookingData The booking data object */ private function buildServicesPayload(array &$payload, Booking $bookingData): void { foreach ($bookingData->additionalServices as $service) { $payload['zusatzleistungen']['zusatzleistung'][] = [ '@idleistung' => $service->id, '@anzahl' => count($service->mapping), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), ]; } foreach ($bookingData->transportationServices as $service) { $payload['beförderungen']['beförderung'][] = [ '@idleistung' => $service->id, '@anzahl' => count($service->mapping), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)), ]; } foreach ($bookingData->rooms as $room) { $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ '@idzimmer' => $room->id, '@kategorie' => $room->category, '@idverpflegung' => $room->boardId, '@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null, '@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null, '@anzahl' => $room->totalCount, '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)), ]; } // Add insurances with participant mappings if (false === empty($bookingData->insurances)) { $payload['versicherungen']['versicherung'] = []; foreach ($bookingData->insurances as $insurance) { if (count($insurance->mapping) > 0) { $payload['versicherungen']['versicherung'][] = [ '@idversicherung' => $insurance->id, '@anzahl' => count($insurance->mapping), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)), ]; } } } } /** * Builds the pickup locations section of the payload. * * Only included in payload if there are actual pickup assignments for bus transportation. * * @param array $payload The payload array to modify * @param Booking $bookingData The booking data object */ private function buildPickupPayload(array &$payload, Booking $bookingData): void { if (0 < count($bookingData->pickupsOutbound)) { $payload['zustiege']['zustieg'] = []; foreach ($bookingData->pickupsOutbound as $pickup) { $payload['zustiege']['zustieg'][] = [ '@idzustieg' => $pickup->id, '@anzahl' => count($pickup->mapping), '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)), ]; } } } /** * 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 insurance if enabled (modifies DTO in place) $this->applyBulkInsuranceIfActive($bookingDto); $firstParticipant = $bookingDto->participants[0]; $payload = [ 'buchungsart' => $bookingType, 'status' => $bookingDto->bookingStatus, 'idreise' => $bookingDto->travel->id, 'idpartner' => $bookingDto->travel->hotelId, 'idagentur' => $bookingDto->agencyId, ]; // Add applicant (first participant data) $payload['anmelder'] = [ 'name' => $firstParticipant->lastName, 'vorname' => $firstParticipant->firstName, 'geschlecht' => $firstParticipant->gender ?? '', 'nationalitaet' => $firstParticipant->nationality ?? '', ]; // DO NOT include personId or addressId in create mode // BPN will automatically match existing customers by exact personal data (name, DOB, address) // Including IDs would prevent automatic matching and could cause data inconsistencies if (null !== $firstParticipant->dateOfBirth) { $payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y'); } // Add address for applicant if (null !== $firstParticipant->address) { $payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload(); } if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) { $payload['anmelder']['kommunikation'] = []; if (null !== $firstParticipant->email) { $payload['anmelder']['kommunikation']['email'] = $firstParticipant->email; } if (null !== $firstParticipant->mobile) { $payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile; } } // Add participants $payload['teilnehmerliste']['teilnehmer'] = []; foreach ($bookingDto->participants as $index => $participant) { $participantData = [ '@id' => $index + 1, 'name' => $participant->lastName, 'vorname' => $participant->firstName, 'geschlecht' => $participant->gender ?? '', 'nationalitaet' => $participant->nationality ?? '', ]; // DO NOT include personId or addressId in create mode // BPN will automatically match existing customers by exact personal data (name, DOB, address) // Including IDs would prevent automatic matching and could cause data inconsistencies if (null !== $participant->dateOfBirth) { $participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); } // Add address (always include structure, even if empty) $participantData['anschrift'] = $participant->address?->toPayload() ?? [ 'strasse' => null, 'plz' => null, 'ort' => null, 'ortsteil' => null, 'land' => null, ]; // Add contact info for all participants if (null !== $participant->email || null !== $participant->mobile) { $participantData['kommunikation'] = []; if (null !== $participant->email) { $participantData['kommunikation']['email'] = $participant->email; } if (null !== $participant->mobile) { $participantData['kommunikation']['telefonmobil'] = $participant->mobile; } } // Add wishes (room remarks and license plate) if (null !== $participant->remarksRoom || null !== $participant->licensePlate) { $participantData['wünsche'] = []; if (null !== $participant->remarksRoom && '' !== trim($participant->remarksRoom)) { $participantData['wünsche']['unterbringungswunsch'] = $participant->remarksRoom; } if (null !== $participant->licensePlate && '' !== trim($participant->licensePlate)) { $participantData['wünsche']['beförderungswunsch'] = $participant->licensePlate; } } // Add promo voucher or goodwill voucher as aktionscode // Goodwill vouchers (Kulanz) take precedence over promo vouchers $aktionscode = $this->getPromoVoucherCodeForParticipant($participant); if (null !== $aktionscode) { $participantData['aktionscode'] = $aktionscode; } $payload['teilnehmerliste']['teilnehmer'][] = $participantData; } // Collect and group all services by ID with participant mappings $serviceMap = $this->collectServiceMappings($bookingDto); $transportationMap = $this->collectTransportationMappings($bookingDto); $roomMap = $this->collectRoomMappings($bookingDto); $pickupMap = $this->collectPickupMappings($bookingDto); $insuranceMap = $this->collectInsuranceMappings($bookingDto); // Add services using reusable helper methods $this->addServicesFromMap($payload, 'beförderungen', 'beförderung', '@idleistung', $transportationMap); $this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto); $this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap); $this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap); $this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap); // Add purchase vouchers if any exist $purchaseVouchers = $this->collectPurchaseVouchers($bookingDto); if (false === empty($purchaseVouchers)) { $payload['gutscheine']['gutschein'] = []; foreach ($purchaseVouchers as $code) { $payload['gutscheine']['gutschein'][] = [ '@einloesecode' => $code, ]; } } // Add payment information $payload['zahlung'] = [ '@idzahlungsart' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod ? Constants::PAYMENT_TYPE_ID_DEBIT : Constants::PAYMENT_TYPE_ID_TRANSFER, '@art' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod ? 'EINZUG' : 'UEBERWEISUNG', ]; if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod && null !== $bookingDto->bankAccount) { $payload['zahlung']['bankverbindung'] = [ '@iban' => $bookingDto->bankAccount->iban, '@kontoinhaber' => $bookingDto->bankAccount->accountHolder, ]; } return $payload; } /** * Adds services from a mapping to the payload. * * Generic helper method that converts service ID => participant IDs mappings * into XML payload structure. * * @param array $payload The payload array to modify * @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen') * @param string $itemKey The item key (e.g., 'beförderung', 'versicherung') * @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung') * @param array $serviceMap Map of service ID to participant IDs */ private function addServicesFromMap( array &$payload, string $sectionKey, string $itemKey, string $idAttributeName, array $serviceMap, ): void { if (false === empty($serviceMap)) { $payload[$sectionKey][$itemKey] = []; foreach ($serviceMap as $serviceId => $participantIds) { $payload[$sectionKey][$itemKey][] = [ $idAttributeName => $serviceId, '@anzahl' => count($participantIds), '@zuordnung' => implode(',', $participantIds), ]; } } } /** * Adds room mappings with detailed attributes to the payload. * * Rooms require special attributes beyond simple service mapping: * - kategorie (room category code) * - idverpflegung (board type ID) * - anreise (arrival date) * - abreise (departure date) * - anzahl (number of rooms of this type booked) * * @param array $payload The payload array to modify * @param array $roomMap Map of room ID to participant IDs * @param BookingDto $bookingDto The booking data for accessing room details and quantities */ private function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingDto $bookingDto): void { if (empty($roomMap)) { return; } $availableRooms = $bookingDto->travel->getAvailableRooms(); $payload['ferienzielunterbringungen']['ferienzielunterbringung'] = []; // Build room selection quantity lookup $roomQuantities = []; foreach ($bookingDto->roomSelections as $selection) { if ($selection->quantity > 0) { $roomQuantities[$selection->roomId] = $selection->quantity; } } foreach ($roomMap as $roomId => $participantIds) { $room = $availableRooms[$roomId] ?? null; if (null === $room) { continue; } $quantity = $roomQuantities[$roomId] ?? 1; $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ '@idzimmer' => $room->id, '@kategorie' => $room->category, '@idverpflegung' => $room->boardId, '@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'), '@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'), '@anzahl' => $quantity, '@zuordnung' => implode(',', $participantIds), ]; } } /** * Collects room mappings. * * Groups participants by their assigned room ID. * * @return array> Map of room ID to participant IDs */ private function collectRoomMappings(BookingDto $bookingDto): array { $roomMap = []; foreach ($bookingDto->participants as $index => $participant) { $participantId = $index + 1; if (null !== $participant->assignedRoomId) { $roomMap[$participant->assignedRoomId][] = $participantId; } } return $roomMap; } /** * Collects service mappings for the booking request. * * Groups board, ski passes, rentals, rental insurance, courses, parking, and additional services * by service ID with their participant assignments (1-based). * * @return array> Map of service ID to participant IDs */ private function collectServiceMappings(BookingDto $bookingDto): array { $serviceMap = []; foreach ($bookingDto->participants as $index => $participant) { $participantId = $index + 1; // Board services foreach ($participant->board as $board) { $serviceMap[$board->id][] = $participantId; } // Ski pass if (null !== $participant->skiPass) { $serviceMap[$participant->skiPass->id][] = $participantId; } // Rentals foreach ($participant->rentals as $rental) { $serviceMap[$rental->id][] = $participantId; } // Rental insurance if (null !== $participant->rentalInsurance) { $serviceMap[$participant->rentalInsurance->id][] = $participantId; } // Courses foreach ($participant->courses as $course) { $serviceMap[$course->id][] = $participantId; } // Parking service (for self-organized transportation) if (true === $participant->parking && null !== $participant->parkingService) { $serviceMap[$participant->parkingService->id][] = $participantId; } // Additional services foreach ($participant->additionalServices as $service) { $serviceMap[$service->id][] = $participantId; } } return $serviceMap; } /** * Collects transportation service mappings. * * @return array> Map of transportation service ID to participant IDs */ private function collectTransportationMappings(BookingDto $bookingDto): array { $transportationMap = []; foreach ($bookingDto->participants as $index => $participant) { $participantId = $index + 1; if (null !== $participant->transportationOutbound) { $transportationMap[$participant->transportationOutbound->id][] = $participantId; } if (null !== $participant->transportationInbound) { $transportationMap[$participant->transportationInbound->id][] = $participantId; } } return $transportationMap; } /** * Collects pickup location mappings. * * Collects the unified pickup selection which applies to both directions. * The API receives this as outbound pickup data. * * @return array> Map of pickup ID to participant IDs */ private function collectPickupMappings(BookingDto $bookingDto): array { $pickupMap = []; foreach ($bookingDto->participants as $index => $participant) { $participantId = $index + 1; if (null !== $participant->pickup) { $pickupMap[$participant->pickup->id][] = $participantId; } } return $pickupMap; } /** * Collects insurance mappings. * * CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow. * IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission. * * @return array> Map of insurance ID to participant IDs */ private function collectInsuranceMappings(BookingDto $bookingDto): array { $insuranceMap = []; foreach ($bookingDto->participants as $index => $participant) { $participantId = $index + 1; // Exclude synthetic "keine Versicherung gewünscht" option from BPN XML if (null !== $participant->insurance && false === $participant->insurance->isNoInsurance()) { $insuranceMap[$participant->insurance->id][] = $participantId; } } return $insuranceMap; } /** * Collects all purchase vouchers from participants. * * Removes duplicates and empty values, returning unique redemption codes * for aggregation into the collection in the booking payload. * * IMPORTANT: Goodwill vouchers (Kulanz) are excluded from this collection. * They are added per participant as instead. * * @return string[] Array of unique redemption codes (excluding Kulanz) */ private function collectPurchaseVouchers(BookingDto $bookingDto): array { $vouchers = []; foreach ($bookingDto->participants as $participant) { if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { // Exclude goodwill vouchers (flagged by field handler) if (false === $participant->hasGoodwillVoucher) { $vouchers[] = trim($participant->purchaseVoucherCode); } } } // Remove duplicates (in case multiple participants enter same code) return array_unique($vouchers); } /** * Gets the aktionscode for a participant. * * Checks if the participant has a goodwill (Kulanz) purchase voucher first. * If yes, returns that code (goodwill vouchers override promo vouchers). * Otherwise, returns the promo voucher code if present. * * @return string|null The aktionscode to use, or null if none */ private function getPromoVoucherCodeForParticipant(ParticipantDto $participant): ?string { // Check for goodwill voucher first (takes precedence) if (true === $participant->hasGoodwillVoucher && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { return trim($participant->purchaseVoucherCode); } // Fall back to promo voucher if present if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { return trim($participant->promoVoucherCode); } return null; } /** * Applies bulk insurance assignment if the applicant has enabled it. * * When bulk insurance is active (applicant's bulkInsuranceBooking = true), this method * assigns the applicant's insurance TYPE to all dependent participants with automatic * price tier adjustment based on each participant's total cost. * * IMPORTANT: In edit mode, 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. * * @param BookingDto $bookingDto The booking DTO (create or edit flow) */ private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void { $applicant = $bookingDto->getParticipant(0); // Check if bulk insurance is enabled and applicant has selected an insurance if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { 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 InsuranceService for proper type-based assignment with price tier matching $assignments = $this->insuranceService->batchAssignInsuranceToParticipants( $selectableInsurances, $applicant->insurance, $bookingDto, $participantPrices ); // Apply assignments to dependent participants (skip applicant at index 0) foreach ($assignments as $index => $insurance) { if (0 === $index) { continue; // Skip applicant } $participant = $bookingDto->getParticipant($index); if (null === $participant) { continue; } // In edit mode: only apply bulk insurance if participant has no insurance // This respects the rule that once assigned, insurance cannot be changed if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) { continue; // Skip participants with existing insurance assignment } $participant->insurance = $insurance; } } }