booking to be the fresh booking, as the availability rule reads its * already-held carve-out from there. * * @return list Labels of the services that were reverted, for user feedback */ public function revertUnbookableServiceAdditions(BookingDto $workingDto, Booking $freshBooking): array { $baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking()); $revertedLabels = []; foreach ($workingDto->participants as $index => $participant) { $baseline = $baselineDto->participants[$index] ?? null; if (null === $baseline) { continue; } $isBlocked = fn (?Service $service): bool => null !== $service && null !== $service->id && $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $workingDto, $index); // Multi-selection fields: drop the blocked additions, keep everything else foreach (['courses', 'additionalServices', 'board', 'rentals'] as $field) { $kept = []; foreach ($participant->{$field} as $service) { if (true === $isBlocked($service)) { $revertedLabels[] = (string) $service->label; continue; } $kept[] = $service; } if (count($kept) !== count($participant->{$field})) { $participant->{$field} = $kept; } } // Single-selection fields: fall back to what the booking already holds rather than // clearing, so a required field does not end up empty foreach (['skiPass', 'veg', 'transportationOutbound', 'transportationInbound', 'parkingService'] as $field) { if (false === $isBlocked($participant->{$field})) { continue; } $revertedLabels[] = (string) $participant->{$field}->label; $participant->{$field} = $baseline->{$field}; } } return array_values(array_unique($revertedLabels)); } /** * Reverts immutable category changes to fresh booking values. * * @return bool True when at least one immutable field was reverted */ public function reconcileImmutableCategories(BookingDto $workingDto, Booking $freshBooking): bool { $baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking()); // Booking-level D-4 cutoff used as submit-time safety net for rentals. $serviceCutoffReachedCondition = new TravelStartCutoffReachedCondition( TravelStartCutoffReachedCondition::DEFAULT_DAYS_BEFORE_START ); // TravelStartCutoffReachedCondition is booking-level (travel date only). // The interface requires a participant index, but this condition does not use it, // so we pass 0 as a neutral placeholder. $rentalsLockedByCutoff = $serviceCutoffReachedCondition->evaluate($workingDto, 0, []); $changed = false; foreach ($workingDto->participants as $index => $participant) { $baseline = $baselineDto->participants[$index] ?? null; if (null === $baseline) { continue; } if (false === $workingDto->travel->additionalServicesMutable) { $changed = $this->reconcileAdditionalServices($participant, $baseline) || $changed; } // Reconcile rentals either when BPN additional services are immutable // or when our D-4 rule has been reached. if (false === $workingDto->travel->additionalServicesMutable || $rentalsLockedByCutoff) { $changed = $this->reconcileRentals($participant, $baseline) || $changed; } if (false === $workingDto->travel->transportationServicesMutable) { $changed = $this->reconcileTransportationServices($participant, $baseline) || $changed; } if (false === $workingDto->travel->pickupsMutable) { $changed = $this->reconcilePickups($participant, $baseline) || $changed; } } return $changed; } private function reconcileAdditionalServices(ParticipantDto $participant, ParticipantDto $baseline): bool { $changed = false; if (false === $this->areServiceListsEqual($participant->courses, $baseline->courses)) { $participant->courses = $baseline->courses; $changed = true; } if (false === $this->areServiceListsEqual($participant->additionalServices, $baseline->additionalServices)) { $participant->additionalServices = $baseline->additionalServices; $changed = true; } if (false === $this->areServiceListsEqual($participant->board, $baseline->board)) { $participant->board = $baseline->board; $changed = true; } if (false === $this->isSameService($participant->veg, $baseline->veg)) { $participant->veg = $baseline->veg; $changed = true; } if (false === $this->isSameService($participant->skiPass, $baseline->skiPass)) { $participant->skiPass = $baseline->skiPass; $changed = true; } if (false === $this->isSameService($participant->rentalInsurance, $baseline->rentalInsurance)) { $participant->rentalInsurance = $baseline->rentalInsurance; $changed = true; } if ($participant->rentalInsuranceSelected !== $baseline->rentalInsuranceSelected) { $participant->rentalInsuranceSelected = $baseline->rentalInsuranceSelected; $changed = true; } return $changed; } private function reconcileRentals(ParticipantDto $participant, ParticipantDto $baseline): bool { if (false === $this->areServiceListsEqual($participant->rentals, $baseline->rentals)) { $participant->rentals = $baseline->rentals; return true; } return false; } private function reconcileTransportationServices(ParticipantDto $participant, ParticipantDto $baseline): bool { $changed = false; if (false === $this->isSameService($participant->transportationOutbound, $baseline->transportationOutbound)) { $participant->transportationOutbound = $baseline->transportationOutbound; $changed = true; } if (false === $this->isSameService($participant->transportationInbound, $baseline->transportationInbound)) { $participant->transportationInbound = $baseline->transportationInbound; $changed = true; } if ($participant->parking !== $baseline->parking) { $participant->parking = $baseline->parking; $changed = true; } if (false === $this->isSameService($participant->parkingService, $baseline->parkingService)) { $participant->parkingService = $baseline->parkingService; $changed = true; } return $changed; } private function reconcilePickups(ParticipantDto $participant, ParticipantDto $baseline): bool { $changed = false; if (false === $this->isSamePickup($participant->pickup, $baseline->pickup)) { $participant->pickup = $baseline->pickup; $changed = true; } if (false === $this->isSamePickup($participant->dropOff, $baseline->dropOff)) { $participant->dropOff = $baseline->dropOff; $changed = true; } if ($participant->differentDropOff !== $baseline->differentDropOff) { $participant->differentDropOff = $baseline->differentDropOff; $changed = true; } return $changed; } /** * @param list $left * @param list $right */ private function areServiceListsEqual(array $left, array $right): bool { return $this->normalizeServiceIds($left) === $this->normalizeServiceIds($right); } /** * @param Service[] $services * * @return int[] */ private function normalizeServiceIds(array $services): array { $ids = []; foreach ($services as $service) { $ids[] = $service->id; } $ids = array_values(array_unique($ids)); sort($ids); return $ids; } private function isSameService(?Service $left, ?Service $right): bool { return $left?->id === $right?->id; } private function isSamePickup(?Pickup $left, ?Pickup $right): bool { return $left?->id === $right?->id; } }