feat: split booking between participants and applicant

addresses #869f2q1vu
This commit is contained in:
fromme committed 2026-10-19 09:18:22 +02:00
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