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);
}
}
+37 -20
View File
@@ -9,8 +9,8 @@ use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types;
@@ -81,12 +81,12 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private AccommodationBookingStatus $status = AccommodationBookingStatus::Draft;
/**
* What kind of record this is — an offer the customer still has to accept, or a
* directly placed, binding booking. Independent of the status: an accepted offer
* keeps its Inquiry type, which is how offer-originated bookings stay recognisable.
* How this record came about — through an offer the customer had to accept, or as a
* booking the customer placed directly. Orthogonal to the status and never changed
* after creation, so it stays truthful at every point of the lifecycle.
*/
#[ORM\Column(length: 20, enumType: AccommodationBookingType::class)]
private AccommodationBookingType $type = AccommodationBookingType::Inquiry;
#[ORM\Column(length: 20, enumType: AccommodationBookingOrigin::class)]
private AccommodationBookingOrigin $origin = AccommodationBookingOrigin::Offer;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $accessLinkIssuedAt = null;
@@ -118,14 +118,15 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private ?string $lastName = null;
/**
* The `offer` group covers the one field a customer-facing booking cannot do without:
* the access link and every notification are addressed to it. It is enforced on
* creation as soon as the booking is not a draft, while `edit` additionally demands
* the rest of the contact data.
* The one field a customer-facing booking cannot do without — the access link and every
* notification are addressed to it. A record on its way to the customer is validated with
* `edit`, which additionally demands the rest of the contact data; the actions that
* actually reach out (SendOfferController, ConfirmController) check the address again on
* their own, since a draft may legitimately be saved without one.
*/
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(groups: ['edit', 'offer'])]
#[Assert\Email(groups: ['edit', 'offer'])]
#[Assert\NotBlank(groups: ['edit'])]
#[Assert\Email(groups: ['edit'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
@@ -376,6 +377,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Draft === $this->status;
}
public function isRequested(): bool
{
return AccommodationBookingStatus::Requested === $this->status;
}
public function isOpen(): bool
{
return AccommodationBookingStatus::Open === $this->status;
@@ -396,26 +402,37 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Discarded === $this->status;
}
public function getType(): AccommodationBookingType
public function getOrigin(): AccommodationBookingOrigin
{
return $this->type;
return $this->origin;
}
public function setType(AccommodationBookingType $type): self
public function setOrigin(AccommodationBookingOrigin $origin): self
{
$this->type = $type;
$this->origin = $origin;
return $this;
}
public function isInquiry(): bool
public function isFromOffer(): bool
{
return AccommodationBookingType::Inquiry === $this->type;
return AccommodationBookingOrigin::Offer === $this->origin;
}
public function isBooking(): bool
public function isDirectBooking(): bool
{
return AccommodationBookingType::Booking === $this->type;
return AccommodationBookingOrigin::Direct === $this->origin;
}
/**
* What to call this record wherever it is named next to its status. An offer becomes a
* booking the moment the customer commits, which is exactly what acceptedAt records — so
* this can never contradict the status the way a stored type could. The origin is a
* separate question ("where did this come from") and keeps its own label.
*/
public function recordLabel(): string
{
return null !== $this->acceptedAt ? 'Buchung' : 'Angebot';
}
public function getAccessLinkIssuedAt(): ?\DateTimeImmutable
@@ -0,0 +1,23 @@
<?php
namespace App\Enum\Groups;
/**
* How a booking record came about — deliberately orthogonal to the status, which
* carries the lifecycle. The origin is set once when the record is created and
* never changes afterwards, so an accepted offer stays recognisable as one without
* the origin ever contradicting the status it has reached.
*/
enum AccommodationBookingOrigin: string
{
case Offer = 'offer';
case Direct = 'direct';
public function label(): string
{
return match ($this) {
self::Offer => 'Angebot',
self::Direct => 'Direktbuchung',
};
}
}
@@ -2,9 +2,17 @@
namespace App\Enum\Groups;
/**
* The single lifecycle of a booking record. Every case names exactly one state and
* exactly one party it is waiting on: Angefragt and Entwurf wait on the office to
* prepare an offer, Offen waits on the customer to accept it, Eingegangen waits on
* the office to confirm. Where the record came from is not encoded here — that is
* what AccommodationBookingOrigin is for.
*/
enum AccommodationBookingStatus: string
{
case Draft = 'draft';
case Requested = 'requested';
case Open = 'open';
case Received = 'received';
case Confirmed = 'confirmed';
@@ -14,6 +22,7 @@ enum AccommodationBookingStatus: string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Requested => 'Angefragt',
self::Open => 'Offen',
self::Received => 'Eingegangen',
self::Confirmed => 'Bestätigt',
@@ -1,17 +0,0 @@
<?php
namespace App\Enum\Groups;
enum AccommodationBookingType: string
{
case Inquiry = 'inquiry';
case Booking = 'booking';
public function label(): string
{
return match ($this) {
self::Inquiry => 'Anfrage',
self::Booking => 'Buchung',
};
}
}
@@ -6,8 +6,8 @@ namespace App\Form\Admin\Filter;
use App\Entity\Groups\Accommodation;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -30,10 +30,10 @@ class AccommodationBookingFilterType extends AbstractListFilterType
'expanded' => true,
'required' => false,
]],
'type' => [EnumType::class, [
'label' => 'Art',
'class' => AccommodationBookingType::class,
'choice_label' => static fn (AccommodationBookingType $type) => $type->label(),
'origin' => [EnumType::class, [
'label' => 'Herkunft',
'class' => AccommodationBookingOrigin::class,
'choice_label' => static fn (AccommodationBookingOrigin $origin) => $origin->label(),
'multiple' => true,
'expanded' => true,
'required' => false,
@@ -6,15 +6,12 @@ namespace App\Form\Admin\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -50,17 +47,9 @@ class AccommodationBookingCreateType extends AbstractType
->add('childrenCount', IntegerType::class, [
'label' => 'davon Kinder',
])
->add('status', EnumType::class, [
'label' => 'Status',
'class' => AccommodationBookingStatus::class,
'choices' => [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Open,
],
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
// Not marked required in HTML: a draft may be saved without it, and the
// validation group below enforces it for everything else.
// Not marked required in HTML: a new record is always an Entwurf, which is a
// scratch record nobody is pointed at yet. The address is enforced when the
// offer is actually sent — see SendOfferController.
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
@@ -73,15 +62,10 @@ class AccommodationBookingCreateType extends AbstractType
{
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
// A draft is a scratch record, but anything the customer can be pointed at
// needs a reachable address — see the `offer` group on the entity. The rest of
// the contact data is collected on the edit page, which validates `edit`.
'validation_groups' => static function (FormInterface $form): array {
/** @var AccommodationBooking $booking */
$booking = $form->getData();
return $booking->isDraft() ? ['Default'] : ['Default', 'offer'];
},
// Creation always yields an Entwurf, a scratch record the office fills in over
// time, so nothing beyond the basics is required here. The contact data is
// collected on the edit page, which validates `edit`.
'validation_groups' => ['Default'],
]);
}
}
@@ -8,13 +8,11 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -137,11 +135,8 @@ class AccommodationBookingType extends AbstractType
}
$builder
->add('status', EnumType::class, [
'label' => 'Status',
'class' => AccommodationBookingStatus::class,
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
// No status field: the status is only ever moved by a named action, so that it
// can never end up set without the timestamp and email that belong to it.
->add('accommodationDiscount', IntegerType::class, [
'label' => 'Rabatt Unterkunft (%)',
'required' => false,
@@ -175,9 +170,9 @@ class AccommodationBookingType extends AbstractType
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
// Entwurf is a scratch record the office fills in over time, and an Absage is
// never going anywhere — neither needs complete contact data. The groups are
// resolved after binding, so promoting a draft to Anfrage or Buchung enforces
// the contact data in the very same save.
// never going anywhere — neither needs complete contact data. Everything else,
// Angefragt included, has already been in contact with the customer and is
// expected to carry the full set.
'validation_groups' => static function (FormInterface $form): array {
/** @var AccommodationBooking $booking */
$booking = $form->getData();
@@ -6,8 +6,8 @@ namespace App\Form\Model\Filter;
use App\Entity\Groups\Accommodation;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Model\ListFilterChip;
class AccommodationBookingFilterDto extends AbstractListFilterDto
@@ -15,8 +15,8 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
/** @var AccommodationBookingStatus[] */
public array $status = [];
/** @var AccommodationBookingType[] */
public array $type = [];
/** @var AccommodationBookingOrigin[] */
public array $origin = [];
public ?User $managedBy = null;
@@ -38,15 +38,17 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
/**
* The list as it presents itself to someone who has not filtered yet.
*
* The still-live statuses — drafts, open bookings and bookings awaiting validation, i.e.
* everything that may still need work — nothing whose stay is already over, and, for
* staff who are not group admins, only the bookings they are responsible for.
* The still-live statuses — drafts, inquiries awaiting an offer, offers out with the
* customer and bookings awaiting validation, i.e. everything that may still need work —
* nothing whose stay is already over, and, for staff who are not group admins, only the
* bookings they are responsible for.
*/
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
{
$filter = new self();
$filter->status = [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Requested,
AccommodationBookingStatus::Open,
AccommodationBookingStatus::Received,
];
@@ -72,11 +74,11 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
);
}
if ([] !== $this->type) {
if ([] !== $this->origin) {
$chips[] = new ListFilterChip(
'Art',
implode(', ', array_map(static fn (AccommodationBookingType $t) => $t->label(), $this->type)),
['type'],
'Herkunft',
implode(', ', array_map(static fn (AccommodationBookingOrigin $o) => $o->label(), $this->origin)),
['origin'],
);
}
@@ -95,7 +95,7 @@ final readonly class AccommodationBookingApiResponse
{
$this->uuid = $booking->getUuid();
$this->status = $booking->getStatus()->value;
$this->type = $booking->getType()->value;
$this->type = $booking->getOrigin()->value;
$this->dateFrom = $booking->getDateFrom();
$this->dateTo = $booking->getDateTo();
$this->nights = $booking->getNights();
@@ -84,8 +84,8 @@ class AccommodationBookingRepository extends ServiceEntityRepository
$qb->andWhere('booking.status IN (:status)')->setParameter('status', $filter->status);
}
if ([] !== $filter->type) {
$qb->andWhere('booking.type IN (:type)')->setParameter('type', $filter->type);
if ([] !== $filter->origin) {
$qb->andWhere('booking.origin IN (:origin)')->setParameter('origin', $filter->origin);
}
if ($filter->unassigned) {
@@ -98,7 +98,8 @@ class AccommodationBookingPdfGenerator
$groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString();
return sprintf(
'buchung-%s%s.pdf',
'%s-%s%s.pdf',
mb_strtolower($booking->recordLabel()),
$booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum',
'' !== $groupName ? '-'.$groupName : ''
);
+56 -17
View File
@@ -11,8 +11,8 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
@@ -268,12 +268,12 @@ class AccommodationBookingService
$booking->setPaxCount($dto->paxCount);
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
// An inquiry arrives live — the office has nothing to prepare first, so it goes
// straight to Offen. A direct booking has been committed to by the customer but
// still awaits the office's validation, hence Eingegangen rather than Bestätigt.
// An inquiry still needs an offer the office has to prepare, so it waits at Angefragt
// until that offer goes out. A direct booking has been committed to by the customer
// but still awaits the office's validation, hence Eingegangen rather than Bestätigt.
$isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry;
$booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking);
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Received);
$booking->setOrigin($isInquiry ? AccommodationBookingOrigin::Offer : AccommodationBookingOrigin::Direct);
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Requested : AccommodationBookingStatus::Received);
if (!$isInquiry) {
$booking->setAcceptedAt(new \DateTimeImmutable());
}
@@ -342,7 +342,7 @@ class AccommodationBookingService
// A direct booking gets no customer mail here: it only reaches the customer once the
// office has validated it and confirmed via confirmBooking(). Acknowledging an
// inquiry is a different matter — it promises nothing, so it still goes out.
if ($booking->isInquiry()) {
if ($booking->isFromOffer()) {
$this->sendCustomerConfirmationEmail($booking);
}
@@ -375,7 +375,7 @@ class AccommodationBookingService
$this->accommodationEmail,
sprintf(
'Neue Unterkunfts%s: %s',
$booking->isInquiry() ? 'anfrage' : 'buchung',
$booking->isDirectBooking() ? 'buchung' : 'anfrage',
$booking->getAccommodation()?->getName(),
),
'email/accommodation_booking.html.twig',
@@ -457,7 +457,7 @@ class AccommodationBookingService
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
{
if (!$booking->isBooking()) {
if (!$booking->isDirectBooking()) {
return;
}
@@ -465,13 +465,14 @@ class AccommodationBookingService
}
/**
* Always sends a confirmation email to the customer, whether or not an access link
* exists yet — the template renders differently depending on accessLink being present,
* so the subject has to follow the same distinction.
* Serves both customer-facing messages of the pre-booking phase: the acknowledgement
* that an inquiry arrived, and the offer itself. Offen is the status that means the
* offer is out, so it is what decides the subject — the template makes the same
* distinction via accessLink, which is always issued as the offer goes out.
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
$subject = null !== $booking->getAccessLinkIssuedAt()
$subject = $booking->isOpen()
? 'Dein Angebot ist bereit'
: 'Deine Anfrage ist bei uns eingegangen';
@@ -496,9 +497,45 @@ class AccommodationBookingService
}
/**
* Accepts an open offer and notifies the office. The type stays Anfrage —
* only the status moves to Eingegangen, so an offer-originated booking remains
* distinguishable from a direct one.
* Publishes the prepared offer: the single action that puts an offer in front of the
* customer, and the only transition into Offen. Issues the access link the email links
* to, so the two can never drift apart the way they could when sending was a side
* effect of editing.
* Idempotent — a no-op (including no email) for anything that is not awaiting an offer.
*/
public function sendOffer(AccommodationBooking $booking): void
{
if (!$booking->isDraft() && !$booking->isRequested()) {
return;
}
$this->issueAccessLink($booking);
$booking->setStatus(AccommodationBookingStatus::Open);
$this->entityManager->flush();
$this->sendCustomerConfirmationEmail($booking);
}
/**
* Closes a booking that is not going to happen. Deliberately silent: the office tells
* the customer itself, so this only records the outcome.
* Idempotent — a no-op for a booking that is already confirmed or discarded, since a
* released confirmation is binding and must not be withdrawn behind the customer's back.
*/
public function discardBooking(AccommodationBooking $booking): void
{
if ($booking->isConfirmed() || $booking->isDiscarded()) {
return;
}
$booking->setStatus(AccommodationBookingStatus::Discarded);
$this->entityManager->flush();
}
/**
* Accepts an offer that is out with the customer and notifies the office. The origin
* stays untouched, so the booking remains recognisable as offer-originated for the
* rest of its life; only the status moves on to Eingegangen.
* Idempotent — a no-op (including no emails) if the offer is not open any more.
*
* The customer deliberately gets no mail here: nothing is confirmed until the office
@@ -509,7 +546,9 @@ class AccommodationBookingService
*/
public function acceptBooking(AccommodationBooking $booking, ?string $remarks = null): void
{
if (!$booking->isInquiry() || !$booking->isOpen()) {
// Offen means exactly one thing — an offer is out and awaiting this acceptance —
// so the status alone is the whole guard.
if (!$booking->isOpen()) {
return;
}