feat: additional confirmation cycle and status for groups inquiries
This commit is contained in:
@@ -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 ConfirmController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/{id}/confirm', name: 'app_admin_accommodationbooking_confirm')]
|
||||
public function index(AccommodationBooking $booking, Request $request): Response
|
||||
{
|
||||
// Only a booking awaiting validation can be confirmed — everything else has either
|
||||
// not been committed to by the customer yet or is already dealt with.
|
||||
if (!$booking->isReceived()) {
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||
}
|
||||
|
||||
if ($request->isMethod(Request::METHOD_POST)) {
|
||||
if (!$this->isCsrfTokenValid('confirm_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
// Confirming is what makes the booking binding for the customer, so it must not
|
||||
// happen when the confirmation itself cannot be delivered.
|
||||
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 die Buchung zu bestätigen.');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]));
|
||||
}
|
||||
|
||||
$this->bookingService->confirmBooking($booking);
|
||||
|
||||
$this->addFlash('success', 'Die Buchung wurde bestätigt und dem Kunden per E-Mail zugestellt.');
|
||||
|
||||
$this->logger->info('Confirmed accommodation booking', [
|
||||
'id' => $booking->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_booking/modal_confirm.html.twig', [
|
||||
'booking' => $booking,
|
||||
'csrf_token_id' => 'confirm_accommodation_booking_'.$booking->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,11 @@ class EditController extends AbstractController
|
||||
$this->entityManager->persist($booking);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// 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::Open, AccommodationBookingStatus::Accepted];
|
||||
// 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);
|
||||
|
||||
@@ -108,7 +108,7 @@ class OfferController extends AbstractController
|
||||
// The textarea is prefilled, so the submitted value is the complete remark —
|
||||
// an emptied field arrives as null and must clear the stored one, not keep it.
|
||||
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
||||
$this->addFlash('success', 'Deine Buchung ist bestätigt.');
|
||||
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
|
||||
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $acceptedAt = null;
|
||||
|
||||
/**
|
||||
* When the office validated the requested services and capacities and released the
|
||||
* booking confirmation to the customer — deliberately distinct from acceptedAt, which
|
||||
* only records that the customer committed.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $confirmedAt = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\NotBlank(groups: ['edit'])]
|
||||
private ?string $groupName = null;
|
||||
@@ -373,9 +381,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
return AccommodationBookingStatus::Open === $this->status;
|
||||
}
|
||||
|
||||
public function isAccepted(): bool
|
||||
public function isReceived(): bool
|
||||
{
|
||||
return AccommodationBookingStatus::Accepted === $this->status;
|
||||
return AccommodationBookingStatus::Received === $this->status;
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool
|
||||
{
|
||||
return AccommodationBookingStatus::Confirmed === $this->status;
|
||||
}
|
||||
|
||||
public function isDiscarded(): bool
|
||||
@@ -429,6 +442,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConfirmedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->confirmedAt;
|
||||
}
|
||||
|
||||
public function setConfirmedAt(?\DateTimeImmutable $confirmedAt): self
|
||||
{
|
||||
$this->confirmedAt = $confirmedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGroupName(): ?string
|
||||
{
|
||||
return $this->groupName;
|
||||
|
||||
@@ -6,7 +6,8 @@ enum AccommodationBookingStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Open = 'open';
|
||||
case Accepted = 'accepted';
|
||||
case Received = 'received';
|
||||
case Confirmed = 'confirmed';
|
||||
case Discarded = 'discarded';
|
||||
|
||||
public function label(): string
|
||||
@@ -14,7 +15,8 @@ enum AccommodationBookingStatus: string
|
||||
return match ($this) {
|
||||
self::Draft => 'Entwurf',
|
||||
self::Open => 'Offen',
|
||||
self::Accepted => 'Bestätigt',
|
||||
self::Received => 'Eingegangen',
|
||||
self::Confirmed => 'Bestätigt',
|
||||
self::Discarded => 'Absage',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,14 +38,18 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
|
||||
/**
|
||||
* The list as it presents itself to someone who has not filtered yet.
|
||||
*
|
||||
* The still-live statuses — drafts and open bookings, 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, 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.
|
||||
*/
|
||||
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
|
||||
{
|
||||
$filter = new self();
|
||||
$filter->status = [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open];
|
||||
$filter->status = [
|
||||
AccommodationBookingStatus::Draft,
|
||||
AccommodationBookingStatus::Open,
|
||||
AccommodationBookingStatus::Received,
|
||||
];
|
||||
$filter->dateFrom = new \DateTimeImmutable('today');
|
||||
|
||||
if (!$seesAllBookings) {
|
||||
|
||||
@@ -46,6 +46,9 @@ final readonly class AccommodationBookingApiResponse
|
||||
#[Groups(['api:single'])]
|
||||
public ?\DateTimeImmutable $acceptedAt;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?\DateTimeImmutable $confirmedAt;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public AccommodationBookingPersonalData $personalData;
|
||||
|
||||
@@ -101,6 +104,7 @@ final readonly class AccommodationBookingApiResponse
|
||||
$this->childrenCount = $booking->getChildrenCount();
|
||||
$this->groupName = $booking->getGroupName();
|
||||
$this->acceptedAt = $booking->getAcceptedAt();
|
||||
$this->confirmedAt = $booking->getConfirmedAt();
|
||||
$this->personalData = new AccommodationBookingPersonalData($booking);
|
||||
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
|
||||
$this->boardService = new AccommodationBookingBoardServiceInfo($booking);
|
||||
|
||||
@@ -267,10 +267,14 @@ class AccommodationBookingService
|
||||
$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 is binding on arrival, hence Bestätigt.
|
||||
// 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.
|
||||
$isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry;
|
||||
$booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking);
|
||||
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Accepted);
|
||||
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Received);
|
||||
if (!$isInquiry) {
|
||||
$booking->setAcceptedAt(new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
// Freeze board service as scalar fields (no FK)
|
||||
if (null !== $dto->selectedBoardServiceId) {
|
||||
@@ -333,7 +337,12 @@ class AccommodationBookingService
|
||||
$booking = $this->persist($dto, $accommodation, $prices, $services);
|
||||
$this->issueAccessLinkForDirectBooking($booking);
|
||||
$this->sendNotificationEmail($booking);
|
||||
$this->sendCustomerConfirmationEmail($booking);
|
||||
// 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()) {
|
||||
$this->sendCustomerConfirmationEmail($booking);
|
||||
}
|
||||
|
||||
return $booking;
|
||||
}
|
||||
@@ -409,11 +418,15 @@ class AccommodationBookingService
|
||||
return;
|
||||
}
|
||||
|
||||
$breakdown = $this->breakdownCalculator->compute($booking);
|
||||
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'booking' => $booking,
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
'priceBreakdown' => $breakdown,
|
||||
'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'] ?? null,
|
||||
],
|
||||
[
|
||||
'from' => $this->accommodationEmail,
|
||||
@@ -467,13 +480,9 @@ class AccommodationBookingService
|
||||
*/
|
||||
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
if ($booking->isAccepted()) {
|
||||
$subject = 'Deine Buchung ist bestätigt';
|
||||
} elseif (null !== $booking->getAccessLinkIssuedAt()) {
|
||||
$subject = 'Dein Angebot ist bereit';
|
||||
} else {
|
||||
$subject = 'Deine Anfrage ist bei uns eingegangen';
|
||||
}
|
||||
$subject = null !== $booking->getAccessLinkIssuedAt()
|
||||
? 'Dein Angebot ist bereit'
|
||||
: 'Deine Anfrage ist bei uns eingegangen';
|
||||
|
||||
$this->sendBookingEmail(
|
||||
$booking,
|
||||
@@ -496,11 +505,14 @@ class AccommodationBookingService
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* 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
|
||||
* has validated the booking and released it via confirmBooking().
|
||||
*
|
||||
* @param ?string $remarks null leaves the stored remark untouched, a string replaces it,
|
||||
* an empty string clears it
|
||||
*/
|
||||
@@ -515,12 +527,32 @@ class AccommodationBookingService
|
||||
$booking->setRemarks('' === $trimmed ? null : $trimmed);
|
||||
}
|
||||
|
||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
||||
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||
$booking->setAcceptedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->sendOfferAcceptedNotificationEmail($booking);
|
||||
$this->sendOfferAcceptedCustomerEmail($booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit office action after the requested services and capacities have been validated:
|
||||
* this is the moment the booking becomes binding for the customer, and the only place the
|
||||
* booking confirmation email is sent.
|
||||
* Idempotent — a no-op (including no email) for anything that is not awaiting validation.
|
||||
*/
|
||||
public function confirmBooking(AccommodationBooking $booking): void
|
||||
{
|
||||
if (!$booking->isReceived()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||
$booking->setConfirmedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
|
||||
// The confirmation links to the booking view, so make sure a link exists.
|
||||
$this->issueAccessLink($booking);
|
||||
$this->sendBookingConfirmedCustomerEmail($booking);
|
||||
}
|
||||
|
||||
public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void
|
||||
@@ -534,14 +566,14 @@ class AccommodationBookingService
|
||||
);
|
||||
}
|
||||
|
||||
public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void
|
||||
public function sendBookingConfirmedCustomerEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
$this->sendBookingEmail(
|
||||
$booking,
|
||||
$booking->getEmail(),
|
||||
'Deine Buchung ist bestätigt',
|
||||
'email/offer_accepted_customer.html.twig',
|
||||
'Failed to send offer accepted customer email',
|
||||
'email/booking_confirmed_customer.html.twig',
|
||||
'Failed to send booking confirmed customer email',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user