From 3db1850e4f89d48407dafdcd70eb494c2627311e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 20 Aug 2026 12:34:44 +0200 Subject: [PATCH] feat: edit selected accommodation of bookings in draft --- .../ChangeAccommodationController.php | 88 ++++++++ ...modationBookingChangeAccommodationType.php | 54 +++++ src/Service/AccommodationBookingService.php | 29 +++ .../accommodation_booking/edit.html.twig | 21 +- .../modal_change_accommodation.html.twig | 20 ++ .../ChangeAccommodationControllerTest.php | 205 ++++++++++++++++++ ...tionBookingChangeAccommodationTypeTest.php | 71 ++++++ .../AccommodationBookingServiceTest.php | 71 ++++++ 8 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 src/Controller/Admin/AccommodationBooking/ChangeAccommodationController.php create mode 100644 src/Form/Admin/Groups/AccommodationBookingChangeAccommodationType.php create mode 100644 templates/admin/accommodation_booking/modal_change_accommodation.html.twig create mode 100644 tests/Controller/Admin/AccommodationBooking/ChangeAccommodationControllerTest.php create mode 100644 tests/Form/Admin/Groups/AccommodationBookingChangeAccommodationTypeTest.php diff --git a/src/Controller/Admin/AccommodationBooking/ChangeAccommodationController.php b/src/Controller/Admin/AccommodationBooking/ChangeAccommodationController.php new file mode 100644 index 0000000..73d3d02 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/ChangeAccommodationController.php @@ -0,0 +1,88 @@ +isDraft()) { + $this->addFlash('error', 'Das Gruppenhaus lässt sich nur bei einem Entwurf ändern.'); + + return $this->htmxRedirect($request, $this->editUrl($booking, $request)); + } + + $form = $this->createForm(AccommodationBookingChangeAccommodationType::class, $booking, [ + 'current_accommodation' => $booking->getAccommodation(), + 'hx_post' => $request->getRequestUri(), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $selected = $form->get('accommodation')->getData(); + + if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) { + $this->bookingService->changeAccommodation($booking, $selected); + + $this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.'); + + $this->logger->info('Changed accommodation of accommodation booking', [ + 'id' => $booking->getId(), + 'accommodation' => $selected->getName(), + ]); + } + + // Back to the edit form, which is rebuilt from the new house: its board and + // additional service choices are resolved server-side and cannot be swapped in + // place, which is the whole reason this is a modal and not a field on that form. + return $this->htmxRedirect($request, $this->editUrl($booking, $request)); + } + + return $this->render('admin/accommodation_booking/modal_change_accommodation.html.twig', [ + 'booking' => $booking, + 'form' => $form, + ]); + } + + /** + * The return url is forwarded still encoded, the way return_url() handed it over, so that + * the edit page keeps leading back to the list the booking was opened from. + */ + private function editUrl(AccommodationBooking $booking, Request $request): string + { + $parameters = ['id' => $booking->getId()]; + $returnUrl = $request->query->getString('r'); + if ('' !== $returnUrl) { + $parameters['r'] = $returnUrl; + } + + return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters); + } +} diff --git a/src/Form/Admin/Groups/AccommodationBookingChangeAccommodationType.php b/src/Form/Admin/Groups/AccommodationBookingChangeAccommodationType.php new file mode 100644 index 0000000..51393a4 --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationBookingChangeAccommodationType.php @@ -0,0 +1,54 @@ + + */ +class AccommodationBookingChangeAccommodationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('accommodation', EntityType::class, [ + 'class' => Accommodation::class, + 'mapped' => false, + 'choice_label' => 'name', + 'label' => 'Gruppenhaus neu', + 'data' => $options['current_accommodation'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationBooking::class, + // Only ever reached for an Entwurf, which is a scratch record the office fills in + // over time — the `edit` group would reject it over contact data this form does + // not even show. + 'validation_groups' => ['Default'], + 'current_accommodation' => null, + ]); + $resolver->setAllowedTypes('current_accommodation', ['null', Accommodation::class]); + } +} diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index c77f324..104f4ce 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -530,6 +530,35 @@ class AccommodationBookingService $this->sendCustomerConfirmationEmail($booking); } + /** + * Moves a draft to another Gruppenhaus, for the case where the wrong one was picked at + * creation. The chosen Verpflegung and Zusatzleistungen belong to the old house's catalog — + * they carry its prices and its ids — so they are dropped rather than guessed at; the office + * picks them again from the new catalog on the edit page. + * Restricted to Entwurf: anything further along may already be in front of the customer via + * their access link, and swapping the house underneath them would rewrite what they were + * offered. + * Idempotent — a no-op for a booking that is not a draft or already sits at $accommodation. + */ + public function changeAccommodation(AccommodationBooking $booking, Accommodation $accommodation): void + { + if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) { + return; + } + + $booking->setAccommodation($accommodation); + + $booking->setBoardServiceLabel(null); + $booking->setBoardServicePrice(null); + $booking->setBoardServiceOriginalId(null); + $booking->setAdditionalServices([]); + + // Clears the snapshot by itself when the new house has no price for the booked range. + $this->refreshPriceSnapshot($booking); + + $this->entityManager->flush(); + } + /** * Closes a booking that is not going to happen. Deliberately silent: the office tells * the customer itself, so this only records the outcome. diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index f8fe803..39b26ee 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -1,8 +1,23 @@ {% extends 'layout_admin.html.twig' %} {% block content %} + {% set back_params = { 'id': booking.id } %} + {% if app.request.query.get('r') %} + {% set back_params = back_params | merge({ 'r': app.request.query.get('r') }) %} + {% endif %} + {{ booking.recordLabel }} {{ booking.groupName }} -

{{ booking.accommodation.name }}

+

+ {{ booking.accommodation.name }} + {# Only an Entwurf may still move house — see ChangeAccommodationController. #} + {% if booking.draft %} + + {% endif %} +

{% form_theme form 'forms_admin.html.twig' %} @@ -69,10 +84,6 @@
- {% set back_params = { 'id': booking.id } %} - {% if app.request.query.get('r') %} - {% set back_params = back_params | merge({ 'r': app.request.query.get('r') }) %} - {% endif %} zurück diff --git a/templates/admin/accommodation_booking/modal_change_accommodation.html.twig b/templates/admin/accommodation_booking/modal_change_accommodation.html.twig new file mode 100644 index 0000000..22afec3 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_change_accommodation.html.twig @@ -0,0 +1,20 @@ +{% extends 'htmx_modal_admin.html.twig' %} +{% form_theme form 'forms_admin.html.twig' %} + +{% block content %} +
+ Gruppenhaus ändern +
+

+ Achtung: Die gewählte Verpflegung und alle Zusatzleistungen müssen anschließend neu ausgewählt werden. +

+ {{ form_start(form) }} + {{ form_row(form.accommodation) }} +
+ +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/tests/Controller/Admin/AccommodationBooking/ChangeAccommodationControllerTest.php b/tests/Controller/Admin/AccommodationBooking/ChangeAccommodationControllerTest.php new file mode 100644 index 0000000..66f2233 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/ChangeAccommodationControllerTest.php @@ -0,0 +1,205 @@ +createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('changeAccommodation'); + + $controller = $this->buildController($bookingService, $this->unsubmittedForm()); + + $response = $controller->index($this->draft(), $this->request()); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('admin/accommodation_booking/modal_change_accommodation.html.twig', $controller->renderedView); + } + + /** + * @dataProvider lockedStatuses + */ + public function testABookingThatHasLeftEntwurfCannotMoveHouse(AccommodationBookingStatus $status): void + { + // The customer may already be looking at the offer under their access link. + $booking = $this->draft(); + $booking->setStatus($status); + $selected = new Accommodation(); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('changeAccommodation'); + + $controller = $this->buildController($bookingService, $this->submittedForm($selected)); + + $response = $controller->index($booking, $this->request('POST')); + + self::assertTrue($response->headers->has('HX-Redirect')); + self::assertNull($controller->renderedView); + self::assertSame('error', $controller->flashes[0]['type']); + } + + /** + * @return iterable + */ + public static function lockedStatuses(): iterable + { + yield 'requested' => [AccommodationBookingStatus::Requested]; + yield 'open' => [AccommodationBookingStatus::Open]; + yield 'received' => [AccommodationBookingStatus::Received]; + yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; + yield 'discarded' => [AccommodationBookingStatus::Discarded]; + } + + public function testAValidSubmitChangesTheHouseAndSendsTheBrowserBackToTheEditForm(): void + { + $booking = $this->draft(); + $selected = new Accommodation(); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('changeAccommodation')->with($booking, $selected); + + $controller = $this->buildController($bookingService, $this->submittedForm($selected)); + + $response = $controller->index($booking, $this->request('POST')); + + self::assertSame( + '/app_admin_accommodationbooking_edit?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', + $response->headers->get('HX-Redirect'), + 'the still-encoded return url has to survive so the edit page leads back to the list', + ); + self::assertSame('success', $controller->flashes[0]['type']); + } + + public function testPickingTheSameHouseIsNotReportedAsAChange(): void + { + $booking = $this->draft(); + $accommodation = $booking->getAccommodation(); + self::assertNotNull($accommodation); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('changeAccommodation'); + + $controller = $this->buildController($bookingService, $this->submittedForm($accommodation)); + + $response = $controller->index($booking, $this->request('POST')); + + self::assertTrue($response->headers->has('HX-Redirect')); + self::assertSame([], $controller->flashes); + } + + private function draft(): AccommodationBooking + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Draft); + $booking->setAccommodation((new Accommodation())->setName('Seehaus')); + + return $booking; + } + + private function request(string $method = 'GET'): Request + { + // Every entry point is htmx — the button opens the modal, the modal posts back — so + // the header is what makes htmxRedirect() answer with HX-Redirect rather than a 302. + return Request::create( + '/admin/accommodation-booking/1/change-accommodation?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', + $method, + server: ['HTTP_HX-Request' => 'true'], + ); + } + + private function unsubmittedForm(): FormInterface + { + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturn($form); + $form->method('isSubmitted')->willReturn(false); + + return $form; + } + + private function submittedForm(Accommodation $selected): FormInterface + { + $field = $this->createMock(FormInterface::class); + $field->method('getData')->willReturn($selected); + + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturn($form); + $form->method('isSubmitted')->willReturn(true); + $form->method('isValid')->willReturn(true); + $form->method('get')->with('accommodation')->willReturn($field); + + return $form; + } + + private function buildController( + AccommodationBookingService $bookingService, + FormInterface $form, + ): TestableChangeAccommodationController { + return new TestableChangeAccommodationController( + $bookingService, + $this->createMock(LoggerInterface::class), + $form, + ); + } +} + +final class TestableChangeAccommodationController extends ChangeAccommodationController +{ + public ?string $renderedView = null; + + /** @var list */ + public array $flashes = []; + + public function __construct( + AccommodationBookingService $bookingService, + LoggerInterface $logger, + private readonly FormInterface $form, + ) { + parent::__construct($bookingService, $logger); + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + + return new Response(); + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = ['type' => $type, 'message' => $message]; + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Form/Admin/Groups/AccommodationBookingChangeAccommodationTypeTest.php b/tests/Form/Admin/Groups/AccommodationBookingChangeAccommodationTypeTest.php new file mode 100644 index 0000000..64da942 --- /dev/null +++ b/tests/Form/Admin/Groups/AccommodationBookingChangeAccommodationTypeTest.php @@ -0,0 +1,71 @@ +builtFields())); + } + + public function testTheFieldIsUnmapped(): void + { + // Swapping the house drops the booked services with it, so the change has to go + // through AccommodationBookingService::changeAccommodation() as one transition — the + // form must not write it onto the booking on its own, least of all on a failed submit. + self::assertFalse($this->builtFields()['accommodation']['mapped']); + } + + public function testTheCurrentHouseIsPreselected(): void + { + $accommodation = (new Accommodation())->setName('Seehaus'); + + $fields = $this->builtFields(['current_accommodation' => $accommodation]); + + self::assertSame($accommodation, $fields['accommodation']['data']); + } + + public function testADraftIsValidatedAsAScratchRecord(): void + { + // The action is limited to Entwurf, whose contact data is deliberately still incomplete. + $resolver = new OptionsResolver(); + (new AccommodationBookingChangeAccommodationType())->configureOptions($resolver); + + self::assertSame(['Default'], $resolver->resolve()['validation_groups']); + } + + /** + * @param array $options + * + * @return array> + */ + private function builtFields(array $options = []): array + { + $fields = []; + + $builder = $this->createMock(FormBuilderInterface::class); + $builder->method('add')->willReturnCallback( + static function (string $name, ?string $type = null, array $fieldOptions = []) use (&$fields, $builder) { + $fields[$name] = $fieldOptions; + + return $builder; + }, + ); + + $resolver = new OptionsResolver(); + $type = new AccommodationBookingChangeAccommodationType(); + $type->configureOptions($resolver); + $type->buildForm($builder, $resolver->resolve($options)); + + return $fields; + } +} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index 813286c..6cd9aa4 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -11,6 +11,7 @@ use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationPrice; use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingStatus; +use App\Enum\Groups\AdditionalServiceType; use App\Form\Model\AccommodationBookingDto; use App\Model\AccommodationBookingQueryParams; use App\Repository\Groups\AccommodationPriceRepository; @@ -859,6 +860,76 @@ class AccommodationBookingServiceTest extends TestCase self::assertSame(1, $booking->getPricingVersion()); } + public function testChangeAccommodationSwapsTheHouseAndDropsTheBookedServices(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = $this->draftWithServices(); + $newAccommodation = (new Accommodation())->setName('Berghaus')->setCalendarCode('BERG'); + + $service->changeAccommodation($booking, $newAccommodation); + + self::assertSame($newAccommodation, $booking->getAccommodation()); + // The services belong to the old house's catalog and would carry its prices along. + self::assertNull($booking->getBoardServiceLabel()); + self::assertNull($booking->getBoardServicePrice()); + self::assertNull($booking->getBoardServiceOriginalId()); + self::assertSame([], $booking->getAdditionalServices()); + self::assertNull($booking->getTotalPrice(), 'the frozen price belonged to the old house'); + } + + public function testChangeAccommodationNoOpsForANonDraft(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = $this->draftWithServices(); + $booking->setStatus(AccommodationBookingStatus::Open); + $oldAccommodation = $booking->getAccommodation(); + + $service->changeAccommodation($booking, (new Accommodation())->setName('Berghaus')); + + self::assertSame($oldAccommodation, $booking->getAccommodation()); + self::assertSame('Vollpension', $booking->getBoardServiceLabel()); + self::assertCount(1, $booking->getAdditionalServices()); + } + + public function testChangeAccommodationNoOpsWhenTheHouseIsUnchanged(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = $this->draftWithServices(); + $accommodation = $booking->getAccommodation(); + self::assertNotNull($accommodation); + + $service->changeAccommodation($booking, $accommodation); + + self::assertSame('Vollpension', $booking->getBoardServiceLabel()); + self::assertCount(1, $booking->getAdditionalServices()); + } + + private function draftWithServices(): AccommodationBooking + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Draft); + $booking->setAccommodation((new Accommodation())->setName('Seehaus')->setCalendarCode('SEE')); + $booking->setBoardServiceLabel('Vollpension'); + $booking->setBoardServicePrice(4500); + $booking->setBoardServiceOriginalId(7); + $booking->addAdditionalServiceSnapshot('Bettwäsche', 1200, AdditionalServiceType::Flat, 12); + $booking->setPriceSnapshot(['total' => 12345], 12345, 'EUR', 1); + + return $booking; + } + /** * @param AccommodationPrice[] $prices */