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);