diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index 3a36806..948d33d 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -143,6 +143,31 @@ class Booking }); } + /** + * Checks whether a participant already holds a service in this booking. + * + * This is the BusPro-side baseline, not the working edit selection: it answers + * "did the participant have this service when the booking was last read back". + * Services already held may be kept regardless of their current status or + * contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit + * Teilnehmerzuordnung überein" on the next update. + * + * @param int $participantIndex The participant index to search for + * @param int $serviceId The service ID to look for + * + * @return bool True if the participant already holds the service + */ + public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool + { + foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) { + if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) { + return true; + } + } + + return false; + } + /** * Retrieves transportation service for a specific participant and direction. * diff --git a/src/BusProNet/XmlLoader/TravelLoader.php b/src/BusProNet/XmlLoader/TravelLoader.php index 881824f..63c801e 100644 --- a/src/BusProNet/XmlLoader/TravelLoader.php +++ b/src/BusProNet/XmlLoader/TravelLoader.php @@ -281,9 +281,9 @@ class TravelLoader extends AbstractLoader /** * Apply availability data to travel services. * - * Updates the availability status of additional and transportation - * services, and the allowed booking status, based on the provided - * availability data. + * Updates the remaining contingent and the live status of additional and + * transportation services, and the allowed booking status, based on the + * provided availability data. * * @param Travel $travel The travel object to update * @param ServiceAvailabilityResponse $availabilities The availability data for services @@ -293,8 +293,19 @@ class TravelLoader extends AbstractLoader $serviceAvailabilities = $availabilities->getServices(); foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) { - if (array_key_exists($service->id, $serviceAvailabilities)) { - $service->available = $serviceAvailabilities[$service->id]->available; + if (false === array_key_exists($service->id, $serviceAvailabilities)) { + continue; + } + + $availability = $serviceAvailabilities[$service->id]; + $service->available = $availability->available; + + // The live status is the only signal that a contingent has run out: BusPro moves a + // Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the + // response actually carries one, so a response that omits the attribute does not wipe + // the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService(). + if (null !== $availability->status && '' !== trim($availability->status)) { + $service->status = $availability->status; } } diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index 434b2b1..d45126c 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -116,6 +116,14 @@ class IndexController extends AbstractController $this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.'); } + $droppedDraftServiceLabels = $this->dataLoader->getDroppedDraftServiceLabels(); + if ([] !== $droppedDraftServiceLabels) { + $this->addFlash('info', sprintf( + 'Folgende Leistungen aus deinem gespeicherten Entwurf sind inzwischen ausgebucht oder nur auf Anfrage verfügbar und konnten nicht übernommen werden: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.', + implode(', ', $droppedDraftServiceLabels) + )); + } + // Fetch booking data for display (surcharges, canceled status, etc.) $bookingData = $this->dataLoader->fetchBookingData($bookingId, $user); if (null === $bookingData || $bookingData instanceof Notification) { @@ -185,6 +193,13 @@ class IndexController extends AbstractController $this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'); } + if ([] !== $result->unbookableServicesReverted) { + $this->addFlash('info', sprintf( + 'Folgende Leistungen konnten nicht hinzugebucht werden, da sie inzwischen ausgebucht oder nur auf Anfrage verfügbar sind: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.', + implode(', ', $result->unbookableServicesReverted) + )); + } + switch ($result->status) { case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED: $this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden'); diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index ff6d1a4..b1030bc 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) { + // Make readonly if the service is not bookable for this participant + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider } // Make readonly if service is unavailable (only if not already mandatory) - if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) { + if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Make readonly if service is unavailable (only if not already mandatory) if (false === $service->mandatory - && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { + && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return $attributes; } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { + // Make readonly if the service is not bookable for this participant + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Make readonly if service is unavailable (only if not already mandatory) if (false === $service->mandatory - && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) { + && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) { + // Make readonly if the service is not bookable for this participant + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) { + // Make readonly if the service is not bookable for this participant + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; + $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto); } return $attributes; @@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $fieldOptions['attr']['data-description'] = $parkingService->description; } - if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) { + if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) { $fieldOptions['attr']['readonly'] = true; - $fieldOptions['attr']['data-tooltip'] = 'ausgebucht'; + $fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto); } return $fieldOptions; @@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider */ private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool { - if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { - // For non-create workflows, don't apply availability restrictions - return false; - } - return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex); } /** * Determines if a service should be rendered as read-only. * - * This method intelligently handles readonly state for services in both create and edit modes: - * - * - CREATE MODE: Uses existing availability calculator logic - * - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them - * - * This prevents fingerprint false positives in edit mode by allowing participants to keep - * services they already have, even if those services are now fully booked. + * Both modes share one rule set. The already-held carve-out inside + * ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a + * service that is now sold out or on request, so no mode-specific branch is needed here. * * @param Service $service The service to check * @param BookingDto $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant currently selecting services - * @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals') * * @return bool True if the service should be read-only */ - private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool + private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool { - // In CREATE mode, use existing availability logic - if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { - return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex); - } - - // In EDIT mode, apply intelligent readonly logic - // If service is available (available > 0), it's never readonly - if (null !== $service->available && $service->available > 0) { - return false; - } - - // Service is unavailable - check if participant already has it - $participant = $bookingDto->getParticipant($participantIndex); - if (null === $participant) { - return true; // Readonly if no participant data - } - - // Check if participant has this service based on field type - $participantHasService = match ($fieldName) { - 'courses' => $this->hasServiceById($participant->courses, $service->id), - 'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id), - 'board' => $this->hasServiceById($participant->board, $service->id), - 'veg' => $participant->veg?->id === $service->id, - 'rentals' => $this->hasServiceById($participant->rentals, $service->id), - 'skiPass' => $participant->skiPass?->id === $service->id, - 'transportationOutbound' => $participant->transportationOutbound?->id === $service->id, - 'transportationInbound' => $participant->transportationInbound?->id === $service->id, - default => false, - }; - - // Make readonly only if participant doesn't have it - return false === $participantHasService; + return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex); } /** @@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return false; } + /** + * Returns the tooltip explaining why a service is rendered read-only. + * + * Only called for a service that is already known to be blocked, so the status alone + * identifies the reason. Create keeps the pre-existing wording, since the on-request rule + * never fires there. + */ + private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string + { + if (BookingDto::MODE_EDIT === $bookingDto->getMode() + && Constants::STATUS_ON_REQUEST === $service->status) { + return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.'; + } + + return 'ausgebucht'; + } + /** * Filters services based on participant's age constraints. * diff --git a/src/Model/BookingEditSubmissionResult.php b/src/Model/BookingEditSubmissionResult.php index 9317606..2ee7537 100644 --- a/src/Model/BookingEditSubmissionResult.php +++ b/src/Model/BookingEditSubmissionResult.php @@ -14,10 +14,17 @@ final readonly class BookingEditSubmissionResult public const string STATUS_TIMEOUT = 'timeout'; public const string STATUS_API_CLIENT_ERROR = 'api_client_error'; + /** + * @param list $unbookableServicesReverted Labels of services dropped because they are + * no longer bookable, reported separately from + * the mutability revert: the category is still + * mutable, the service is not bookable + */ public function __construct( public string $status, public ?string $message = null, public bool $immutableChangesReverted = false, + public array $unbookableServicesReverted = [], ) { } } diff --git a/src/Service/BookingEditDataLoader.php b/src/Service/BookingEditDataLoader.php index 587f771..948a4c2 100644 --- a/src/Service/BookingEditDataLoader.php +++ b/src/Service/BookingEditDataLoader.php @@ -29,6 +29,9 @@ class BookingEditDataLoader private bool $draftRestored = false; + /** @var list */ + private array $droppedDraftServiceLabels = []; + public function __construct( private readonly ApiClient $apiClient, private readonly BookingDataProcessor $bookingDataProcessor, @@ -53,6 +56,17 @@ class BookingEditDataLoader return $this->draftRestored; } + /** + * Returns the drafted services dropped during the last load because they are no longer + * bookable. Reset on each call to loadFormData(), like isDraftRestored(). + * + * @return list + */ + public function getDroppedDraftServiceLabels(): array + { + return $this->droppedDraftServiceLabels; + } + /** * Loads booking data from session or initializes from API. * @@ -72,6 +86,7 @@ class BookingEditDataLoader public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto { $this->draftRestored = false; + $this->droppedDraftServiceLabels = []; $formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT); @@ -177,6 +192,10 @@ class BookingEditDataLoader // it would tell the user their draft was restored for edits they never made. $this->draftRestored = true === $applied && $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData); + + if (true === $applied) { + $this->droppedDraftServiceLabels = $this->draftService->getDroppedServiceLabels(); + } } $this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); diff --git a/src/Service/BookingEditDraftManager.php b/src/Service/BookingEditDraftManager.php index abbf87a..72a0a5d 100644 --- a/src/Service/BookingEditDraftManager.php +++ b/src/Service/BookingEditDraftManager.php @@ -23,6 +23,9 @@ use Psr\Log\LoggerInterface; */ class BookingEditDraftManager { + /** @var list */ + private array $droppedServiceLabels = []; + public function __construct( private readonly BookingEditDraftRepository $draftRepository, private readonly EntityManagerInterface $entityManager, @@ -32,6 +35,19 @@ class BookingEditDraftManager ) { } + /** + * Returns the services dropped during the last applyDraftToDto() call. + * + * Reset on each call, so the caller can report them to the user. Mirrors + * BookingEditDataLoader::isDraftRestored(). + * + * @return list + */ + public function getDroppedServiceLabels(): array + { + return $this->droppedServiceLabels; + } + /** * Finds an existing draft for a user and booking combination. * @@ -129,6 +145,9 @@ class BookingEditDraftManager */ public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool { + $this->droppedServiceLabels = []; + $droppedServiceLabels = []; + try { $formData = $draft->getFormData(); @@ -149,16 +168,25 @@ class BookingEditDraftManager continue; } - $this->participantApplier->apply( + $droppedServiceLabels = [...$droppedServiceLabels, ...$this->participantApplier->apply( $dto, $index, $dto->participants[$index], $participantData, $travel, - ); + )]; } } + $this->droppedServiceLabels = array_values(array_unique($droppedServiceLabels)); + + if ([] !== $this->droppedServiceLabels) { + $this->logger->info('Dropped drafted services that are no longer bookable', [ + 'booking_id' => $draft->getBookingId(), + 'services' => $this->droppedServiceLabels, + ]); + } + $this->logger->info('Applied draft to booking DTO', [ 'booking_id' => $draft->getBookingId(), 'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'), diff --git a/src/Service/BookingEditDraftMerger.php b/src/Service/BookingEditDraftMerger.php index be77510..990b192 100644 --- a/src/Service/BookingEditDraftMerger.php +++ b/src/Service/BookingEditDraftMerger.php @@ -24,14 +24,24 @@ use App\Form\Model\ParticipantDto; */ class BookingEditDraftMerger { - /** @param array $data */ + public function __construct( + private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, + ) { + } + + /** + * @param array $data + * + * @return list Labels of drafted services dropped because they are no longer bookable + */ public function apply( BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant, array $data, Travel $travel, - ): void { + ): array { + $droppedServiceLabels = []; $canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant); // Personal data @@ -68,10 +78,19 @@ class BookingEditDraftMerger // Service selections (gated per-category by travel mutability flags) if (isset($data['services']) && true === is_array($data['services'])) { - $this->applyServiceSelections($participant, $data['services'], $travel); + $this->applyServiceSelections( + $participant, + $data['services'], + $travel, + $bookingDto, + $participantIndex, + $droppedServiceLabels + ); } $participant->normalizeLoadedData(); + + return array_values(array_unique($droppedServiceLabels)); } private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool @@ -205,16 +224,25 @@ class BookingEditDraftMerger * 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 - { + /** + * @param array $data + * @param list $droppedServiceLabels Collects what was dropped, by reference + */ + private function applyServiceSelections( + ParticipantDto $participant, + array $data, + Travel $travel, + BookingDto $bookingDto, + int $participantIndex, + array &$droppedServiceLabels, + ): 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); + $resolved = $this->resolveService($data['skiPass'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); if (null !== $resolved) { $participant->skiPass = $resolved; } @@ -222,17 +250,17 @@ class BookingEditDraftMerger // 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); + $participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); } // 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); + $participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); } // 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); + $resolved = $this->resolveService($data['veg'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); if (null !== $resolved) { $participant->veg = $resolved; } @@ -240,12 +268,12 @@ class BookingEditDraftMerger // 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); + $participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); } // 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); + $resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); if (null !== $resolved) { $participant->rentalInsurance = $resolved; $participant->rentalInsuranceSelected = true; @@ -255,7 +283,7 @@ class BookingEditDraftMerger // 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); + $resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels); $participant->additionalServices = $this->preserveMandatoryServices( $resolvedFromDraft, $participant->additionalServices, @@ -268,7 +296,7 @@ class BookingEditDraftMerger 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); + $resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels); if (null !== $resolved) { $participant->transportationOutbound = $resolved; } @@ -276,7 +304,7 @@ class BookingEditDraftMerger // 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); + $resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels); if (null !== $resolved) { $participant->transportationInbound = $resolved; } @@ -368,37 +396,84 @@ class BookingEditDraftMerger /** * @param array $services + * @param list $droppedServiceLabels */ - private function resolveService(?int $serviceId, array $services): ?object - { + private function resolveService( + ?int $serviceId, + array $services, + BookingDto $bookingDto, + int $participantIndex, + array &$droppedServiceLabels, + ): ?object { if (null === $serviceId) { return null; } - return $services[$serviceId] ?? null; + $service = $services[$serviceId] ?? null; + + if (null !== $service && true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) { + $droppedServiceLabels[] = (string) $service->label; + + return null; + } + + return $service; } /** * @param array $serviceIds * @param array $services + * @param list $droppedServiceLabels * * @return array */ - private function resolveServiceArray(array $serviceIds, array $services): array - { + private function resolveServiceArray( + array $serviceIds, + array $services, + BookingDto $bookingDto, + int $participantIndex, + array &$droppedServiceLabels, + ): array { $resolved = []; $addedIds = []; foreach ($serviceIds as $serviceId) { - if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) { - $resolved[] = $services[$serviceId]; - $addedIds[$serviceId] = true; + if (false === isset($services[$serviceId]) || true === isset($addedIds[$serviceId])) { + continue; } + + $service = $services[$serviceId]; + + if (true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) { + $droppedServiceLabels[] = (string) $service->label; + $addedIds[$serviceId] = true; + continue; + } + + $resolved[] = $service; + $addedIds[$serviceId] = true; } return $resolved; } + /** + * Checks whether a drafted selection must not be restored. + * + * A draft can be weeks old. Restoring a selection for a service that has since sold out or + * moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice is re-applied on + * every re-entry and BusPro rejects the whole update again. Services the participant already + * holds are carved out by the availability rule itself, so they still restore. + */ + private function isDroppedFromDraft(object $service, BookingDto $bookingDto, int $participantIndex): bool + { + if (false === $service instanceof Service || null === $service->id) { + return false; + } + + return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex); + } + private function resolvePickup(?int $pickupId, Travel $travel): ?object { if (null === $pickupId) { diff --git a/src/Service/BookingEditSubmitGuard.php b/src/Service/BookingEditSubmitGuard.php index 89db821..7b711ae 100644 --- a/src/Service/BookingEditSubmitGuard.php +++ b/src/Service/BookingEditSubmitGuard.php @@ -24,9 +24,72 @@ class BookingEditSubmitGuard { public function __construct( private readonly BookingDataProcessor $bookingDataProcessor, + private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, ) { } + /** + * Reverts selections of services that are no longer bookable. + * + * The read-only state in the participant form is presentation only, so a stale session or + * a replayed POST can still carry a service that has since sold out or moved to 'Anfrage'. + * Sending one makes BusPro reject the entire update - every participant's changes with it - + * so the selection is reverted to what the live booking already holds. + * + * Services the participant already holds are never touched: the availability rule carves + * them out, and withdrawing one would break the Leistung/Teilnehmer counts. + * + * Expects $workingDto->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. * diff --git a/src/Service/BookingEditSubmitter.php b/src/Service/BookingEditSubmitter.php index b122b68..b316e04 100644 --- a/src/Service/BookingEditSubmitter.php +++ b/src/Service/BookingEditSubmitter.php @@ -63,11 +63,32 @@ class BookingEditSubmitter $this->travelDataService->patchMutability($bookingDto->travel, $mutableData); } + // The guard checks service status and contingent, so it needs the live figures rather + // than whatever was cached when the edit session started. + $availabilities = $this->travelDataService->getAvailabilityData( + $freshBookingData->dateId, + cached: true, + forceRefresh: true + ); + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities); + } + $immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData); - if (true === $immutableChangesReverted) { + $unbookableServicesReverted = $this->submitGuard->revertUnbookableServiceAdditions($bookingDto, $freshBookingData); + + if (true === $immutableChangesReverted || [] !== $unbookableServicesReverted) { $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); } + if ([] !== $unbookableServicesReverted) { + $this->logger->warning('Reverted selections of services that are no longer bookable', [ + 'email' => $email, + 'booking_id' => $bookingId, + 'services' => $unbookableServicesReverted, + ]); + } + try { $response = $this->apiClient->updateBooking($bookingDto, true); if ($response instanceof Notification) { @@ -83,6 +104,7 @@ class BookingEditSubmitter : BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO, $response->message, $immutableChangesReverted, + $unbookableServicesReverted, ); } @@ -99,6 +121,7 @@ class BookingEditSubmitter BookingEditSubmissionResult::STATUS_SUCCESS, null, $immutableChangesReverted, + $unbookableServicesReverted, ); } @@ -113,6 +136,7 @@ class BookingEditSubmitter BookingEditSubmissionResult::STATUS_UNSUCCESSFUL, $response->status ?? 'Buchung konnte nicht aktualisiert werden', $immutableChangesReverted, + $unbookableServicesReverted, ); } catch (TimeoutException) { $this->logger->error('Booking update timeout', [ @@ -124,12 +148,14 @@ class BookingEditSubmitter BookingEditSubmissionResult::STATUS_TIMEOUT, null, $immutableChangesReverted, + $unbookableServicesReverted, ); } catch (ApiClientException) { return new BookingEditSubmissionResult( BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR, null, $immutableChangesReverted, + $unbookableServicesReverted, ); } } diff --git a/src/Service/ServiceAvailabilityCalculator.php b/src/Service/ServiceAvailabilityCalculator.php index f4abd01..cd85ce0 100644 --- a/src/Service/ServiceAvailabilityCalculator.php +++ b/src/Service/ServiceAvailabilityCalculator.php @@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator * * Delegates to isServiceUnavailable() so that both entry points share one rule set. * + * Never empties a non-empty choice group on the on-request rule alone: on some termine + * every outbound transport option or every ski pass is 'Anfrage', and an empty required + * group makes the travel unbookable - ParticipantEligibilityChecker reads "offered but + * none selectable" as the participant being unable to travel. Sold-out and Buchungsstop + * still empty a group, preserving the pre-existing semantics. Inert in create mode, where + * the on-request rule cannot fire. + * * @param array $services Array of Service objects to filter * @param BookingDto $bookingDto The booking data with participant selections * @param int $participantIndex The index of the participant currently filling the form @@ -63,7 +70,7 @@ class ServiceAvailabilityCalculator */ public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array { - return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) { + $filtered = array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) { // Services without an ID cannot be resolved against travel data - treat as available if (null === $service->id) { return true; @@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex); }); + + if ([] !== $filtered || [] === $services) { + return $filtered; + } + + return array_filter( + $services, + fn (Service $service): bool => $this->isBlockedByOnRequestStatus($service, $bookingDto) + && Constants::STATUS_BLOCKED !== $service->status + && false === $this->isContingentExhausted($service, $bookingDto, $participantIndex) + ); } /** @@ -80,6 +98,16 @@ class ServiceAvailabilityCalculator * contingent is exhausted, either at the API level or through selections made by the * other participants of the current booking. * + * In edit mode one further rule applies: a service that is only available on request + * (Anfrage) cannot be *acquired*. The participants of an existing booking are fixed at + * status 'F', and BusPro derives a Leistung's status from the travel data and refuses to + * attach it when the two differ ("Status der Leistung (A) ist unterschiedlich zum Status + * des Teilnehmers (F)"), rejecting the whole update. The create flow has no such problem, + * because the booking status can still move to 'A' there - hence the mode precondition. + * + * The rule is asymmetric: a service the participant already holds in the live booking + * stays available, so it can be kept and re-sent. + * * @param int $serviceId The ID of the service to check * @param BookingDto $bookingDto The booking data with participant selections * @param int $participantIndex The index of the participant currently filling the form @@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator return false; } + // A service the participant already holds is always keepable, whatever its state now + if (true === $this->isServiceHeldByParticipant($service, $bookingDto, $participantIndex)) { + return false; + } + // A booking stop blocks the service regardless of its contingent if (Constants::STATUS_BLOCKED === $service->status) { return true; } + // On request cannot be added to a participant whose status is already fixed + if (true === $this->isBlockedByOnRequestStatus($service, $bookingDto)) { + return true; + } + + return $this->isContingentExhausted($service, $bookingDto, $participantIndex); + } + + /** + * Checks whether a service has no contingent left. + * + * Covers both the API-level figure and the seats taken by the other participants of the + * current booking. + */ + private function isContingentExhausted(Service $service, BookingDto $bookingDto, int $participantIndex): bool + { // If no availability tracking (null), service is unlimited and available if (null === $service->available) { return false; @@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator // For services with positive availability, calculate remaining based on booking selections $remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex); - return ($remainingAvailability[$serviceId] ?? $service->available) <= 0; + return ($remainingAvailability[$service->id] ?? $service->available) <= 0; + } + + /** + * Checks whether the on-request rule blocks a service. + * + * Only applies in edit mode, and never to services the form would then be unable to + * satisfy: auto-booked services and mandatory services outside the transport categories + * are required by MandatoryAdditionalServicesSelectedValidator, so blocking one makes the + * form unsatisfiable. Mandatory transport legs are deliberately not exempt - there + * pflicht="True" means "pick one of this group" rather than "compulsory", and exempting + * them would exempt the bus legs this rule exists for. + */ + private function isBlockedByOnRequestStatus(Service $service, BookingDto $bookingDto): bool + { + if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) { + return false; + } + + if (Constants::STATUS_ON_REQUEST !== $service->status) { + return false; + } + + if (true === $service->autoBook) { + return false; + } + + return false === ($service->mandatory && Constants::CATEGORY_TRANSPORTATION !== $service->category); + } + + /** + * Checks whether the participant already holds the service in the live booking. + * + * Reads the BusPro-side baseline (BookingDto::$booking), never the working selection, + * which already carries whatever the customer just picked. Always false in create mode, + * where there is no booking yet. + */ + private function isServiceHeldByParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool + { + if (null === $bookingDto->booking || null === $service->id) { + return false; + } + + return $bookingDto->booking->hasServiceForParticipant($participantIndex, $service->id); } /** diff --git a/tests/BusProNet/XmlLoader/TravelLoaderAvailabilityTest.php b/tests/BusProNet/XmlLoader/TravelLoaderAvailabilityTest.php new file mode 100644 index 0000000..a7d17ea --- /dev/null +++ b/tests/BusProNet/XmlLoader/TravelLoaderAvailabilityTest.php @@ -0,0 +1,141 @@ +loader = new TravelLoader( + $this->createStub(HotelLoader::class), + $this->createStub(TravelParser::class), + 'https://example.test', + $this->createStub(CacheInterface::class), + $this->createStub(FilesystemOperator::class), + ); + } + + public function testPatchesStatusAlongsideAvailability(): void + { + $service = $this->createService(60, Constants::STATUS_AVAILABLE, 10); + $travel = $this->createTravel([$service]); + + $this->loader->patchAvailabilities( + $travel, + $this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)]) + ); + + $this->assertSame(Constants::STATUS_ON_REQUEST, $service->status); + $this->assertSame(3, $service->available); + } + + public function testKeepsExportStatusWhenTheResponseOmitsIt(): void + { + $service = $this->createService(60, Constants::STATUS_AVAILABLE, 10); + $travel = $this->createTravel([$service]); + + $this->loader->patchAvailabilities( + $travel, + $this->createResponse([$this->createAvailability(60, null, 3)]) + ); + + $this->assertSame(Constants::STATUS_AVAILABLE, $service->status); + $this->assertSame(3, $service->available); + } + + public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void + { + $service = $this->createService(60, Constants::STATUS_AVAILABLE, 10); + $travel = $this->createTravel([$service]); + + $this->loader->patchAvailabilities( + $travel, + $this->createResponse([$this->createAvailability(60, ' ', 3)]) + ); + + $this->assertSame(Constants::STATUS_AVAILABLE, $service->status); + } + + public function testPatchesTransportationServicesToo(): void + { + $service = $this->createService(80, Constants::STATUS_AVAILABLE, 10); + $travel = new Travel(); + $travel->transportationServices = [80 => $service]; + + $this->loader->patchAvailabilities( + $travel, + $this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)]) + ); + + $this->assertSame(Constants::STATUS_BLOCKED, $service->status); + $this->assertSame(0, $service->available); + } + + /** @param array $services */ + private function createTravel(array $services): Travel + { + $travel = new Travel(); + + $indexed = []; + foreach ($services as $service) { + $indexed[$service->id] = $service; + } + $travel->additionalServices = $indexed; + + return $travel; + } + + private function createService(int $id, string $status, ?int $available): Service + { + $service = new Service(); + $service->id = $id; + $service->status = $status; + $service->available = $available; + + return $service; + } + + private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability + { + $availability = new Availability(); + $availability->serviceId = $serviceId; + $availability->status = $status; + $availability->available = $available; + + return $availability; + } + + /** @param array $availabilities */ + private function createResponse(array $availabilities): ServiceAvailabilityResponse + { + $indexed = []; + foreach ($availabilities as $availability) { + $indexed[$availability->serviceId] = $availability; + } + + return new ServiceAvailabilityResponse($indexed); + } +} diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderEditAvailabilityTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderEditAvailabilityTest.php new file mode 100644 index 0000000..4d28571 --- /dev/null +++ b/tests/Form/Service/ParticipantFieldOptionsProviderEditAvailabilityTest.php @@ -0,0 +1,174 @@ +createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $this->provider = new ParticipantFieldOptionsProvider( + new ServiceAvailabilityCalculator(), + $this->createStub(InsuranceManager::class), + $this->createStub(BookingPriceCalculator::class), + new ServiceLabelFormatter(), + $translator + ); + } + + public function testOnRequestCourseIsReadonlyInEditMode(): void + { + $course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST); + $bookingDto = $this->createEditBookingDto([$course]); + + $attributes = $this->getChoiceAttributes($bookingDto, $course); + + $this->assertArrayHasKey('readonly', $attributes); + $this->assertSame( + 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.', + $attributes['data-tooltip'] + ); + } + + public function testSoldOutCourseIsReadonlyInEditMode(): void + { + $course = $this->createCourse(id: 1, available: 0); + $bookingDto = $this->createEditBookingDto([$course]); + + $attributes = $this->getChoiceAttributes($bookingDto, $course); + + $this->assertArrayHasKey('readonly', $attributes); + $this->assertSame('ausgebucht', $attributes['data-tooltip']); + } + + public function testOnRequestCourseStaysSelectableWhenAlreadyBooked(): void + { + $course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST); + $bookingDto = $this->createEditBookingDto([$course], heldServiceIds: [1]); + + $attributes = $this->getChoiceAttributes($bookingDto, $course); + + $this->assertArrayNotHasKey('readonly', $attributes); + $this->assertArrayNotHasKey('data-tooltip', $attributes); + } + + public function testUnlimitedCourseIsNotReadonlyInEditMode(): void + { + // A null contingent means unlimited. The previous edit branch inverted this and made + // such a service read-only for anyone who did not already hold it. + $course = $this->createCourse(id: 1, available: null); + $bookingDto = $this->createEditBookingDto([$course]); + + $attributes = $this->getChoiceAttributes($bookingDto, $course); + + $this->assertArrayNotHasKey('readonly', $attributes); + } + + public function testOnRequestCourseStaysSelectableInCreateMode(): void + { + $course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST); + $bookingDto = $this->createBookingDto([$course]); + + $this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode()); + + $attributes = $this->getChoiceAttributes($bookingDto, $course); + + $this->assertArrayNotHasKey('readonly', $attributes); + } + + /** @return array */ + private function getChoiceAttributes(BookingDto $bookingDto, Service $service): array + { + $options = $this->provider->getFieldOptions('courses', $bookingDto, 0); + + return $options['choice_attr']($service); + } + + /** @param Service[] $courses */ + private function createBookingDto(array $courses): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2027-01-09'); + $travel->dateTo = new \DateTimeImmutable('2027-01-16'); + + foreach ($courses as $course) { + $travel->additionalServices[$course->id] = $course; + } + + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + $bookingDto->participants[0] = $participant; + + return $bookingDto; + } + + /** + * @param Service[] $courses + * @param list $heldServiceIds IDs participant 0 already holds in the booking + */ + private function createEditBookingDto(array $courses, array $heldServiceIds = []): BookingDto + { + $bookingDto = $this->createBookingDto($courses); + + $heldServices = []; + foreach ($heldServiceIds as $serviceId) { + $held = clone $bookingDto->travel->additionalServices[$serviceId]; + $held->mapping = [0]; + $heldServices[$serviceId] = $held; + } + + // A non-null booking is what puts the DTO into edit mode + $booking = new Booking(); + $booking->additionalServices = $heldServices; + $bookingDto->booking = $booking; + + return $bookingDto; + } + + private function createCourse(int $id, ?int $available = 10, string $status = Constants::STATUS_AVAILABLE): Service + { + $service = new Service(); + $service->id = $id; + $service->label = sprintf('Kurs %d', $id); + $service->subType = Constants::TOKEN_COURSES; + $service->category = Constants::CATEGORY_ADDITIONAL; + $service->price = (float) $id; + $service->available = $available; + $service->status = $status; + + return $service; + } +} diff --git a/tests/Service/BookingEditDraftManagerMutabilityTest.php b/tests/Service/BookingEditDraftManagerMutabilityTest.php index 382ee4b..5478a5b 100644 --- a/tests/Service/BookingEditDraftManagerMutabilityTest.php +++ b/tests/Service/BookingEditDraftManagerMutabilityTest.php @@ -16,6 +16,7 @@ use App\Repository\BookingEditDraftRepository; use App\Service\BookingChangeTracker; use App\Service\BookingEditDraftManager; use App\Service\BookingEditDraftMerger; +use App\Service\ServiceAvailabilityCalculator; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -37,7 +38,7 @@ class BookingEditDraftManagerMutabilityTest extends TestCase $this->createStub(BookingEditDraftRepository::class), $this->createStub(EntityManagerInterface::class), $this->createStub(BookingChangeTracker::class), - new BookingEditDraftMerger(), + new BookingEditDraftMerger(new ServiceAvailabilityCalculator()), new NullLogger(), ); } diff --git a/tests/Service/BookingEditDraftMergerRoomAssignmentTest.php b/tests/Service/BookingEditDraftMergerRoomAssignmentTest.php index ba88652..ab4d112 100644 --- a/tests/Service/BookingEditDraftMergerRoomAssignmentTest.php +++ b/tests/Service/BookingEditDraftMergerRoomAssignmentTest.php @@ -9,6 +9,7 @@ use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Service\BookingEditDraftMerger; +use App\Service\ServiceAvailabilityCalculator; use PHPUnit\Framework\TestCase; /** @@ -26,7 +27,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase protected function setUp(): void { - $this->merger = new BookingEditDraftMerger(); + $this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator()); } public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void diff --git a/tests/Service/BookingEditDraftMergerServiceAvailabilityTest.php b/tests/Service/BookingEditDraftMergerServiceAvailabilityTest.php new file mode 100644 index 0000000..5d99476 --- /dev/null +++ b/tests/Service/BookingEditDraftMergerServiceAvailabilityTest.php @@ -0,0 +1,142 @@ +merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator()); + } + + public function testDraftDropsSkiPassThatIsNowOnRequest(): void + { + $skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST); + $travel = $this->createTravel([60 => $skiPass]); + + $participant = new ParticipantDto(); + $participant->mutable = true; + + $dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking()); + + $this->assertNull($participant->skiPass); + $this->assertSame(['Skipass 6 Tage'], $dropped); + } + + public function testDraftRestoresSkiPassThatIsStillFree(): void + { + $skiPass = $this->createSkiPass(60, 'Skipass 6 Tage'); + $travel = $this->createTravel([60 => $skiPass]); + + $participant = new ParticipantDto(); + $participant->mutable = true; + + $dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking()); + + $this->assertSame(60, $participant->skiPass?->id); + $this->assertSame([], $dropped); + } + + public function testDraftRestoresOnRequestSkiPassTheParticipantAlreadyHolds(): void + { + $skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST); + $travel = $this->createTravel([60 => $skiPass]); + + $held = clone $skiPass; + $held->mapping = [1]; + $booking = new Booking(); + $booking->additionalServices = [60 => $held]; + + $participant = new ParticipantDto(); + $participant->mutable = true; + + $dropped = $this->apply($travel, $participant, ['skiPass' => 60], $booking); + + $this->assertSame(60, $participant->skiPass?->id); + $this->assertSame([], $dropped); + } + + public function testDraftDropsOnlyTheUnbookableEntryOfAMultiSelectField(): void + { + $bookable = $this->createCourse(70, 'Snowboardkurs'); + $onRequest = $this->createCourse(71, 'Skikurs', Constants::STATUS_ON_REQUEST); + $travel = $this->createTravel([70 => $bookable, 71 => $onRequest]); + + $participant = new ParticipantDto(); + $participant->mutable = true; + + $dropped = $this->apply($travel, $participant, ['courses' => [70, 71]], new Booking()); + + $this->assertSame([70], array_map(static fn (Service $s) => $s->id, $participant->courses)); + $this->assertSame(['Skikurs'], $dropped); + } + + /** + * @param array $services + * + * @return list + */ + private function apply(Travel $travel, ParticipantDto $participant, array $services, Booking $booking): array + { + $dto = new BookingDto($travel, 1); + $dto->participants = [1 => $participant]; + // A non-null booking is what puts the DTO into edit mode + $dto->booking = $booking; + + return $this->merger->apply($dto, 1, $participant, ['services' => $services], $travel); + } + + /** @param array $additionalServices */ + private function createTravel(array $additionalServices): Travel + { + $travel = new Travel(); + $travel->additionalServices = $additionalServices; + $travel->additionalServicesMutable = true; + + return $travel; + } + + private function createSkiPass(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service + { + return $this->createService($id, $label, Constants::TOKEN_SKI_PASS, $status); + } + + private function createCourse(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service + { + return $this->createService($id, $label, Constants::TOKEN_COURSES, $status); + } + + private function createService(int $id, string $label, string $subType, string $status): Service + { + $service = new Service(); + $service->id = $id; + $service->label = $label; + $service->subType = $subType; + $service->status = $status; + $service->category = Constants::CATEGORY_ADDITIONAL; + + return $service; + } +} diff --git a/tests/Service/BookingEditSubmitGuardTest.php b/tests/Service/BookingEditSubmitGuardTest.php index c9899b7..b426aaf 100644 --- a/tests/Service/BookingEditSubmitGuardTest.php +++ b/tests/Service/BookingEditSubmitGuardTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Service; +use App\BusProNet\Constants; use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\Model\Booking; use App\BusProNet\Model\Pickup; @@ -12,6 +13,7 @@ use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Service\BookingEditSubmitGuard; +use App\Service\ServiceAvailabilityCalculator; use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; @@ -54,7 +56,7 @@ class BookingEditSubmitGuardTest extends TestCase ->method('createBookingDtoFromBooking') ->willReturn($baselineDto); - $service = new BookingEditSubmitGuard($processor); + $service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator()); $changed = $service->reconcileImmutableCategories($workingDto, new Booking()); @@ -91,7 +93,7 @@ class BookingEditSubmitGuardTest extends TestCase ->method('createBookingDtoFromBooking') ->willReturn($baselineDto); - $service = new BookingEditSubmitGuard($processor); + $service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator()); $changed = $service->reconcileImmutableCategories($workingDto, new Booking()); @@ -128,7 +130,7 @@ class BookingEditSubmitGuardTest extends TestCase ->method('createBookingDtoFromBooking') ->willReturn($baselineDto); - $service = new BookingEditSubmitGuard($processor); + $service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator()); $changed = $service->reconcileImmutableCategories($workingDto, new Booking()); @@ -136,6 +138,85 @@ class BookingEditSubmitGuardTest extends TestCase $this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals)); } + public function testRevertUnbookableServiceAdditionsDropsOnRequestAddition(): void + { + $onRequest = $this->createService(99); + $onRequest->label = 'Bus-Hinfahrt'; + $onRequest->subType = Constants::TOKEN_SKI_PASS; + $onRequest->status = Constants::STATUS_ON_REQUEST; + + $travel = new Travel(); + $travel->additionalServices = [99 => $onRequest]; + + $workingParticipant = new ParticipantDto(); + $workingParticipant->index = 0; + $workingParticipant->skiPass = $onRequest; + + $freshBooking = new Booking(); + + $workingDto = new BookingDto($travel, 1); + $workingDto->participants = [$workingParticipant]; + $workingDto->booking = $freshBooking; + + $baselineParticipant = new ParticipantDto(); + $baselineParticipant->index = 0; + + $baselineDto = new BookingDto($travel, 1); + $baselineDto->participants = [$baselineParticipant]; + + $processor = $this->createStub(BookingDataProcessor::class); + $processor->method('createBookingDtoFromBooking')->willReturn($baselineDto); + + $guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator()); + + $reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking); + + $this->assertSame(['Bus-Hinfahrt'], $reverted); + $this->assertNull($workingParticipant->skiPass); + } + + public function testRevertUnbookableServiceAdditionsKeepsAServiceTheParticipantAlreadyHolds(): void + { + $onRequest = $this->createService(99); + $onRequest->label = 'Bus-Hinfahrt'; + $onRequest->subType = Constants::TOKEN_SKI_PASS; + $onRequest->status = Constants::STATUS_ON_REQUEST; + + $travel = new Travel(); + $travel->additionalServices = [99 => $onRequest]; + + $held = clone $onRequest; + $held->mapping = [0]; + + $freshBooking = new Booking(); + $freshBooking->additionalServices = [99 => $held]; + + $workingParticipant = new ParticipantDto(); + $workingParticipant->index = 0; + $workingParticipant->skiPass = $onRequest; + + $workingDto = new BookingDto($travel, 1); + $workingDto->participants = [$workingParticipant]; + $workingDto->booking = $freshBooking; + + $baselineParticipant = new ParticipantDto(); + $baselineParticipant->index = 0; + $baselineParticipant->skiPass = $held; + + $baselineDto = new BookingDto($travel, 1); + $baselineDto->participants = [$baselineParticipant]; + + $processor = $this->createStub(BookingDataProcessor::class); + $processor->method('createBookingDtoFromBooking')->willReturn($baselineDto); + + $guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator()); + + $reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking); + + $this->assertSame([], $reverted); + $this->assertSame($onRequest, $workingParticipant->skiPass); + } + private function createService(int $id): Service { $service = new Service(); diff --git a/tests/Service/ServiceAvailabilityCalculatorTest.php b/tests/Service/ServiceAvailabilityCalculatorTest.php index d319335..6630167 100644 --- a/tests/Service/ServiceAvailabilityCalculatorTest.php +++ b/tests/Service/ServiceAvailabilityCalculatorTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Service; use App\BusProNet\Constants; +use App\BusProNet\Model\Booking; use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; @@ -85,6 +86,95 @@ class ServiceAvailabilityCalculatorTest extends TestCase $this->assertSame([1, 4], array_keys($filtered)); } + public function testOnRequestServiceStaysAvailableInCreateMode(): void + { + $skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); + $bookingDto = $this->createBookingDtoWithServices([$skiPass]); + + // Create may still move the whole booking to status 'A', so on request is bookable there + $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testOnRequestServiceIsUnavailableInEditMode(): void + { + $skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); + $bookingDto = $this->createEditBookingDtoWithServices([$skiPass]); + + $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testOnRequestServiceAlreadyHeldStaysAvailableInEditMode(): void + { + $skiPass = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST); + $bookingDto = $this->createEditBookingDtoWithServices([$skiPass], heldServiceIds: [1]); + + // Withdrawing a booked service breaks the Leistung/Teilnehmer counts, so it must be keepable + $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void + { + $service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); + $service->autoBook = true; + $bookingDto = $this->createEditBookingDtoWithServices([$service]); + + $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testMandatoryNonTransportOnRequestServiceStaysAvailableInEditMode(): void + { + $service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); + $service->mandatory = true; + $service->category = Constants::CATEGORY_ADDITIONAL; + $bookingDto = $this->createEditBookingDtoWithServices([$service]); + + // Blocking a service the mandatory-services validator requires makes the form unsatisfiable + $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testMandatoryTransportOnRequestServiceIsStillUnavailableInEditMode(): void + { + $service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); + $service->mandatory = true; + $service->category = Constants::CATEGORY_TRANSPORTATION; + $bookingDto = $this->createEditBookingDtoWithServices([$service]); + + // pflicht on a transport leg means "pick one of this group", not "compulsory" + $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); + } + + public function testFilterAvailableServicesNeverEmptiesAGroupOnTheOnRequestRuleAlone(): void + { + $first = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); + $second = $this->createSkiPass(id: 2, available: 10, status: Constants::STATUS_ON_REQUEST); + + $bookingDto = $this->createEditBookingDtoWithServices([$first, $second]); + + $filtered = $this->calculator->filterAvailableServices( + $bookingDto->travel->additionalServices, + $bookingDto, + 0 + ); + + $this->assertSame([1, 2], array_keys($filtered)); + } + + public function testFilterAvailableServicesStillEmptiesAGroupThatIsSoldOutOrBlocked(): void + { + $soldOut = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST); + $blocked = $this->createSkiPass(id: 2, available: null, status: Constants::STATUS_BLOCKED); + + $bookingDto = $this->createEditBookingDtoWithServices([$soldOut, $blocked]); + + $filtered = $this->calculator->filterAvailableServices( + $bookingDto->travel->additionalServices, + $bookingDto, + 0 + ); + + $this->assertSame([], array_keys($filtered)); + } + private function createParkingService(int $id, int $available): Service { $service = new Service(); @@ -123,4 +213,27 @@ class ServiceAvailabilityCalculatorTest extends TestCase return $bookingDto; } + + /** + * @param array $services + * @param list $heldServiceIds IDs participant 0 already holds in the booking + */ + private function createEditBookingDtoWithServices(array $services, array $heldServiceIds = []): BookingDto + { + $bookingDto = $this->createBookingDtoWithServices($services); + + $heldServices = []; + foreach ($heldServiceIds as $serviceId) { + $held = clone $bookingDto->travel->additionalServices[$serviceId]; + $held->mapping = [0]; + $heldServices[$serviceId] = $held; + } + + // A non-null booking is what puts the DTO into edit mode + $booking = new Booking(); + $booking->additionalServices = $heldServices; + $bookingDto->booking = $booking; + + return $bookingDto; + } }