$data */ public function apply( BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant, array $data, Travel $travel, ): void { $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']); // TODO: Drop this once all legacy drafts no longer contain non-numeric body-dimension values. $participant->normalizeBodyDimensions(); } // 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']; } // Vouchers if (true === isset($data['vouchers']) && true === is_array($data['vouchers'])) { $this->applyVoucherCodes($participant, $data['vouchers']); } // Service selections (gated per-category by travel mutability flags) if (isset($data['services']) && true === is_array($data['services'])) { $this->applyServiceSelections($participant, $data['services'], $travel); } $participant->normalizeLoadedData(); } private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool { if (true === $bookingDto->isInternalAgencyBooking()) { return true; } if (0 === $participantIndex) { return false; } return $participant->mutable; } /** @param array $data */ 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']; } } /** @param array $data */ 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']; } } /** @param array $data */ 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']; } } /** @param array $data */ 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']; } } /** @param array $data */ 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']; } } /** * 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. */ /** @param array $data */ 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']) { $resolved = $this->resolveService($data['skiPass'], $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); } // Veg (single service) - merge strategy: only apply if resolves to valid service if (true === array_key_exists('veg', $data) && null !== $data['veg']) { $resolved = $this->resolveService($data['veg'], $travel->additionalServices); if (null !== $resolved) { $participant->veg = $resolved; } } // 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']; } } /** * 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 list $draftServices Services resolved from draft data * @param list $originalServices Services from fresh API data (booking assignments) * @param Travel $travel Travel data containing mandatory flag on services * * @return list 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), since the // booking entity itself does not carry the mandatory flag. foreach ($originalServices as $service) { if (false === isset($draftServiceIds[$service->id])) { $travelService = $travel->additionalServices[$service->id] ?? null; $isMandatory = null !== $travelService && true === $travelService->mandatory; if ($isMandatory) { $draftServices[] = $service; } } } return $draftServices; } /** * @param array $services */ private function resolveService(?int $serviceId, array $services): ?object { if (null === $serviceId) { return null; } return $services[$serviceId] ?? null; } /** * @param array $serviceIds * @param array $services * * @return array */ 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; } private function resolvePickup(?int $pickupId, Travel $travel): ?object { if (null === $pickupId) { return null; } // Pickups and drop-offs share the same ID space; a pickup ID may appear in either map return $travel->pickups[$pickupId] ?? $travel->dropOffs[$pickupId] ?? null; } private function resolveDropOff(?int $dropOffId, Travel $travel): ?object { if (null === $dropOffId) { return null; } return $travel->dropOffs[$dropOffId] ?? null; } private function resolveInsurance(?string $insuranceId, Travel $travel): ?object { if (null === $insuranceId || '' === $insuranceId) { return null; } return $travel->insurances[$insuranceId] ?? null; } }