feat: split booking between participants and applicant

addresses #869f2q1vu
This commit is contained in:
fromme committed 2026-10-16 17:49:04 +02:00
1 parent 0ee8fafbe1
commit ad6e42ab0c
44 files changed
+2024 -316

No files matched your search

@@ -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