feat: refactored accommodation booking status model

This commit is contained in:
Björn Fromme
2026-08-19 11:46:52 +02:00
parent e8accbb3ed
commit 5a3957e143
42 changed files with 1190 additions and 277 deletions
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
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 DiscardController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/discard', name: 'app_admin_accommodationbooking_discard')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// A confirmation that has gone out is binding, so a confirmed booking is not something
// the office can walk back here; an already discarded one has nothing left to do.
if ($booking->isConfirmed() || $booking->isDiscarded()) {
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('discard_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->bookingService->discardBooking($booking);
$this->addFlash('success', 'Die Buchung wurde abgesagt.');
$this->logger->info('Discarded accommodation booking', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_discard.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'discard_accommodation_booking_'.$booking->getId(),
]);
}
}
@@ -7,7 +7,6 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
@@ -93,8 +92,6 @@ class EditController extends AbstractController
'current_additional_services' => $currentAdditionalServices,
'assignable_managers' => $assignableManagers,
]);
$previousStatus = $booking->getStatus();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -125,19 +122,12 @@ class EditController extends AbstractController
$this->bookingService->refreshPriceSnapshot($booking);
// Editing never moves the booking on and never mails the customer: every status
// change is its own named action (Angebot senden, bestätigen, absagen), so the
// status, its timestamp and its email can no longer drift apart.
$this->entityManager->persist($booking);
$this->entityManager->flush();
// Entering Offen is what the customer needs to hear about: the offer becomes
// viewable. Every other status stays silent here — in particular Bestätigt, which
// is only ever reached through the explicit confirm action so that the binding
// confirmation is never sent as a side effect of editing.
$notifiableStatuses = [AccommodationBookingStatus::Open];
if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) {
$this->bookingService->issueAccessLink($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
}
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
$this->logger->info('Updated accommodation booking', [
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
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 SendOfferController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/send-offer', name: 'app_admin_accommodationbooking_send_offer')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// Only a booking still waiting for its offer can have one sent — anything further
// along has already been put in front of the customer or been dealt with.
if (!$booking->isDraft() && !$booking->isRequested()) {
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('send_offer_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
// The offer only exists for the customer as the email carrying its link, so
// publishing it without a deliverable address would strand the booking in Offen.
if (null === $booking->getEmail() || '' === trim($booking->getEmail())) {
$this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um das Angebot zu versenden.');
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]));
}
$this->bookingService->sendOffer($booking);
$this->addFlash('success', 'Das Angebot wurde dem Kunden per E-Mail zugestellt.');
$this->logger->info('Sent accommodation booking offer', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_send_offer.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'send_offer_accommodation_booking_'.$booking->getId(),
]);
}
}
+7 -4
View File
@@ -94,7 +94,9 @@ class OfferController extends AbstractController
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isInquiry() || !$booking->isOpen()) {
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
@@ -120,12 +122,13 @@ class OfferController extends AbstractController
}
/**
* A draft is not ready to be shown, and a discarded booking must not be viewable
* any more — in both cases the link behaves as if it had expired.
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
private function isCustomerVisible(AccommodationBooking $booking): bool
{
return !$booking->isDraft() && !$booking->isDiscarded();
return !$booking->isDraft() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
+1 -1
View File
@@ -153,7 +153,7 @@ class Step4Controller extends AbstractAccommodationController
{
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
$this->addFlash('groups_booking_result', $booking->getType()->value);
$this->addFlash('groups_booking_result', $booking->getOrigin()->value);
$this->sessionManager->clear($request);
}
}