wip: booking process

This commit is contained in:
Björn Fromme
2025-07-16 11:55:49 +02:00
parent bef18971c3
commit 47faa6b08e
26 changed files with 1123 additions and 271 deletions
+68 -14
View File
@@ -2,6 +2,8 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\TravelCodeUtility;
use App\BusProNet\XmlLoader\HotelLoader;
@@ -11,6 +13,7 @@ use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapDateTime;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
@@ -24,6 +27,7 @@ class TravelController extends AbstractController
private readonly TravelLoader $travelXmlLoader,
private readonly HotelLoader $hotelXmlLoader,
private readonly PickupLoader $pickupXmlLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
) {
}
@@ -49,6 +53,38 @@ class TravelController extends AbstractController
return $this->json($travel, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
}
#[Route(
path: '/travels/{travelId}/remote',
name: 'api_travel_single_id_remote',
requirements: ['travelId' => '\d+'],
)]
public function singleByIdRemote(int $travelId): JsonResponse
{
$cacheKey = sprintf('bpn_travel_remote_%d', $travelId);
try {
$result = $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId) {
$item->expiresAfter(300); // 5 minutes cache for remote API calls
try {
return $this->apiClient->getTravelData($travelId);
} catch (ApiClientException $e) {
// Return error info instead of throwing to avoid cache wrapping issues
return ['error' => $e->getMessage(), 'type' => 'api_error'];
}
});
// Check if result is an error
if (is_array($result) && isset($result['error'], $result['type'])) {
return $this->json($result['error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->json($result, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
} catch (InvalidArgumentException) {
return $this->json('Cache error occurred', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route(
path: '/travels/{travelCode}/{hotelCode}',
name: 'api_travel_single_code',
@@ -63,7 +99,7 @@ class TravelController extends AbstractController
$hotelId = $hotelCode ? $this->hotelXmlLoader->mapCodeToId($travelCode) : null;
if (null === $travelId) {
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$travel = $this->loadCached($travelId, $hotelId);
@@ -71,29 +107,47 @@ class TravelController extends AbstractController
return $this->json($travel, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
}
#[Route('/travels/{travelId}/{hotelId}/{dateTo}/availability', name: 'api_travel_hotel_availability')]
public function hotelAvailability(
int $travelId,
int $hotelId,
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo
): JsonResponse {
try {
$result = $this->apiClient->getHotelAvailability($travelId, $hotelId, $dateTo);
return $this->json($result);
} catch (ApiClientException $e) {
return $this->json(['error' => $e->getMessage(), 'type' => 'api_error']);
}
}
private function loadCached(int $travelId, ?int $hotelId = null): ?Travel
{
$cacheKey = sprintf('bpn_travel_%d_%d', $travelId, $hotelId ?? 0);
try {
$travel = $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId, $hotelId) {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId, $hotelId) {
$item->expiresAfter(60);
$travel = $this->travelXmlLoader->loadById($travelId, $hotelId);
try {
$travel = $this->travelXmlLoader->loadById($travelId, $hotelId);
if (null === $travel) {
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
if (null === $travel) {
return null;
}
$this->pickupXmlLoader->patchPickupsDetails($travel);
$this->hotelXmlLoader->patchHotelDetails($travel);
return $travel;
} catch (\Exception) {
// Return null for any loader exceptions to avoid cache wrapping issues
return null;
}
$this->pickupXmlLoader->patchPickupsDetails($travel);
$this->hotelXmlLoader->patchHotelDetails($travel);
return $travel;
});
} catch (InvalidArgumentException $e) {
$travel = null;
} catch (InvalidArgumentException) {
return null;
}
return $travel;
}
}
+92 -24
View File
@@ -2,49 +2,117 @@
namespace App\Controller\Booking;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use App\Form\BookingCreateStep1Type;
use App\Form\BookingCreateStep2Type;
class CreateController extends AbstractController
{
public function __construct(private readonly TravelLoader $travelDataLoader)
{}
public function __construct(
private readonly BookingService $bookingCreateService,
) {
}
#[Route('/bookings/create', name: 'app_booking_create', methods: ['POST'])]
#[Route('/bookings/create', name: 'app_booking_create_step_1')]
public function index(Request $request): Response
{
$travelId = $request->query->getInt('travel_id');
$hotelId = $request->query->getInt('hotel_id');
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
$roomsIdsAndQuantities = $this->getRoomsIdsAndQuantities($request);
// Validate step access - allow step 1 or redirect to current step
$this->validateStepAccess($bookingCreateDto, 1);
$travelData = $this->travelDataLoader->loadById($travelId, $hotelId);
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto);
if (null === $travelData) {
throw $this->createNotFoundException('Travel data not found');
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$bookingCreateDto->currentStep = 2;
$this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto);
return $this->redirectToRoute('app_booking_create_step_2');
}
$participantsCount = 0;
$rooms = $travelData->getRoomsByIds(array_keys($roomsIdsAndQuantities));
foreach ($rooms as $room) {
$roomCount = $roomsIdsAndQuantities[$room->id] ?? 0;
$participantsCount += $room->minPax * $roomCount;
}
return $this->render('booking/create.html.twig', [
'travelData' => $travelData,
return $this->render('booking/create_step_1.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
'form' => $form->createView(),
]);
}
private function getRoomsIdsAndQuantities(Request $request): array
#[Route('/bookings/create/participants', name: 'app_booking_create_step_2')]
public function participants(Request $request): Response
{
$rooms = $request->request->all('rooms');
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
return array_map('intval', array_filter($rooms, 'strlen'));
// Validate step access
$this->validateStepAccess($bookingCreateDto, 2);
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$bookingCreateDto->currentStep = 3;
$this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto);
return $this->redirectToRoute('app_booking_create_step_3');
}
return $this->render('booking/create_step_2.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
'form' => $form->createView(),
]);
}
#[Route('/bookings/create/confirm', name: 'app_booking_create_step_3')]
public function confirm(Request $request): Response
{
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
// Validate step access
$this->validateStepAccess($bookingCreateDto, 3);
return $this->render('booking/confirm.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
]);
}
/**
* Validates step access and redirects if necessary.
*
* @param \App\Form\Model\BookingCreateDto $bookingCreateDto
* @param int $expectedStep
*/
private function validateStepAccess($bookingCreateDto, int $expectedStep): void
{
// Allow access to current step or any previous step
if ($expectedStep > $bookingCreateDto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
$this->redirectToCurrentStep($bookingCreateDto);
}
}
/**
* Redirects to the current step based on the DTO's currentStep.
*/
private function redirectToCurrentStep($bookingCreateDto): void
{
$routeParams = [
'travel_id' => $bookingCreateDto->travelData->id,
'hotel_id' => $bookingCreateDto->travelData->hotelId,
];
$route = match ($bookingCreateDto->currentStep) {
1 => 'app_booking_create_step_1',
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
default => 'app_booking_create_step_1',
};
$this->redirectToRoute($route, $routeParams);
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Controller\Traits\BookingDataTrait;
use App\Entity\User;
use App\Form\BookingType;
use App\Form\BookingEditType;
use App\Form\Model\BookingEditDto;
use App\Security\Crypt;
use Psr\Cache\InvalidArgumentException;
@@ -92,7 +92,7 @@ class EditController extends AbstractController
// Create DTO for form
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
$form = $this->createForm(BookingType::class, $formData, [
$form = $this->createForm(BookingEditType::class, $formData, [
'attr' => ['novalidate' => 'novalidate'],
]);
+35
View File
@@ -16,8 +16,20 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Controller for managing customer personal data operations.
*
* Provides functionality for viewing and updating customer profile information
* through integration with the BusProNet API system. Handles personal data
* management and newsletter subscription preferences for authenticated users.
*/
class PersonalDataController extends AbstractController
{
/**
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
@@ -25,6 +37,18 @@ class PersonalDataController extends AbstractController
) {
}
/**
* Display and handle updates to customer personal data.
*
* Fetches current personal data from BusProNet API and displays an editable form.
* Handles form submission to update personal information including address and
* communication details. Uses the Post-Redirect-Get pattern for form processing.
*
* @param Request $request The HTTP request containing form data
* @return Response The rendered personal data page or redirect response
*
* @throws ApiClientException When BusProNet API communication fails
*/
#[Route('/personal-data', name: 'app_personal_data')]
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
@@ -79,6 +103,17 @@ class PersonalDataController extends AbstractController
]);
}
/**
* Toggle newsletter subscription status for the authenticated user.
*
* Retrieves current personal data, toggles the newsletter subscription flag,
* and updates the preference via BusProNet API. Designed for HTMX AJAX
* requests to provide immediate feedback without full page reload.
*
* @return Response Redirect response to personal data page
*
* @throws ApiClientException When BusProNet API communication fails
*/
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function newsletter(): Response