wip: group inquiries and bookings

This commit is contained in:
Björn Fromme committed 2026-07-16 09:03:42 +02:00
1 parent 41424de151
commit 1a653726b4
115 files changed
+8107 -424

No files matched your search

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractAccommodationController extends AbstractController
{
protected function validateStepAccess(AccommodationBookingDto $dto, int $expectedStep): ?RedirectResponse
{
if ($expectedStep > $dto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToCurrentStep($dto);
}
return null;
}
protected function createFailureResponse(\Throwable $exception, bool $htmx): Response
{
if ($htmx) {
return new Response('', 400);
}
$message = $exception instanceof AccommodationSessionNotFoundException
? 'Deine Sitzung ist abgelaufen. Bitte starte die Anfrage erneut.'
: 'Die Anfrage konnte nicht geladen werden.';
$this->addFlash('error', $message);
return $this->redirectToRoute('app_groups_booking_error');
}
private function redirectToCurrentStep(AccommodationBookingDto $dto): RedirectResponse
{
$route = match ($dto->currentStep) {
2 => 'app_groups_booking_step_2',
3 => 'app_groups_booking_step_3',
default => 'app_groups_booking_step_1',
};
return $this->redirectToRoute($route);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingQueryParams;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
/**
* Validates query params, initializes the session DTO, and redirects to step 1.
*
* No intermediate loading page is needed because all work here is DB-only and fast.
*/
#[Route('/groups/booking/init', name: 'app_groups_booking_init')]
public function init(Request $request, #[MapQueryString] ?AccommodationBookingQueryParams $params): Response
{
if (null === $params) {
$this->addFlash('error', 'Ungültige oder fehlende Parameter. Bitte überprüfe den Link.');
return $this->redirectToRoute('app_groups_booking_error');
}
try {
$this->sessionManager->clear($request);
$dto = $this->bookingService->initFromParams($params);
$this->sessionManager->save($request, $dto);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('app_groups_booking_error');
}
return $this->redirectToRoute('app_groups_booking_step_1');
}
#[Route('/groups/booking/success', name: 'app_groups_booking_success')]
public function success(Request $request): Response
{
$session = $request->getSession();
$resultType = $session instanceof FlashBagAwareSessionInterface
? $session->getFlashBag()->get('groups_booking_result')[0] ?? null
: null;
// Flash is consumed after the first read — reload/direct access ends up here with none.
if (null === $resultType) {
return $this->redirectToRoute('app_login');
}
return $this->render('groups/booking/success.html.twig', [
'resultType' => $resultType,
]);
}
#[Route('/groups/booking/error', name: 'app_groups_booking_error')]
public function error(): Response
{
return $this->render('groups/booking/error.html.twig');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OfferController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) {
return $this->render('groups/booking/offer_unavailable.html.twig');
}
if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) {
$this->bookingService->acceptBooking($booking);
return new RedirectResponse($request->getUri());
}
return $this->render('groups/booking/offer.html.twig', [
'booking' => $booking,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
]);
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\AccommodationPrice;
use App\Exception\AccommodationSessionNotFoundException;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\CalendarGridBuilder;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class Step1Controller extends AbstractAccommodationController
{
use HxTrait;
private const int CALENDAR_MONTHS = 18;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly ContingentsClient $contingentsClient,
private readonly AccommodationPriceRepository $priceRepository,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly CacheInterface $cache,
private readonly CalendarGridBuilder $calendarGridBuilder,
private readonly GroupsPriceCalculator $priceCalculator,
) {
}
#[Route('/groups/booking/step-1', name: 'app_groups_booking_step_1', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($request->isMethod('POST')) {
$dateFromRaw = $request->request->getString('date_from');
$dateToRaw = $request->request->getString('date_to');
try {
$this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('app_groups_booking_step_1');
}
$dateFrom = $dto->dateFrom;
$dateTo = $dto->dateTo;
if (null !== $dateFrom && null !== $dateTo) {
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$dto->paxCount = $this->bookingService->computeInitialPaxCount($accommodation, $dateFrom, $dateTo);
}
// Reset service selections — no longer valid for the new date range
$dto->selectedBoardServiceId = null;
$dto->selectedAdditionalServiceIds = [];
$dto->currentStep = 2;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_2');
}
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
null,
[],
$accommodation->getCurrency() ?? 'EUR',
);
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/booking/step_1.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'hotelCode' => $accommodation->getCalendarCode() ?? '',
]);
}
#[Route('/groups/booking/calendar-refresh', name: 'app_groups_booking_calendar_refresh', methods: ['POST'])]
public function calendarRefresh(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$dateFromRaw = $request->request->getString('date_from');
$dateToRaw = $request->request->getString('date_to');
if ('' !== $dateFromRaw && '' !== $dateToRaw) {
try {
$this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw);
} catch (\InvalidArgumentException) {
// Invalid dates — fall through with original dto state
}
} else {
$dto->dateFrom = null;
$dto->dateTo = null;
}
$this->sessionManager->save($request, $dto);
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
return new Response('', Response::HTTP_NOT_FOUND);
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
null,
[],
$accommodation->getCurrency() ?? 'EUR',
);
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->htmxOobResponse(
'groups/booking/_summary.html.twig',
['booking_summary'],
['dto' => $dto, 'ctx' => $ctx],
);
}
#[Route('/groups/booking/calendar-grid', name: 'app_groups_booking_calendar_grid', methods: ['GET'])]
public function calendarGrid(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
$hotelCode = $accommodation?->getCalendarCode() ?? '';
$now = new \DateTimeImmutable('today');
$calendarStart = $now->modify('first day of this month');
$calendarEnd = $calendarStart->modify('+'.(self::CALENDAR_MONTHS - 1).' months')->modify('last day of this month');
$todayStr = $now->format('Y-m-d');
$maxOffset = self::CALENDAR_MONTHS - 2;
if ($request->query->has('offset')) {
$offset = max(0, min($request->query->getInt('offset'), $maxOffset));
} else {
$offset = 0;
if ($dto->dateFrom !== null) {
$monthsDiff = ((int) $dto->dateFrom->format('Y') - (int) $calendarStart->format('Y')) * 12
+ ((int) $dto->dateFrom->format('n') - (int) $calendarStart->format('n'));
$offset = max(0, min($monthsDiff, $maxOffset));
}
}
$displayFrom = $calendarStart->modify("+{$offset} months");
$months = $this->calendarGridBuilder->buildMonths($displayFrom, 2);
$enrichedByDate = $this->buildEnrichedDayData(
$hotelCode,
$calendarStart,
$calendarEnd,
$calendarStart->format('Y-m-d'),
$calendarEnd->format('Y-m-d'),
);
return $this->render('groups/booking/_price_calendar_grid.html.twig', [
'months' => $months,
'enrichedByDate' => $enrichedByDate,
'todayStr' => $todayStr,
'offset' => $offset,
'totalMonths' => self::CALENDAR_MONTHS,
]);
}
/**
* Fetches contingent + price data and returns a map of date → ['status', 'minNights'].
*
* Returns an empty array on API failure; the template treats missing dates as blocked.
*
* @return array<string, array{status: string, minNights: int}>
*/
private function buildEnrichedDayData(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
string $dateFromStr,
string $dateToStr,
): array {
try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
$calendar = $this->cache->get(
$cacheKey,
function (ItemInterface $item) use ($hotelCode, $dateFromStr, $dateToStr): mixed {
$item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromStr, $dateToStr);
},
);
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
return [];
}
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$availableDates = [];
foreach ($calendar->data as $entry) {
if (ContingentStatus::Ok === $entry->status) {
$availableDates[$entry->date] = true;
}
}
$enriched = [];
foreach ($calendar->data as $entry) {
$isAvailable = ContingentStatus::Ok === $entry->status;
$prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d');
$prevAvailable = isset($availableDates[$prevDate]);
$status = match (true) {
$isAvailable && $prevAvailable => 'ok',
$isAvailable => 'blocked-to-ok',
$prevAvailable => 'checkout-only',
default => 'blocked',
};
$day = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
$enriched[$entry->date] = [
'status' => $status,
'minNights' => $winner?->getMinNights() ?? 0,
];
}
return $enriched;
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationStep2Type;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\GroupsPriceCalculator;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step2Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly GroupsPriceCalculator $priceCalculator,
) {
}
#[Route('/groups/booking/step-2', name: 'app_groups_booking_step_2')]
public function index(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($redirect = $this->validateStepAccess($dto, 2)) {
return $redirect;
}
[$ctx, $form] = $this->buildStep2Context($request, $dto, true);
if ($form->isSubmitted() && $form->isValid()) {
$dto->currentStep = 3;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_3');
}
return $this->render('groups/booking/step_2.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
]);
}
#[Route('/groups/booking/step-2/refresh', name: 'app_groups_booking_step_2_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, true);
}
[$ctx, $form] = $this->buildStep2Context($request, $dto, false);
return $this->htmxOobResponse(
'groups/booking/step_2.html.twig',
['accommodation_form', 'accommodation_summary'],
['dto' => $dto, 'ctx' => $ctx, 'form' => $form->createView()],
);
}
/**
* @return array{AccommodationBookingContext, FormInterface<AccommodationBookingDto>}
*/
private function buildStep2Context(Request $request, AccommodationBookingDto $dto, bool $validate): array
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$form = $this->createForm(AccommodationStep2Type::class, $dto, [
'board_service_choices' => $this->buildServiceChoices($services['boardServices']),
'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']),
'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(),
'validation_groups' => $validate ? ['step_2'] : false,
]);
$form->handleRequest($request);
$status = $this->bookingService->computeInquiryStatus($dto, $prices);
$dto->isInquiry = $status->isInquiry;
$dto->inquiryReasons = $status->reasons;
$boardService = $this->resolveSelectedBoardService($dto, $services['boardServices']);
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
$boardService,
$selectedAdditionalServices,
$accommodation->getCurrency() ?? 'EUR',
);
$dto->totalPrice = $priceBreakdown['total'];
$dto->priceBreakdown = $priceBreakdown;
}
$this->sessionManager->save($request, $dto);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
groupedAdditionalServices: $services['groupedAdditionalServices'],
ungroupedAdditionalServices: $services['ungroupedAdditionalServices'],
priceBreakdown: $priceBreakdown,
);
return [$ctx, $form];
}
/**
* @param BoardService[] $boardServices
*/
private function resolveSelectedBoardService(AccommodationBookingDto $dto, array $boardServices): ?BoardService
{
if (null === $dto->selectedBoardServiceId) {
return null;
}
foreach ($boardServices as $service) {
if ($service->getId() === $dto->selectedBoardServiceId) {
return $service;
}
}
return null;
}
/**
* @param AdditionalService[] $additionalServices
*
* @return AdditionalService[]
*/
private function resolveSelectedAdditionalServices(AccommodationBookingDto $dto, array $additionalServices): array
{
if (empty($dto->selectedAdditionalServiceIds)) {
return [];
}
$selectedIds = array_flip($dto->selectedAdditionalServiceIds);
return array_values(array_filter(
$additionalServices,
fn(AdditionalService $s) => isset($selectedIds[$s->getId()]),
));
}
/**
* Builds an ID-keyed choices array for Symfony form binding.
* Labels and price formatting are handled in Twig.
*
* @param BoardService[]|AdditionalService[] $services
*
* @return array<string, int>
*/
private function buildServiceChoices(array $services): array
{
$choices = [];
foreach ($services as $service) {
$choices[(string) $service->getId()] = $service->getId();
}
return $choices;
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\Accommodation;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationStep3ConfirmationType;
use App\Form\AccommodationStep3Type;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\Form\ClickableInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step3Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
#[Route('/groups/booking/step-3', name: 'app_groups_booking_step_3')]
public function index(Request $request): Response
{
$dto = $this->resolveDto($request, false);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
priceBreakdown: $dto->priceBreakdown ?: null,
);
$form = $this->createForm(AccommodationStep3Type::class, $dto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$submitBookingClicked = false;
// booking or inquiry status depends on clicked button
if (true === $form->has('submitBooking')) {
$submitBookingButton = $form->get('submitBooking');
$submitBookingClicked = $submitBookingButton instanceof ClickableInterface && true === $submitBookingButton->isClicked();
}
// Booking is only offered via the confirmation modal; a forced-inquiry DTO
// can never actually reach this branch since submitBooking is disabled for it.
if (true === $submitBookingClicked && false === $dto->isInquiry) {
$dto->forceInquiry = false;
$this->sessionManager->save($request, $dto);
$confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto);
return $this->render('groups/booking/step_3.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
'confirmationForm' => $confirmationForm,
]);
}
$dto->forceInquiry = true;
$this->persistBooking($request, $dto, $accommodation, $services);
return $this->redirectToRoute('app_groups_booking_success');
}
return $this->render('groups/booking/step_3.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
]);
}
#[Route('/groups/booking/step-3/confirm', name: 'app_groups_booking_step_3_confirm', methods: ['POST'])]
public function confirm(Request $request): Response
{
$dto = $this->resolveDto($request, true);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid() && false === $dto->isInquiry) {
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$this->persistBooking($request, $dto, $accommodation, $services);
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
}
return $this->render('groups/booking/_step3_confirmation_modal.html.twig', [
'confirmationForm' => $confirmationForm,
]);
}
/**
* Loads the session DTO and checks step access, returning an early
* response (session failure or step-access redirect) if either fails.
*/
private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, $htmx);
}
if ($redirect = $this->validateStepAccess($dto, 3)) {
return $redirect;
}
return $dto;
}
private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
return $accommodation;
}
/**
* @param array{boardServices: array, additionalServices: array} $services
*/
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
{
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->persist($dto, $accommodation, $prices, $services);
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown);
$this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking');
$this->sessionManager->clear($request);
}
}