diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 5fdcae0..103d30d 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -1080,9 +1080,19 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider if (null !== $discountedPkw && null !== $regularPkw) { $participant = $bookingDto->getParticipant($participantIndex); + // In edit mode, always preserve the participant's current selection in the choices + // This ensures the form can render the selected value even if filtering would hide it + $currentOutboundId = $participant?->transportationOutbound?->id; + $preserveDiscounted = $currentOutboundId === $discountedPkw->id; + $preserveRegular = $currentOutboundId === $regularPkw->id; + // Baby participants always get regular PKW (no discounts) $age = $participant?->getAge($bookingDto->travel->dateFrom); if (null !== $age && $age <= Constants::BABY_MAX_AGE) { + if ($preserveDiscounted) { + return [...$otherServices, $discountedPkw, $regularPkw]; + } + return [...$otherServices, $regularPkw]; } @@ -1092,6 +1102,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // If inbound is BUS, always show regular PKW (not discounted) if ($inboundIsBus) { + if ($preserveDiscounted) { + return [...$otherServices, $discountedPkw, $regularPkw]; + } + return [...$otherServices, $regularPkw]; } @@ -1104,9 +1118,19 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider if ($discountedIsUnavailable) { // Discounted is sold out within this booking, show only regular + // But preserve discounted if participant already has it selected + if ($preserveDiscounted) { + return [...$otherServices, $discountedPkw, $regularPkw]; + } + return [...$otherServices, $regularPkw]; } else { // Discounted has availability, show only discounted (hide regular) + // But preserve regular if participant already has it selected + if ($preserveRegular) { + return [...$otherServices, $discountedPkw, $regularPkw]; + } + return [...$otherServices, $discountedPkw]; } } diff --git a/src/Service/BookingEditDraftService.php b/src/Service/BookingEditDraftService.php index a5c63a6..a6dcee3 100644 --- a/src/Service/BookingEditDraftService.php +++ b/src/Service/BookingEditDraftService.php @@ -240,7 +240,7 @@ class BookingEditDraftService if (true === array_key_exists('gender', $data)) { $participant->gender = $data['gender']; } - if (true === array_key_exists('nationality', $data)) { + if (true === array_key_exists('nationality', $data) && '' !== $data['nationality'] && null !== $data['nationality']) { $participant->nationality = $data['nationality']; } } @@ -308,66 +308,101 @@ class BookingEditDraftService /** * 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 { - // Ski pass (single service) - if (true === array_key_exists('skiPass', $data)) { - $participant->skiPass = $this->resolveService($data['skiPass'], $travel->additionalServices); + // 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) + // 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) + // 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) + // 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) - if (true === array_key_exists('rentalInsurance', $data)) { - $participant->rentalInsurance = $this->resolveService($data['rentalInsurance'], $travel->additionalServices); - $participant->rentalInsuranceSelected = null !== $participant->rentalInsurance; + // 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) + // Additional services (array) - overwrite strategy with mandatory service preservation + // 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'])) { - $participant->additionalServices = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices); + $resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices); + $participant->additionalServices = $this->preserveMandatoryServices( + $resolvedFromDraft, + $participant->additionalServices, + $travel + ); } - // Transportation outbound (single) - if (true === array_key_exists('transportationOutbound', $data)) { - $participant->transportationOutbound = $this->resolveService($data['transportationOutbound'], $travel->transportationServices); + // 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) - if (true === array_key_exists('transportationInbound', $data)) { - $participant->transportationInbound = $this->resolveService($data['transportationInbound'], $travel->transportationServices); + // 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; + } } - // Pickup (single) - if (true === array_key_exists('pickup', $data)) { - $participant->pickup = $this->resolvePickup($data['pickup'], $travel); + // 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; + } } - // Parking (boolean) + // Parking (boolean) - overwrite strategy: user can uncheck if (true === array_key_exists('parking', $data)) { $participant->parking = (bool) $data['parking']; } - // Insurance (single) - if (true === array_key_exists('insurance', $data)) { - $participant->insurance = $this->resolveInsurance($data['insurance'], $travel); + // Insurance (single) - merge strategy: only apply if resolves to valid insurance + 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) + // Bulk insurance booking (boolean) - overwrite strategy: user can uncheck if (true === array_key_exists('bulkInsuranceBooking', $data)) { $participant->bulkInsuranceBooking = (bool) $data['bulkInsuranceBooking']; } @@ -447,4 +482,53 @@ class BookingEditDraftService 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; + } }