feat: accommodation booking type and status as dedicated properties

This commit is contained in:
Björn Fromme
2026-08-06 08:52:09 +02:00
parent 2cfa87f44b
commit aa8fa5c1b2
22 changed files with 218 additions and 151 deletions
@@ -127,9 +127,9 @@ class EditController extends AbstractController
$this->entityManager->persist($booking);
$this->entityManager->flush();
// Entering Anfrage or Buchung is what the customer needs to hear about: the offer
// Entering Offen or Bestätigt is what the customer needs to hear about: the offer
// becomes viewable, or the booking is confirmed. Entwurf and Absage stay silent.
$notifiableStatuses = [AccommodationBookingStatus::Inquiry, AccommodationBookingStatus::Booking];
$notifiableStatuses = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted];
if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) {
$this->bookingService->issueAccessLink($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
+1 -1
View File
@@ -93,7 +93,7 @@ class OfferController extends AbstractController
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isInquiry()) {
if (!$booking->isInquiry() || !$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
+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->getStatus()->value);
$this->addFlash('groups_booking_result', $booking->getType()->value);
$this->sessionManager->clear($request);
}
}
+35 -4
View File
@@ -10,6 +10,7 @@ use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\User;
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;
@@ -79,6 +80,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(length: 20, enumType: AccommodationBookingStatus::class)]
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.
*/
#[ORM\Column(length: 20, enumType: AccommodationBookingType::class)]
private AccommodationBookingType $type = AccommodationBookingType::Inquiry;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $accessLinkIssuedAt = null;
@@ -353,14 +362,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Draft === $this->status;
}
public function isInquiry(): bool
public function isOpen(): bool
{
return AccommodationBookingStatus::Inquiry === $this->status;
return AccommodationBookingStatus::Open === $this->status;
}
public function isBooking(): bool
public function isAccepted(): bool
{
return AccommodationBookingStatus::Booking === $this->status;
return AccommodationBookingStatus::Accepted === $this->status;
}
public function isDiscarded(): bool
@@ -368,6 +377,28 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Discarded === $this->status;
}
public function getType(): AccommodationBookingType
{
return $this->type;
}
public function setType(AccommodationBookingType $type): self
{
$this->type = $type;
return $this;
}
public function isInquiry(): bool
{
return AccommodationBookingType::Inquiry === $this->type;
}
public function isBooking(): bool
{
return AccommodationBookingType::Booking === $this->type;
}
public function getAccessLinkIssuedAt(): ?\DateTimeImmutable
{
return $this->accessLinkIssuedAt;
@@ -5,16 +5,16 @@ namespace App\Enum\Groups;
enum AccommodationBookingStatus: string
{
case Draft = 'draft';
case Inquiry = 'inquiry';
case Booking = 'booking';
case Open = 'open';
case Accepted = 'accepted';
case Discarded = 'discarded';
public function label(): string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Inquiry => 'Anfrage',
self::Booking => 'Buchung',
self::Open => 'Offen',
self::Accepted => 'Bestätigt',
self::Discarded => 'Absage',
};
}
@@ -0,0 +1,17 @@
<?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',
};
}
}
@@ -53,8 +53,8 @@ class AccommodationBookingCreateType extends AbstractType
'class' => AccommodationBookingStatus::class,
'choices' => [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Inquiry,
AccommodationBookingStatus::Booking,
AccommodationBookingStatus::Open,
AccommodationBookingStatus::Accepted,
],
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
@@ -17,6 +17,9 @@ final readonly class AccommodationBookingApiResponse
#[Groups(['api:single'])]
public string $status;
#[Groups(['api:single'])]
public string $type;
#[Groups(['api:single'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateFrom;
@@ -89,6 +92,7 @@ final readonly class AccommodationBookingApiResponse
{
$this->uuid = $booking->getUuid();
$this->status = $booking->getStatus()->value;
$this->type = $booking->getType()->value;
$this->dateFrom = $booking->getDateFrom();
$this->dateTo = $booking->getDateTo();
$this->nights = $booking->getNights();
+15 -12
View File
@@ -11,6 +11,7 @@ use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
@@ -265,11 +266,11 @@ class AccommodationBookingService
$booking->setPaxCount($dto->paxCount);
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
$booking->setStatus(
$dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry
? AccommodationBookingStatus::Inquiry
: AccommodationBookingStatus::Booking
);
// An inquiry arrives live — the office has nothing to prepare first, so it goes
// straight to Offen. A direct booking is binding on arrival, hence Bestätigt.
$isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry;
$booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking);
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Accepted);
// Freeze board service as scalar fields (no FK)
if (null !== $dto->selectedBoardServiceId) {
@@ -432,9 +433,9 @@ class AccommodationBookingService
}
/**
* Issues the access link only for a confirmed booking. Inquiries from the public flow
* Issues the access link only for a direct booking. Inquiries from the public flow
* deliberately get no link — the office prepares the offer first and issues the link
* when it switches the booking to Anfrage.
* when it publishes the offer.
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
{
@@ -452,7 +453,7 @@ class AccommodationBookingService
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
if (!$booking->isInquiry()) {
if ($booking->isAccepted()) {
$subject = 'Deine Buchung ist bestätigt';
} elseif (null !== $booking->getAccessLinkIssuedAt()) {
$subject = 'Dein Angebot ist bereit';
@@ -481,16 +482,18 @@ class AccommodationBookingService
}
/**
* Transitions a booking from inquiry to booking and notifies office and customer.
* Idempotent — a no-op (including no emails) if the booking is already accepted.
* Accepts an open offer and notifies office and customer. The type stays Anfrage —
* only the status moves to Bestätigt, so an offer-originated booking remains
* distinguishable from a direct one.
* Idempotent — a no-op (including no emails) if the offer is not open any more.
*/
public function acceptBooking(AccommodationBooking $booking): void
{
if (!$booking->isInquiry()) {
if (!$booking->isInquiry() || !$booking->isOpen()) {
return;
}
$booking->setStatus(AccommodationBookingStatus::Booking);
$booking->setStatus(AccommodationBookingStatus::Accepted);
$booking->setAcceptedAt(new \DateTimeImmutable());
$this->entityManager->flush();