From b958d1a3716ea720be593436629109f0069f2dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 13 Oct 2026 16:45:55 +0200 Subject: [PATCH] feat: offer non-travelling applicant choice only on selected travels --- src/BusProNet/Model/Travel.php | 15 +++ .../Booking/Create/Step2Controller.php | 6 + .../Create/Step2ParticipantController.php | 16 +-- src/Form/Model/BookingDto.php | 20 +++ templates/booking/create/step_2.html.twig | 39 +++--- tests/BusProNet/Model/TravelTest.php | 21 +++ .../Booking/Create/Step2ControllerTest.php | 60 +++++++++ .../Create/Step2ParticipantControllerTest.php | 127 ++++++++++++++++++ tests/Form/Model/BookingDtoTest.php | 42 ++++++ 9 files changed, 320 insertions(+), 26 deletions(-) create mode 100644 tests/Controller/Booking/Create/Step2ParticipantControllerTest.php diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 1aa07e52..a3e6a9dd 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -20,6 +20,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Travel { use SortByPriceTrait; + + /** + * CRM selection that offers the "I am not travelling myself" choice on a travel date. + */ + private const NON_TRAVELLING_APPLICANT_SELECTION_ID = 1488; + #[Groups(['api:list', 'api:single', 'snapshot'])] public ?int $id = null; @@ -240,6 +246,15 @@ class Travel return false; } + /** + * Whether a booking for this travel date may be placed by an applicant who does not travel + * along, such as a parent booking for their children. + */ + public function allowsNonTravellingApplicant(): bool + { + return $this->hasSelectionId(self::NON_TRAVELLING_APPLICANT_SELECTION_ID); + } + /** * Retrieves rooms filtered by their IDs. * diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index cb4465ea..a012cbef 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -83,6 +83,12 @@ class Step2Controller extends AbstractBookingCreateController $this->ensureCorrectNumberOfParticipants($bookingCreateDto); + // A session can still hold a split for a travel date that does not offer the choice. + if (false === $bookingCreateDto->applicantTravels + && false === $bookingCreateDto->travel->allowsNonTravellingApplicant()) { + $bookingCreateDto->restoreTravellingApplicant(); + } + // The account's own data belongs to whoever is the applicant: participant[0] normally, // and the separate applicant record when they do not travel along. $user = $this->getUser(); diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php index 69e75532..f08c06cc 100644 --- a/src/Controller/Booking/Create/Step2ParticipantController.php +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -139,6 +139,8 @@ class Step2ParticipantController extends AbstractController * Toggling on moves participant[0]'s personal data into the applicant record and leaves * behind a blank traveller that keeps only its index and room assignment: the services were * derived from an age that no longer applies. Toggling off moves it back. + * + * Only offered where the travel date allows it; elsewhere the request changes nothing. */ #[Route( path: '/bookings/create/applicant-toggle', @@ -153,6 +155,10 @@ class Step2ParticipantController extends AbstractController } $bookingDto = $result; + if (false === $bookingDto->travel->allowsNonTravellingApplicant()) { + return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2')); + } + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); // Captured before handleRequest(), which binds the new choice straight onto the DTO. @@ -176,13 +182,7 @@ class Step2ParticipantController extends AbstractController $bookingDto->participants[0] = $this->participantFormSupportService ->createBlankTravellerFrom($bookingDto->applicant); } else { - $restored = $bookingDto->applicant; - if (null !== $restored) { - $restored->index = 0; - $restored->assignedRoomId = $bookingDto->participants[0]->assignedRoomId; - $bookingDto->participants[0] = $restored; - } - $bookingDto->applicant = null; + $bookingDto->restoreTravellingApplicant(); } // Family insurance is not offered on a split booking, so a choice already made on @@ -221,7 +221,7 @@ class Step2ParticipantController extends AbstractController } $bookingDto = $result; - if (true === $bookingDto->applicantTravels) { + if (true === $bookingDto->applicantTravels || false === $bookingDto->travel->allowsNonTravellingApplicant()) { return $this->redirectToRoute('app_booking_create_step_2'); } diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index c99d694e..1af5a117 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -193,6 +193,26 @@ class BookingDto return $this->participants[$index] ?? null; } + /** + * Ends a split: the applicant takes the participant[0] slot back and travels again. + * + * The traveller who held the slot is dropped; only the room assignment carries over, + * the mirror image of what the split left behind. + */ + public function restoreTravellingApplicant(): void + { + $restored = $this->applicant; + + if (null !== $restored) { + $restored->index = 0; + $restored->assignedRoomId = ($this->participants[0] ?? null)?->assignedRoomId; + $this->participants[0] = $restored; + } + + $this->applicant = null; + $this->applicantTravels = true; + } + /** * Whether participant[0] is also the applicant. * diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig index 290a7d6e..c7d2fe30 100644 --- a/templates/booking/create/step_2.html.twig +++ b/templates/booking/create/step_2.html.twig @@ -54,25 +54,28 @@ {% endif %} {% set bookingDto = bookingCreateContext.bookingDto %} - {# No form_start here: this sits inside the step 2 form and nested - form tags are invalid. htmx posts the inputs enclosed below, - including the CSRF token, to the toggle route. #} -
- {{ form_row(applicantTravelsForm.applicantTravels) }} - {{ form_rest(applicantTravelsForm) }} + {# The choice is only offered where the travel date allows it. #} + {% if bookingDto.travel.allowsNonTravellingApplicant %} + {# No form_start here: this sits inside the step 2 form and nested + form tags are invalid. htmx posts the inputs enclosed below, + including the CSRF token, to the toggle route. #} +
+ {{ form_row(applicantTravelsForm.applicantTravels) }} + {{ form_rest(applicantTravelsForm) }} - {% if not bookingDto.applicantTravels %} - {% include 'booking/create/_applicant_card.html.twig' with { - 'applicant': bookingDto.applicant, - 'isSubmitted': bookingCreateContext.isSubmitted, - 'applicantErrors': applicantErrors - } %} - {% endif %} -
+ {% if not bookingDto.applicantTravels %} + {% include 'booking/create/_applicant_card.html.twig' with { + 'applicant': bookingDto.applicant, + 'isSubmitted': bookingCreateContext.isSubmitted, + 'applicantErrors': applicantErrors + } %} + {% endif %} +
+ {% endif %}
{% for cardData in bookingCreateContext.cardsData %} diff --git a/tests/BusProNet/Model/TravelTest.php b/tests/BusProNet/Model/TravelTest.php index eda33c09..244d83ee 100644 --- a/tests/BusProNet/Model/TravelTest.php +++ b/tests/BusProNet/Model/TravelTest.php @@ -134,6 +134,27 @@ class TravelTest extends TestCase $this->assertFalse($travel->hasSelectionId(1473)); } + public function testAllowsNonTravellingApplicantWithSelection1488(): void + { + $travel = new Travel(); + $travel->selectionGroups = [ + 10 => $this->createSelectionGroup(10, [2001, 1488]), + ]; + + $this->assertTrue($travel->allowsNonTravellingApplicant()); + } + + public function testDoesNotAllowNonTravellingApplicantWithoutSelection1488(): void + { + $travel = new Travel(); + $travel->selectionGroups = [ + 10 => $this->createSelectionGroup(10, [1473, 1475]), + ]; + + $this->assertFalse($travel->allowsNonTravellingApplicant()); + $this->assertFalse((new Travel())->allowsNonTravellingApplicant(), 'A travel without any selections'); + } + /** * @param list $selectionIds */ diff --git a/tests/Controller/Booking/Create/Step2ControllerTest.php b/tests/Controller/Booking/Create/Step2ControllerTest.php index 6bc52444..de93d38e 100644 --- a/tests/Controller/Booking/Create/Step2ControllerTest.php +++ b/tests/Controller/Booking/Create/Step2ControllerTest.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace App\Tests\Controller\Booking\Create; use App\BusProNet\Constants; +use App\BusProNet\Model\CrmSelection; +use App\BusProNet\Model\CrmSelectionGroup; use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; use App\Controller\Booking\Create\Step2Controller; use App\Form\Model\BookingDto; +use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use App\Model\RoomSelectionAdjustment; use App\Service\BookingConfigurator; @@ -181,6 +184,63 @@ class Step2ControllerTest extends TestCase $this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants'); } + public function testEndsASplitTheTravelDateDoesNotOffer(): void + { + $bookingDto = $this->createSplitBookingDto($this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)])); + $applicant = $bookingDto->applicant; + + $controller = $this->createControllerForFullRun($bookingDto); + $controller->index(Request::create('/bookings/create/participants', 'GET')); + + $this->assertTrue($bookingDto->applicantTravels); + $this->assertNull($bookingDto->applicant); + $this->assertSame($applicant, $bookingDto->participants[0]); + } + + public function testKeepsASplitTheTravelDateOffers(): void + { + $travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]); + $selection = new CrmSelection(); + $selection->id = 1488; + $group = new CrmSelectionGroup(); + $group->id = 10; + $group->selections = [$selection]; + $travel->selectionGroups = [10 => $group]; + + $bookingDto = $this->createSplitBookingDto($travel); + $applicant = $bookingDto->applicant; + + $controller = $this->createControllerForFullRun($bookingDto); + $controller->index(Request::create('/bookings/create/participants', 'GET')); + + $this->assertFalse($bookingDto->applicantTravels); + $this->assertSame($applicant, $bookingDto->applicant); + $this->assertNotSame($applicant, $bookingDto->participants[0]); + } + + private function createSplitBookingDto(Travel $travel): BookingDto + { + $bookingDto = $this->createBookingDto($travel); + $bookingDto->roomSelections = [ + 1409 => $this->createRoomSelection(1409, 'Einzelzimmer', quantity: 1, capacity: 1), + ]; + $bookingDto->applicantTravels = false; + $bookingDto->applicant = new ParticipantDto(); + + return $bookingDto; + } + + private function createControllerForFullRun(BookingDto $bookingDto): TestableStep2Controller + { + $bookingService = $this->createStub(BookingConfigurator::class); + $bookingService->method('reconcileRoomSelections')->willReturn([]); + + $bookingSessionService = $this->createStub(BookingSessionManager::class); + $bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto); + + return $this->createController($bookingService, $bookingSessionService, $this->createStub(LoggerInterface::class)); + } + private function createController( BookingConfigurator $bookingService, BookingSessionManager $bookingSessionService, diff --git a/tests/Controller/Booking/Create/Step2ParticipantControllerTest.php b/tests/Controller/Booking/Create/Step2ParticipantControllerTest.php new file mode 100644 index 00000000..d13eec22 --- /dev/null +++ b/tests/Controller/Booking/Create/Step2ParticipantControllerTest.php @@ -0,0 +1,127 @@ +createBookingDto(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->method('getBookingDto')->willReturn($bookingDto); + $bookingSessionService->expects($this->never())->method('saveBookingDto'); + + $controller = $this->createController($bookingSessionService); + + $response = $controller->toggleApplicantTravels($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/app_booking_create_step_2', $response->getTargetUrl()); + $this->assertSame(0, $controller->createdForms, 'The submitted choice is never bound'); + $this->assertTrue($bookingDto->applicantTravels); + } + + public function testApplicantFormIsNotServedWhereTheTravelDateDoesNotOfferTheChoice(): void + { + $request = Request::create('/bookings/create/applicant', 'GET'); + $bookingDto = $this->createBookingDto(); + $bookingDto->applicantTravels = false; + $bookingDto->applicant = new ParticipantDto(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->method('getBookingDto')->willReturn($bookingDto); + $bookingSessionService->expects($this->never())->method('saveBookingDto'); + + $controller = $this->createController($bookingSessionService); + + $response = $controller->editApplicant($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/app_booking_create_step_2', $response->getTargetUrl()); + $this->assertSame(0, $controller->createdForms); + } + + private function createBookingDto(): BookingDto + { + $travel = new Travel(); + $travel->id = 4711; + + $participant = new ParticipantDto(); + $participant->index = 0; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + return $bookingDto; + } + + private function createController(BookingSessionManager $bookingSessionService): TestableStep2ParticipantController + { + return new TestableStep2ParticipantController( + $this->createStub(BookingConfigurator::class), + $bookingSessionService, + $this->createStub(BookingCreateContextFactory::class), + $this->createStub(TravelDataProvider::class), + $this->createStub(ParticipantDataPrefiller::class), + $this->createStub(ParticipantFormSupport::class), + $this->createStub(ApplicantInsuranceCascade::class), + ); + } +} + +final class TestableStep2ParticipantController extends Step2ParticipantController +{ + public int $createdForms = 0; + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + ++$this->createdForms; + + throw new \LogicException('No form is expected to be built.'); + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string + { + return '/'.$route; + } + + protected function redirect(string $url, int $status = 302): RedirectResponse + { + return new RedirectResponse($url, $status); + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } +} diff --git a/tests/Form/Model/BookingDtoTest.php b/tests/Form/Model/BookingDtoTest.php index 73c18ff1..50dc2fcf 100644 --- a/tests/Form/Model/BookingDtoTest.php +++ b/tests/Form/Model/BookingDtoTest.php @@ -148,6 +148,48 @@ class BookingDtoTest extends TestCase $this->assertFalse($dto->isFirstParticipantApplicant()); } + public function testRestoringTheTravellingApplicantEndsTheSplit(): void + { + $dto = $this->createCreateModeDto(); + $dto->applicantTravels = false; + + $applicant = new ParticipantDto(); + $applicant->firstName = 'Elternteil'; + $dto->applicant = $applicant; + + $child = new ParticipantDto(); + $child->index = 0; + $child->firstName = 'Kind'; + $child->assignedRoomId = 1408; + $sibling = new ParticipantDto(); + $sibling->index = 1; + $dto->participants = [$child, $sibling]; + + $dto->restoreTravellingApplicant(); + + $this->assertTrue($dto->applicantTravels); + $this->assertNull($dto->applicant); + $this->assertSame($applicant, $dto->participants[0]); + $this->assertSame(0, $applicant->index); + $this->assertSame(1408, $applicant->assignedRoomId, 'The slot keeps its room'); + $this->assertSame($sibling, $dto->participants[1]); + } + + public function testRestoringWithoutAnApplicantRecordKeepsTheFirstParticipant(): void + { + $dto = $this->createCreateModeDto(); + $dto->applicantTravels = false; + + $traveller = new ParticipantDto(); + $traveller->index = 0; + $dto->participants = [$traveller]; + + $dto->restoreTravellingApplicant(); + + $this->assertTrue($dto->applicantTravels); + $this->assertSame($traveller, $dto->participants[0]); + } + public function testInternalAgencyBookingNeverHasTheFirstParticipantAsApplicantInEditMode(): void { $dto = $this->createEditModeDto(100, 100);