feat: refactored accommodation booking status model
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : ''
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user