diff --git a/assets/images/logo_alpinevacation.svg b/assets/images/logo_alpinevacation.svg index 8d246bc..cad4fbe 100644 --- a/assets/images/logo_alpinevacation.svg +++ b/assets/images/logo_alpinevacation.svg @@ -1,33 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - ALPINEVACATION - - - - - - - - + \ No newline at end of file diff --git a/assets/images/logo_cllt.svg b/assets/images/logo_cllt.svg index 4af82bf..5ca7c51 100644 --- a/assets/images/logo_cllt.svg +++ b/assets/images/logo_cllt.svg @@ -1,33 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - CLLT Touristik - - - - - - - - + \ No newline at end of file diff --git a/migrations/Version20260806120000.php b/migrations/Version20260806120000.php new file mode 100644 index 0000000..af55417 --- /dev/null +++ b/migrations/Version20260806120000.php @@ -0,0 +1,46 @@ +addSql('ALTER TABLE accommodation_booking ADD type VARCHAR(20) DEFAULT NULL'); + + // An old 'booking' row with an accepted_at came from an accepted offer, so it is an + // Anfrage; without one it was placed directly. Rows in 'draft'/'discarded' carry no + // trace of their kind any more — they are mapped to 'inquiry', which is where the + // office path produces them. + $this->addSql("UPDATE accommodation_booking SET type = CASE + WHEN status = 'booking' AND accepted_at IS NULL THEN 'booking' + ELSE 'inquiry' END"); + + $this->addSql("UPDATE accommodation_booking SET status = CASE + WHEN status = 'inquiry' THEN 'open' + WHEN status = 'booking' THEN 'accepted' + ELSE status END"); + + $this->addSql('ALTER TABLE accommodation_booking CHANGE type type VARCHAR(20) NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql("UPDATE accommodation_booking SET status = CASE + WHEN status IN ('draft', 'discarded') THEN status + WHEN type = 'inquiry' AND status = 'open' THEN 'inquiry' + ELSE 'booking' END"); + + $this->addSql('ALTER TABLE accommodation_booking DROP type'); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php index 25e8ed8..cdbd247 100644 --- a/src/Controller/Admin/AccommodationBooking/EditController.php +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -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); diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index b899aa3..abcd424 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -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); } diff --git a/src/Controller/Groups/Step4Controller.php b/src/Controller/Groups/Step4Controller.php index 4c0164a..0c19801 100644 --- a/src/Controller/Groups/Step4Controller.php +++ b/src/Controller/Groups/Step4Controller.php @@ -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); } } diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index 39a4b2b..86c5965 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -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; diff --git a/src/Enum/Groups/AccommodationBookingStatus.php b/src/Enum/Groups/AccommodationBookingStatus.php index a54032c..21e0b93 100644 --- a/src/Enum/Groups/AccommodationBookingStatus.php +++ b/src/Enum/Groups/AccommodationBookingStatus.php @@ -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', }; } diff --git a/src/Enum/Groups/AccommodationBookingType.php b/src/Enum/Groups/AccommodationBookingType.php new file mode 100644 index 0000000..3d0e82f --- /dev/null +++ b/src/Enum/Groups/AccommodationBookingType.php @@ -0,0 +1,17 @@ + 'Anfrage', + self::Booking => 'Buchung', + }; + } +} diff --git a/src/Form/Admin/Groups/AccommodationBookingCreateType.php b/src/Form/Admin/Groups/AccommodationBookingCreateType.php index bcf7199..43742bd 100644 --- a/src/Form/Admin/Groups/AccommodationBookingCreateType.php +++ b/src/Form/Admin/Groups/AccommodationBookingCreateType.php @@ -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(), ]) diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php index 765ce0b..960e255 100644 --- a/src/Model/AccommodationBookingApiResponse.php +++ b/src/Model/AccommodationBookingApiResponse.php @@ -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(); diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 65d0aa4..9c9dc98 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -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(); diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 93259c1..654c78b 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -60,7 +60,7 @@ {% endif %} - {{ booking.status.label }} + {{ booking.type.label }} · {{ booking.status.label }} {% set discounts = [] %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index ff1d265..2cea9ba 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -2,7 +2,7 @@ {% block content %} - {{ booking.status.label }} + {{ booking.type.label }} · {{ booking.status.label }} {{ booking.groupName }} @@ -53,6 +53,8 @@
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)
{{ booking.childrenCount }}
{% endif %} +
Art
+
{{ booking.type.label }}
Status
{{ booking.status.label }}
{% if booking.acceptedAt is not null %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig index 775db76..fa99155 100644 --- a/templates/email/accommodation_booking_customer.html.twig +++ b/templates/email/accommodation_booking_customer.html.twig @@ -2,11 +2,11 @@ {% block body %} {% if accessLink %} -

{{ booking.inquiry ? 'Dein Angebot ist bereit' : 'Deine Buchung ist bestätigt' }}

+

{{ booking.accepted ? 'Deine Buchung ist bestätigt' : 'Dein Angebot ist bereit' }}

Hallo {{ booking.firstName }}, - {% if booking.inquiry %} + {% if not booking.accepted %} vielen Dank für deine Anfrage! Wir haben sie geprüft — dein Angebot wartet auf dich. {% else %} vielen Dank! Deine Buchung ist bei uns eingegangen und bestätigt. @@ -15,7 +15,7 @@

- {{ booking.inquiry ? 'Angebot ansehen & bestätigen' : 'Buchung ansehen' }} + {{ booking.accepted ? 'Buchung ansehen' : 'Angebot ansehen & bestätigen' }}

{% else %} diff --git a/templates/groups/booking/offer.html.twig b/templates/groups/booking/offer.html.twig index c03032b..68cdc8f 100644 --- a/templates/groups/booking/offer.html.twig +++ b/templates/groups/booking/offer.html.twig @@ -24,7 +24,7 @@ {% import _self as macros %} -{% block title %}{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}{% endblock %} +{% block title %}{{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }}{% endblock %} {% block background %}bg-outer bg-outer--summer{% endblock %} @@ -52,7 +52,7 @@

- {{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }} + {{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }}

{{ ctx.accommodation.name }} @@ -63,7 +63,7 @@ {% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}

- {{ booking.inquiry ? 'Angefragte' : 'Gebuchte' }} Leistungen + {{ booking.accepted ? 'Gebuchte' : 'Angefragte' }} Leistungen

{% endif %} - {% if booking.inquiry %} + {% if booking.inquiry and booking.open %}