feat: offer non-travelling applicant choice only on selected travels
This commit is contained in:
1 parent
5b75938c73
commit
b958d1a371
9 files changed
+320
-26
No files matched your search
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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. #}
|
||||
<div class="mb-6 rounded border border-gray-200 bg-white p-4"
|
||||
hx-post="{{ path('app_booking_create_step_2_applicant_toggle') }}"
|
||||
hx-trigger="change"
|
||||
hx-include="this"
|
||||
{{ qa_attribute('applicant-travels') }}>
|
||||
{{ 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. #}
|
||||
<div class="mb-6 rounded border border-gray-200 bg-white p-4"
|
||||
hx-post="{{ path('app_booking_create_step_2_applicant_toggle') }}"
|
||||
hx-trigger="change"
|
||||
hx-include="this"
|
||||
{{ qa_attribute('applicant-travels') }}>
|
||||
{{ 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 %}
|
||||
</div>
|
||||
{% if not bookingDto.applicantTravels %}
|
||||
{% include 'booking/create/_applicant_card.html.twig' with {
|
||||
'applicant': bookingDto.applicant,
|
||||
'isSubmitted': bookingCreateContext.isSubmitted,
|
||||
'applicantErrors': applicantErrors
|
||||
} %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="divide-y divide-gray-200">
|
||||
{% for cardData in bookingCreateContext.cardsData %}
|
||||
|
||||
@@ -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<int> $selectionIds
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Controller\Booking\Create\Step2ParticipantController;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use App\Service\TravelDataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class Step2ParticipantControllerTest extends TestCase
|
||||
{
|
||||
public function testToggleChangesNothingWhereTheTravelDateDoesNotOfferTheChoice(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/applicant-toggle', 'POST');
|
||||
$bookingDto = $this->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<mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $parameters
|
||||
*/
|
||||
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
|
||||
{
|
||||
return new RedirectResponse('/'.$route, $status);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user