feat: edit selected accommodation of bookings in draft

This commit is contained in:
Björn Fromme
2026-08-20 12:34:44 +02:00
parent c1f0cda479
commit 3db1850e4f
8 changed files with 554 additions and 5 deletions
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class ChangeAccommodationController extends AbstractController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/change-accommodation', name: 'app_admin_accommodationbooking_change_accommodation')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// Correcting the house is for a scratch record only. Once an offer is out, the customer
// can see it under their access link, and rewriting what they were offered is not
// something that belongs in an edit — such a booking gets discarded and redone instead.
if (!$booking->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);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Corrects the Gruppenhaus of a draft that was created against the wrong one. It is its own
* form rather than a field on AccommodationBookingType because the service choices there are
* resolved server-side from the current accommodation — changing it has to rebuild the whole
* edit form, which is why this runs as a modal that redirects back to it.
*
* The field is unmapped, the way the service fields on the edit form are: swapping the house
* drops the booked services along with it, so the change goes through
* AccommodationBookingService::changeAccommodation() as one transition instead of being
* written onto the entity piecemeal by the form.
*
* @extends AbstractType<AccommodationBooking>
*/
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]);
}
}
@@ -530,6 +530,35 @@ class AccommodationBookingService
$this->sendCustomerConfirmationEmail($booking); $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 * Closes a booking that is not going to happen. Deliberately silent: the office tells
* the customer itself, so this only records the outcome. * the customer itself, so this only records the outcome.
@@ -1,8 +1,23 @@
{% extends 'layout_admin.html.twig' %} {% extends 'layout_admin.html.twig' %}
{% block content %} {% 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 %}
<twig:page:heading>{{ booking.recordLabel }} {{ booking.groupName }}</twig:page:heading> <twig:page:heading>{{ booking.recordLabel }} {{ booking.groupName }}</twig:page:heading>
<p class="text-sm text-gray-500 mb-6">{{ booking.accommodation.name }}</p> <p class="flex items-center gap-3 text-sm text-gray-500 mb-6">
{{ booking.accommodation.name }}
{# Only an Entwurf may still move house — see ChangeAccommodationController. #}
{% if booking.draft %}
<button type="button"
hx-get="{{ path('app_admin_accommodationbooking_change_accommodation', back_params) }}"
hx-target="body"
hx-swap="beforeend"
class="button button--secondary button--small">ändern</button>
{% endif %}
</p>
{% form_theme form 'forms_admin.html.twig' %} {% form_theme form 'forms_admin.html.twig' %}
@@ -69,10 +84,6 @@
</div> </div>
</div> </div>
<div class="flex justify-between pt-8"> <div class="flex justify-between pt-8">
{% 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 %}
<a href="{{ path('app_admin_accommodationbooking_show', back_params) }}" class="button button--secondary button--small"> <a href="{{ path('app_admin_accommodationbooking_show', back_params) }}" class="button button--secondary button--small">
zurück zurück
</a> </a>
@@ -0,0 +1,20 @@
{% extends 'htmx_modal_admin.html.twig' %}
{% form_theme form 'forms_admin.html.twig' %}
{% block content %}
<div class="text-xl font-bold pb-4">
Gruppenhaus ändern
</div>
<p class="text-sm text-gray-500 pb-4">
Achtung: Die gewählte Verpflegung und alle Zusatzleistungen müssen anschließend neu ausgewählt werden.
</p>
{{ form_start(form) }}
{{ form_row(form.accommodation) }}
<div class="flex justify-end pt-8">
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\ChangeAccommodationController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Covers the guards around correcting the Gruppenhaus — the transition itself is tested in
* AccommodationBookingServiceTest.
*/
class ChangeAccommodationControllerTest extends TestCase
{
public function testGetRendersTheModalForADraft(): void
{
$bookingService = $this->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<string, array{AccommodationBookingStatus}>
*/
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<array{type: string, message: mixed}> */
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<string, mixed> $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<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Admin\Groups;
use App\Entity\Groups\Accommodation;
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AccommodationBookingChangeAccommodationTypeTest extends TestCase
{
public function testTheHouseIsTheOnlyThingOnOffer(): void
{
self::assertSame(['accommodation'], array_keys($this->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<string, mixed> $options
*
* @return array<string, array<string, mixed>>
*/
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;
}
}
@@ -11,6 +11,7 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice; use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams; use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationPriceRepository;
@@ -859,6 +860,76 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame(1, $booking->getPricingVersion()); 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 * @param AccommodationPrice[] $prices
*/ */