diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index ab75508c..3e6dc1bc 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -13,6 +13,7 @@ use App\Form\BookingCreateStep2Type; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; +use App\Model\RoomSelectionAdjustment; use App\Service\BookingConfigurator; use App\Service\BookingCreateContextFactory; use App\Service\BookingSessionManager; @@ -66,6 +67,12 @@ class Step2Controller extends AbstractBookingCreateController // Enrich with fresh availability data $this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel); + // Resync the room selections with the refreshed travel before any participant count is derived + // from them, and send the customer back to step 1 when a room they picked is gone. + if (null !== $redirect = $this->guardAgainstUnavailableRoomSelection($request, $bookingCreateDto)) { + return $redirect; + } + // Ensure correct number of participants first, then prepopulate the applicant if needed. if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) { return $redirect; @@ -120,6 +127,79 @@ class Step2Controller extends AbstractBookingCreateController return $this->render('booking/create/step_2.html.twig', $templateData); } + /** + * Sends the customer back to step 1 when the refreshed travel data no longer covers their rooms. + * + * Deriving the participant count from a room that has disappeared would silently fall back to a + * capacity of one and render too few participants, and a quantity the contingent no longer covers + * reaches the BusPro submit unchecked, because no later step re-reads room availability. The + * selections are resynced and saved before every exit, so step 1 and the neighbouring guard both + * see a collection that matches the rooms the template renders: leaving a stale entry behind would + * fail step 1's own validation on a field it never shows. + */ + private function guardAgainstUnavailableRoomSelection(Request $request, BookingDto $bookingCreateDto): ?RedirectResponse + { + $adjustments = $this->bookingService->reconcileRoomSelections($bookingCreateDto); + + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + + if ([] === $bookingCreateDto->travel->getAvailableRooms()) { + $this->handleApiError( + $this->logger, + 'Travel has no bookable rooms left while a booking is in progress.', + $this->createRoomContext($bookingCreateDto, $adjustments), + 'Für diese Reise sind leider keine Zimmer mehr verfügbar.' + ); + + return $this->redirectToRoute('app_booking_create_error'); + } + + if ([] === $adjustments) { + return null; + } + + $hasDroppedRoom = false; + foreach ($adjustments as $adjustment) { + $hasDroppedRoom = $hasDroppedRoom || $adjustment->isDropped(); + } + + $this->handleApiError( + $this->logger, + 'Room selection exceeds what the current travel data still offers.', + $this->createRoomContext($bookingCreateDto, $adjustments), + true === $hasDroppedRoom + ? 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.' + : 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.' + ); + + return $this->redirectToRoute('app_booking_create_step_1'); + } + + /** + * @param list $adjustments + * + * @return array + */ + private function createRoomContext(BookingDto $bookingCreateDto, array $adjustments): array + { + $rooms = []; + foreach ($adjustments as $adjustment) { + $rooms[] = [ + 'roomId' => $adjustment->roomId, + 'label' => $adjustment->label, + 'requestedQuantity' => $adjustment->requestedQuantity, + 'bookableQuantity' => $adjustment->bookableQuantity, + ]; + } + + return [ + 'travelId' => $bookingCreateDto->travel->id, + 'travelCode' => $bookingCreateDto->travel->code, + 'hotelId' => $bookingCreateDto->hotelId, + 'rooms' => $rooms, + ]; + } + /** * Rejects a room selection that resolves to no participants at all. * @@ -190,7 +270,14 @@ class Step2Controller extends AbstractBookingCreateController $rooms = $travelData->getAvailableRooms(); foreach ($roomSelections as $roomSelection) { - $room = $rooms[$roomSelection->id]; + $room = $rooms[$roomSelection->id] ?? null; + + if (null === $room) { + // Unreachable from index(), which reconciles the selections first. Kept so a future + // caller cannot silently fall back to a capacity of one and book too few people. + continue; + } + $participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity; } diff --git a/src/Model/RoomSelectionAdjustment.php b/src/Model/RoomSelectionAdjustment.php new file mode 100644 index 00000000..8ae6cef2 --- /dev/null +++ b/src/Model/RoomSelectionAdjustment.php @@ -0,0 +1,30 @@ +bookableQuantity; + } +} diff --git a/src/Service/BookingConfigurator.php b/src/Service/BookingConfigurator.php index af3db09c..97251464 100644 --- a/src/Service/BookingConfigurator.php +++ b/src/Service/BookingConfigurator.php @@ -15,6 +15,7 @@ use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use App\Form\Service\ServiceAgeEvaluator; +use App\Model\RoomSelectionAdjustment; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -91,6 +92,54 @@ class BookingConfigurator return $bookingCreateDto; } + /** + * Brings the room selections back in line with the rooms the travel currently offers. + * + * Room selections are built once when the booking session starts, while the Travel is re-fetched + * from BusPro on every request. A room that sells out or leaves a bookable status in between drops + * out of getAvailableRooms() and leaves a selection behind that points at a room that no longer + * exists; one that only loses part of its contingent leaves a quantity nothing else re-checks + * before the booking is submitted; and one that becomes bookable again has no selection at all. + * Rebuilding the selections from the current rooms, with the chosen quantities carried over and + * capped at what is left, keeps both sides in step. + * + * @return list The selections that had to give up quantity, in selection order + */ + public function reconcileRoomSelections(BookingDto $bookingDto): array + { + $availableRooms = $bookingDto->travel->getAvailableRooms(); + + $adjustments = []; + $quantitiesByRoomId = []; + + foreach ($bookingDto->roomSelections as $roomSelection) { + if (null === $roomSelection->id) { + continue; + } + + $requestedQuantity = $roomSelection->quantity ?? 0; + $bookableQuantity = min($requestedQuantity, $availableRooms[$roomSelection->id]->available ?? 0); + + $quantitiesByRoomId[$roomSelection->id] = $bookableQuantity; + + if (0 < $requestedQuantity && $bookableQuantity < $requestedQuantity) { + $adjustments[] = new RoomSelectionAdjustment( + $roomSelection->id, + $roomSelection->label, + $requestedQuantity, + $bookableQuantity + ); + } + } + + $bookingDto->roomSelections = array_map( + fn (Room $room) => $this->createRoomSelection($room, $quantitiesByRoomId), + $availableRooms + ); + + return $adjustments; + } + /** * Creates a room selection DTO from room data and quantities. * diff --git a/tests/Controller/Booking/Create/Step2ControllerTest.php b/tests/Controller/Booking/Create/Step2ControllerTest.php new file mode 100644 index 00000000..6bc52444 --- /dev/null +++ b/tests/Controller/Booking/Create/Step2ControllerTest.php @@ -0,0 +1,327 @@ +createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]); + $bookingDto = $this->createBookingDto($travel); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->once()) + ->method('reconcileRoomSelections') + ->with($bookingDto) + ->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]); + $bookingService->expects($this->never())->method('preselectDefaultServices'); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('saveBookingDto') + ->with($request, $bookingDto, BookingDto::MODE_CREATE); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with( + 'Room selection exceeds what the current travel data still offers.', + [ + 'travelId' => 4711, + 'travelCode' => 'TRV-4711', + 'hotelId' => 1, + 'rooms' => [[ + 'roomId' => 1408, + 'label' => 'Doppelzimmer', + 'requestedQuantity' => 2, + 'bookableQuantity' => 0, + ]], + ] + ); + + $controller = $this->createController($bookingService, $bookingSessionService, $logger); + + $response = $controller->index($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/app_booking_create_step_1', $response->getTargetUrl()); + $this->assertSame( + [['error', 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.']], + $controller->flashes + ); + } + + public function testSendsCustomerBackToStep1WithTheCappedQuantityPrefilled(): void + { + $request = Request::create('/bookings/create/participants', 'GET'); + $travel = $this->createTravel([$this->createRoom(1408, 'Doppelzimmer', minPax: 2)]); + $bookingDto = $this->createBookingDto($travel); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, 'Doppelzimmer', quantity: 1, capacity: 2), + ]; + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->once()) + ->method('reconcileRoomSelections') + ->with($bookingDto) + ->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 3, 1)]); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('saveBookingDto') + ->with($request, $bookingDto, BookingDto::MODE_CREATE); + + $controller = $this->createController( + $bookingService, + $bookingSessionService, + $this->createStub(LoggerInterface::class) + ); + + $response = $controller->index($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/app_booking_create_step_1', $response->getTargetUrl()); + $this->assertSame( + [['error', 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.']], + $controller->flashes + ); + $this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'Step 1 reopens on the capped selection'); + } + + public function testSendsCustomerToTheErrorPageWhenTheTravelSoldOutCompletely(): void + { + $request = Request::create('/bookings/create/participants', 'GET'); + $bookingDto = $this->createBookingDto($this->createTravel([])); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->once()) + ->method('reconcileRoomSelections') + ->with($bookingDto) + ->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('saveBookingDto') + ->with($request, $bookingDto, BookingDto::MODE_CREATE); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with('Travel has no bookable rooms left while a booking is in progress.', $this->anything()); + + $controller = $this->createController($bookingService, $bookingSessionService, $logger); + + $response = $controller->index($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/app_booking_create_error', $response->getTargetUrl()); + $this->assertSame( + [['error', 'Für diese Reise sind leider keine Zimmer mehr verfügbar.']], + $controller->flashes + ); + } + + public function testContinuesWhenOnlyUnselectedRoomsWereDropped(): void + { + $request = Request::create('/bookings/create/participants', 'GET'); + $travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 3)]); + $bookingDto = $this->createBookingDto($travel); + $bookingDto->roomSelections = [ + 1409 => $this->createRoomSelection(1409, 'Einzelzimmer', quantity: 2, capacity: 3), + ]; + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->once()) + ->method('reconcileRoomSelections') + ->with($bookingDto) + ->willReturn([]); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto); + $bookingSessionService->expects($this->exactly(2)) + ->method('saveBookingDto') + ->with($request, $bookingDto, BookingDto::MODE_CREATE); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('error'); + + $controller = $this->createController($bookingService, $bookingSessionService, $logger); + + $response = $controller->index($request); + + $this->assertSame([], $controller->flashes); + $this->assertInstanceOf(Response::class, $response); + $this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants'); + } + + private function createController( + BookingConfigurator $bookingService, + BookingSessionManager $bookingSessionService, + LoggerInterface $logger, + ): TestableStep2Controller { + $form = $this->createStub(FormInterface::class); + $form->method('isSubmitted')->willReturn(false); + + return new TestableStep2Controller( + $bookingService, + $bookingSessionService, + $this->createStub(BookingCreateContextFactory::class), + $this->createStub(TravelDataProvider::class), + $this->createStub(RoomAssigner::class), + $this->createStub(ParticipantDataPrefiller::class), + $logger, + $form, + ); + } + + private function createBookingDto(Travel $travel): BookingDto + { + $bookingDto = new BookingDto($travel, 1); + $bookingDto->currentStep = 2; + + return $bookingDto; + } + + /** + * @param array $rooms + */ + private function createTravel(array $rooms): Travel + { + $travel = new Travel(); + $travel->id = 4711; + $travel->code = 'TRV-4711'; + $travel->rooms = $rooms; + + return $travel; + } + + private function createRoom(int $id, string $label, int $minPax): Room + { + $room = new Room(); + $room->id = $id; + $room->label = $label; + $room->available = 4; + $room->minPax = $minPax; + $room->status = Constants::STATUS_AVAILABLE; + + return $room; + } + + private function createRoomSelection(int $id, string $label, int $quantity, int $capacity): RoomSelectionDto + { + $selection = new RoomSelectionDto(); + $selection->id = $id; + $selection->label = $label; + $selection->quantity = $quantity; + $selection->capacity = $capacity; + + return $selection; + } +} + +final class TestableStep2Controller extends Step2Controller +{ + /** + * @var array + */ + public array $flashes = []; + + /** + * @var array + */ + public array $renderParameters = []; + + /** + * @var FormInterface + */ + private readonly FormInterface $form; + + /** + * @param FormInterface $form + */ + public function __construct( + BookingConfigurator $bookingService, + BookingSessionManager $bookingSessionService, + BookingCreateContextFactory $createContextFactory, + TravelDataProvider $travelDataService, + RoomAssigner $roomAssignmentService, + ParticipantDataPrefiller $prepopulationService, + LoggerInterface $logger, + FormInterface $form, + ) { + $this->form = $form; + + parent::__construct( + $bookingService, + $bookingSessionService, + $createContextFactory, + $travelDataService, + $roomAssignmentService, + $prepopulationService, + $logger, + ); + } + + protected function getUser(): ?UserInterface + { + return null; + } + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = [$type, $message]; + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderParameters = $parameters; + + return new Response(''); + } +} diff --git a/tests/Service/BookingConfiguratorRoomReconciliationTest.php b/tests/Service/BookingConfiguratorRoomReconciliationTest.php new file mode 100644 index 00000000..a5e6aef1 --- /dev/null +++ b/tests/Service/BookingConfiguratorRoomReconciliationTest.php @@ -0,0 +1,214 @@ +createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2), + $this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 2), + 1409 => $this->createRoomSelection(1409, quantity: 0), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertSame([], $adjustments); + $this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections)); + $this->assertSame(2, $bookingDto->roomSelections[1408]->quantity); + $this->assertSame(0, $bookingDto->roomSelections[1409]->quantity); + $this->assertSame(2, $bookingDto->roomSelections[1408]->capacity); + $this->assertSame(4, $bookingDto->roomSelections[1408]->maxQuantity); + } + + public function testReportsAndRemovesASelectedRoomThatIsNoLongerBookable(): void + { + $travel = $this->createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2), + $this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 2), + 1409 => $this->createRoomSelection(1409, quantity: 0), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertCount(1, $adjustments); + $this->assertSame(1408, $adjustments[0]->roomId); + $this->assertSame(2, $adjustments[0]->requestedQuantity); + $this->assertSame(0, $adjustments[0]->bookableQuantity); + $this->assertTrue($adjustments[0]->isDropped()); + $this->assertSame([1409], array_keys($bookingDto->roomSelections)); + } + + public function testCapsAQuantityThatExceedsTheRemainingContingent(): void + { + $travel = $this->createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 1, minPax: 2), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 3), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertCount(1, $adjustments); + $this->assertSame(1408, $adjustments[0]->roomId); + $this->assertSame('Aus der Session', $adjustments[0]->label); + $this->assertSame(3, $adjustments[0]->requestedQuantity); + $this->assertSame(1, $adjustments[0]->bookableQuantity); + $this->assertFalse($adjustments[0]->isDropped()); + $this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'The selection carries the capped quantity'); + $this->assertSame(1, $bookingDto->roomSelections[1408]->maxQuantity); + } + + public function testLeavesAQuantityThatStillFitsTheReducedContingent(): void + { + $travel = $this->createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 2, minPax: 2), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 2), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertSame([], $adjustments); + $this->assertSame(2, $bookingDto->roomSelections[1408]->quantity); + } + + public function testRemovesAnUnselectedRoomThatIsNoLongerBookableWithoutReportingIt(): void + { + $travel = $this->createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2), + $this->createRoom(1409, 'Einzelzimmer', available: 0, minPax: 1), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 1), + 1409 => $this->createRoomSelection(1409, quantity: 0), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertSame([], $adjustments); + $this->assertSame([1408], array_keys($bookingDto->roomSelections)); + } + + public function testAddsARoomThatBecameBookableAgain(): void + { + $travel = $this->createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2), + $this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 1), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertSame([], $adjustments); + $this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections)); + $this->assertSame(0, $bookingDto->roomSelections[1409]->quantity); + $this->assertSame('Einzelzimmer', $bookingDto->roomSelections[1409]->label); + } + + public function testEmptiesTheSelectionsWhenTheTravelSoldOutCompletely(): void + { + $travel = $this->createTravel([ + $this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2), + ]); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->roomSelections = [ + 1408 => $this->createRoomSelection(1408, quantity: 2), + ]; + + $adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto); + + $this->assertCount(1, $adjustments); + $this->assertTrue($adjustments[0]->isDropped()); + $this->assertSame([], $bookingDto->roomSelections); + } + + private function bookingService(): BookingConfigurator + { + return new BookingConfigurator( + $this->createStub(BookingSessionManager::class), + $this->createStub(TravelDataProvider::class), + $this->createStub(ParticipantEligibilityChecker::class), + $this->createStub(BookingStatusRuleRegistry::class), + $this->createStub(AgencyLoader::class), + 'F' + ); + } + + /** + * @param array $rooms + */ + private function createTravel(array $rooms): Travel + { + $travel = new Travel(); + $travel->id = 4711; + $travel->code = 'TRV-4711'; + $travel->rooms = $rooms; + + return $travel; + } + + private function createRoom(int $id, string $label, int $available, int $minPax): Room + { + $room = new Room(); + $room->id = $id; + $room->label = $label; + $room->available = $available; + $room->minPax = $minPax; + $room->status = Constants::STATUS_AVAILABLE; + $room->price = 199.0; + + return $room; + } + + private function createRoomSelection(int $id, int $quantity): RoomSelectionDto + { + $selection = new RoomSelectionDto(); + $selection->id = $id; + $selection->label = 'Aus der Session'; + $selection->quantity = $quantity; + $selection->capacity = 2; + $selection->maxQuantity = 9; + + return $selection; + } +}