feat: edit selected accommodation of bookings in draft
This commit is contained in:
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user