feat: split booking between participants and applicant
addresses #869f2q1vu
This commit is contained in:
1 parent
2d49212b08
commit
1ff293f41f
44 files changed
+2024
-316
No files matched your search
@@ -9,6 +9,7 @@ use App\Entity\User;
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Exception\HotelNotInTravelException;
|
||||
use App\Exception\TravelNotFoundException;
|
||||
use App\Form\BookingApplicantTravelsType;
|
||||
use App\Form\BookingCreateStep2Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
@@ -22,10 +23,12 @@ use App\Service\RoomAssigner;
|
||||
use App\Service\TravelDataProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
|
||||
/**
|
||||
* Handles the second step of the booking creation process using card-based UI.
|
||||
@@ -80,14 +83,26 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
|
||||
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
|
||||
|
||||
// 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();
|
||||
if ($user instanceof User
|
||||
&& isset($bookingCreateDto->participants[0])
|
||||
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
||||
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->participants[0]
|
||||
);
|
||||
if ($user instanceof User) {
|
||||
if (false === $bookingCreateDto->applicantTravels) {
|
||||
$bookingCreateDto->applicant ??= new ParticipantDto();
|
||||
|
||||
if ($this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->applicant)) {
|
||||
$bookingCreateDto->applicant = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->applicant
|
||||
);
|
||||
}
|
||||
} elseif (isset($bookingCreateDto->participants[0])
|
||||
&& $this->prepopulationService->shouldPrefillApplicant($bookingCreateDto->participants[0])) {
|
||||
$bookingCreateDto->participants[0] = $this->prepopulationService->prefillApplicantFromUser(
|
||||
$user,
|
||||
$bookingCreateDto->participants[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate room assignments against current selection (handles back-navigation from step 2 to step 1)
|
||||
@@ -122,6 +137,10 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
$templateData = [
|
||||
'form' => $form->createView(),
|
||||
'bookingCreateContext' => $context,
|
||||
'applicantErrors' => $this->collectApplicantErrors($form),
|
||||
'applicantTravelsForm' => $this
|
||||
->createForm(BookingApplicantTravelsType::class, $bookingCreateDto)
|
||||
->createView(),
|
||||
];
|
||||
|
||||
return $this->render('booking/create/step_2.html.twig', $templateData);
|
||||
@@ -200,6 +219,42 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls out the violations that belong to the applicant block rather than a participant.
|
||||
*
|
||||
* The step-2 form carries no fields of its own, so every violation surfaces at the form root
|
||||
* and the page can only say "check the participants". An incomplete applicant would then be
|
||||
* an invisible blocker: nothing on the page is marked and the button simply does nothing.
|
||||
*
|
||||
* @param FormInterface<mixed> $form
|
||||
*
|
||||
* @return string[] Messages to render on the applicant card
|
||||
*/
|
||||
private function collectApplicantErrors(FormInterface $form): array
|
||||
{
|
||||
if (false === $form->isSubmitted()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
|
||||
foreach ($form->getErrors(true) as $error) {
|
||||
$cause = $error->getCause();
|
||||
|
||||
if (false === $cause instanceof ConstraintViolationInterface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === str_contains($cause->getPropertyPath(), 'applicant')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = $error->getMessage();
|
||||
}
|
||||
|
||||
return array_values(array_unique($messages));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a room selection that resolves to no participants at all.
|
||||
*
|
||||
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\Form\BookingApplicantTravelsType;
|
||||
use App\Form\BookingApplicantType;
|
||||
use App\Form\BookingParticipantType;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingConfigurator;
|
||||
@@ -130,6 +133,141 @@ class Step2ParticipantController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the booking-level "I am not travelling myself" choice.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
#[Route(
|
||||
path: '/bookings/create/applicant-toggle',
|
||||
name: 'app_booking_create_step_2_applicant_toggle',
|
||||
methods: ['POST']
|
||||
)]
|
||||
public function toggleApplicantTravels(Request $request): Response
|
||||
{
|
||||
$result = $this->loadBookingDtoOrRedirect($request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
$bookingDto = $result;
|
||||
|
||||
// Internal agency bookings are pre-populated with a non-travelling applicant already,
|
||||
// so the choice is neither offered nor accepted for them.
|
||||
if ($bookingDto->isInternalAgencyBooking()) {
|
||||
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.
|
||||
$previousChoice = $bookingDto->applicantTravels;
|
||||
|
||||
$form = $this->createForm(BookingApplicantTravelsType::class, $bookingDto);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||
}
|
||||
|
||||
$applicantTravels = $bookingDto->applicantTravels;
|
||||
|
||||
if ($applicantTravels !== $previousChoice) {
|
||||
$this->participantFormSupportService->ensureParticipantExists($bookingDto, 0);
|
||||
|
||||
if (false === $applicantTravels) {
|
||||
$bookingDto->applicant = $bookingDto->participants[0];
|
||||
$bookingDto->applicant->index = null;
|
||||
$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;
|
||||
}
|
||||
|
||||
// Family insurance is not offered on a split booking, so a choice already made on
|
||||
// participant[0] has to go with it.
|
||||
if (false === $applicantTravels) {
|
||||
$this->participantFormSupportService->clearFamilyInsurance($bookingDto);
|
||||
}
|
||||
|
||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||
}
|
||||
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// The choice rebuilds the whole participant list, so navigate rather than swap a fragment.
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact data of an applicant who does not travel along.
|
||||
*
|
||||
* Guests only. A logged-in applicant's data comes from their account and is rendered as
|
||||
* static text on the step-2 card, because editing it here would create a duplicate BusPro
|
||||
* record - the same reason participant[0] is static when the applicant travels.
|
||||
*/
|
||||
#[Route(
|
||||
path: '/bookings/create/applicant',
|
||||
name: 'app_booking_create_applicant'
|
||||
)]
|
||||
public function editApplicant(Request $request): Response
|
||||
{
|
||||
$result = $this->loadBookingDtoOrRedirect($request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
$bookingDto = $result;
|
||||
|
||||
if (true === $bookingDto->applicantTravels || $bookingDto->isInternalAgencyBooking()) {
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
$bookingDto->applicant ??= new ParticipantDto();
|
||||
|
||||
$form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant);
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the
|
||||
// whole block with dummy data instead of saving what was typed.
|
||||
if ($form->isSubmitted()
|
||||
&& $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) {
|
||||
$this->prepopulationService->fillDummyApplicant($bookingDto->applicant);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_applicant');
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
// Keep an invalid submission in the session so the entered data survives a reload.
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$bookingCreateContext = $this->createContextFactory->create(
|
||||
$bookingDto,
|
||||
RoomPricingCalculator::PRICING_MODE_SELECTION
|
||||
);
|
||||
|
||||
return $this->render('booking/create/applicant.html.twig', [
|
||||
'form' => $form,
|
||||
'bookingCreateContext' => $bookingCreateContext,
|
||||
'bookingDto' => $bookingCreateContext->bookingDto,
|
||||
'summaryData' => $bookingCreateContext->summaryData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface<mixed> $form
|
||||
* @param array<array{type: string, message: string}> $notifications
|
||||
|
||||
Reference in new issue
Block a user