feat: additional confirmation cycle and status for groups inquiries
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260818103004 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Record when the office confirmed an accommodation booking, separately from when the customer committed to it';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking ADD confirmed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP confirmed_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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->persist($booking);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
// Entering Offen or Bestätigt is what the customer needs to hear about: the offer
|
// Entering Offen is what the customer needs to hear about: the offer becomes
|
||||||
// becomes viewable, or the booking is confirmed. Entwurf and Absage stay silent.
|
// viewable. Every other status stays silent here — in particular Bestätigt, which
|
||||||
$notifiableStatuses = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted];
|
// 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)) {
|
if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) {
|
||||||
$this->bookingService->issueAccessLink($booking);
|
$this->bookingService->issueAccessLink($booking);
|
||||||
$this->bookingService->sendCustomerConfirmationEmail($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 —
|
// 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.
|
// an emptied field arrives as null and must clear the stored one, not keep it.
|
||||||
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
$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);
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
private ?\DateTimeImmutable $acceptedAt = null;
|
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)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
#[Assert\NotBlank(groups: ['edit'])]
|
#[Assert\NotBlank(groups: ['edit'])]
|
||||||
private ?string $groupName = null;
|
private ?string $groupName = null;
|
||||||
@@ -373,9 +381,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
return AccommodationBookingStatus::Open === $this->status;
|
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
|
public function isDiscarded(): bool
|
||||||
@@ -429,6 +442,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getConfirmedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->confirmedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConfirmedAt(?\DateTimeImmutable $confirmedAt): self
|
||||||
|
{
|
||||||
|
$this->confirmedAt = $confirmedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getGroupName(): ?string
|
public function getGroupName(): ?string
|
||||||
{
|
{
|
||||||
return $this->groupName;
|
return $this->groupName;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ enum AccommodationBookingStatus: string
|
|||||||
{
|
{
|
||||||
case Draft = 'draft';
|
case Draft = 'draft';
|
||||||
case Open = 'open';
|
case Open = 'open';
|
||||||
case Accepted = 'accepted';
|
case Received = 'received';
|
||||||
|
case Confirmed = 'confirmed';
|
||||||
case Discarded = 'discarded';
|
case Discarded = 'discarded';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
@@ -14,7 +15,8 @@ enum AccommodationBookingStatus: string
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Draft => 'Entwurf',
|
self::Draft => 'Entwurf',
|
||||||
self::Open => 'Offen',
|
self::Open => 'Offen',
|
||||||
self::Accepted => 'Bestätigt',
|
self::Received => 'Eingegangen',
|
||||||
|
self::Confirmed => 'Bestätigt',
|
||||||
self::Discarded => 'Absage',
|
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 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
|
* The still-live statuses — drafts, open bookings and bookings awaiting validation, i.e.
|
||||||
* work — nothing whose stay is already over, and, for staff who are not group admins, only
|
* everything that may still need work — nothing whose stay is already over, and, for
|
||||||
* the bookings they are responsible for.
|
* staff who are not group admins, only the bookings they are responsible for.
|
||||||
*/
|
*/
|
||||||
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
|
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
|
||||||
{
|
{
|
||||||
$filter = new self();
|
$filter = new self();
|
||||||
$filter->status = [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open];
|
$filter->status = [
|
||||||
|
AccommodationBookingStatus::Draft,
|
||||||
|
AccommodationBookingStatus::Open,
|
||||||
|
AccommodationBookingStatus::Received,
|
||||||
|
];
|
||||||
$filter->dateFrom = new \DateTimeImmutable('today');
|
$filter->dateFrom = new \DateTimeImmutable('today');
|
||||||
|
|
||||||
if (!$seesAllBookings) {
|
if (!$seesAllBookings) {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ final readonly class AccommodationBookingApiResponse
|
|||||||
#[Groups(['api:single'])]
|
#[Groups(['api:single'])]
|
||||||
public ?\DateTimeImmutable $acceptedAt;
|
public ?\DateTimeImmutable $acceptedAt;
|
||||||
|
|
||||||
|
#[Groups(['api:single'])]
|
||||||
|
public ?\DateTimeImmutable $confirmedAt;
|
||||||
|
|
||||||
#[Groups(['api:single'])]
|
#[Groups(['api:single'])]
|
||||||
public AccommodationBookingPersonalData $personalData;
|
public AccommodationBookingPersonalData $personalData;
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ final readonly class AccommodationBookingApiResponse
|
|||||||
$this->childrenCount = $booking->getChildrenCount();
|
$this->childrenCount = $booking->getChildrenCount();
|
||||||
$this->groupName = $booking->getGroupName();
|
$this->groupName = $booking->getGroupName();
|
||||||
$this->acceptedAt = $booking->getAcceptedAt();
|
$this->acceptedAt = $booking->getAcceptedAt();
|
||||||
|
$this->confirmedAt = $booking->getConfirmedAt();
|
||||||
$this->personalData = new AccommodationBookingPersonalData($booking);
|
$this->personalData = new AccommodationBookingPersonalData($booking);
|
||||||
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
|
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
|
||||||
$this->boardService = new AccommodationBookingBoardServiceInfo($booking);
|
$this->boardService = new AccommodationBookingBoardServiceInfo($booking);
|
||||||
|
|||||||
@@ -267,10 +267,14 @@ class AccommodationBookingService
|
|||||||
$booking->setMinorsCount($dto->minorsCount);
|
$booking->setMinorsCount($dto->minorsCount);
|
||||||
$booking->setChildrenCount($dto->childrenCount);
|
$booking->setChildrenCount($dto->childrenCount);
|
||||||
// An inquiry arrives live — the office has nothing to prepare first, so it goes
|
// 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;
|
$isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry;
|
||||||
$booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking);
|
$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)
|
// Freeze board service as scalar fields (no FK)
|
||||||
if (null !== $dto->selectedBoardServiceId) {
|
if (null !== $dto->selectedBoardServiceId) {
|
||||||
@@ -333,7 +337,12 @@ class AccommodationBookingService
|
|||||||
$booking = $this->persist($dto, $accommodation, $prices, $services);
|
$booking = $this->persist($dto, $accommodation, $prices, $services);
|
||||||
$this->issueAccessLinkForDirectBooking($booking);
|
$this->issueAccessLinkForDirectBooking($booking);
|
||||||
$this->sendNotificationEmail($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;
|
return $booking;
|
||||||
}
|
}
|
||||||
@@ -409,11 +418,15 @@ class AccommodationBookingService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$breakdown = $this->breakdownCalculator->compute($booking);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->mailer->createAndSendEmail(
|
$this->mailer->createAndSendEmail(
|
||||||
[
|
[
|
||||||
'booking' => $booking,
|
'booking' => $booking,
|
||||||
'accessLink' => $this->accessLinkOrNull($booking),
|
'accessLink' => $this->accessLinkOrNull($booking),
|
||||||
|
'priceBreakdown' => $breakdown,
|
||||||
|
'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'] ?? null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'from' => $this->accommodationEmail,
|
'from' => $this->accommodationEmail,
|
||||||
@@ -467,13 +480,9 @@ class AccommodationBookingService
|
|||||||
*/
|
*/
|
||||||
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
|
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
|
||||||
{
|
{
|
||||||
if ($booking->isAccepted()) {
|
$subject = null !== $booking->getAccessLinkIssuedAt()
|
||||||
$subject = 'Deine Buchung ist bestätigt';
|
? 'Dein Angebot ist bereit'
|
||||||
} elseif (null !== $booking->getAccessLinkIssuedAt()) {
|
: 'Deine Anfrage ist bei uns eingegangen';
|
||||||
$subject = 'Dein Angebot ist bereit';
|
|
||||||
} else {
|
|
||||||
$subject = 'Deine Anfrage ist bei uns eingegangen';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sendBookingEmail(
|
$this->sendBookingEmail(
|
||||||
$booking,
|
$booking,
|
||||||
@@ -496,11 +505,14 @@ class AccommodationBookingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepts an open offer and notifies office and customer. The type stays Anfrage —
|
* Accepts an open offer and notifies the office. The type stays Anfrage —
|
||||||
* only the status moves to Bestätigt, so an offer-originated booking remains
|
* only the status moves to Eingegangen, so an offer-originated booking remains
|
||||||
* distinguishable from a direct one.
|
* distinguishable from a direct one.
|
||||||
* Idempotent — a no-op (including no emails) if the offer is not open any more.
|
* 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,
|
* @param ?string $remarks null leaves the stored remark untouched, a string replaces it,
|
||||||
* an empty string clears it
|
* an empty string clears it
|
||||||
*/
|
*/
|
||||||
@@ -515,12 +527,32 @@ class AccommodationBookingService
|
|||||||
$booking->setRemarks('' === $trimmed ? null : $trimmed);
|
$booking->setRemarks('' === $trimmed ? null : $trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
$booking->setAcceptedAt(new \DateTimeImmutable());
|
$booking->setAcceptedAt(new \DateTimeImmutable());
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$this->sendOfferAcceptedNotificationEmail($booking);
|
$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
|
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(
|
$this->sendBookingEmail(
|
||||||
$booking,
|
$booking,
|
||||||
$booking->getEmail(),
|
$booking->getEmail(),
|
||||||
'Deine Buchung ist bestätigt',
|
'Deine Buchung ist bestätigt',
|
||||||
'email/offer_accepted_customer.html.twig',
|
'email/booking_confirmed_customer.html.twig',
|
||||||
'Failed to send offer accepted customer email',
|
'Failed to send booking confirmed customer email',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{#- Offen covers two situations that need different office action, told apart by the access
|
||||||
|
link: no link yet means the offer still has to be prepared, a link means the customer is
|
||||||
|
the one who has to act. Display only — both are the same status. -#}
|
||||||
|
{{- booking.status.label -}}
|
||||||
|
{%- if booking.open %} · {{ booking.accessLinkIssuedAt is not null ? 'Angebot versendet' : 'Angebot ausstehend' }}{% endif -%}
|
||||||
@@ -47,6 +47,11 @@
|
|||||||
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
|
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if booking.confirmedAt is not null %}
|
||||||
|
<p class="lg:col-span-2 text-xs text-gray-500">
|
||||||
|
Bestätigt am {{ booking.confirmedAt | date('d.m.Y, H:i') }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
{% if form.managedBy is defined %}
|
{% if form.managedBy is defined %}
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
{{ form_row(form.managedBy) }}
|
{{ form_row(form.managedBy) }}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ booking.type.label }} · {{ booking.status.label }}
|
{{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% set discounts = [] %}
|
{% set discounts = [] %}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Buchung bestätigen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div>
|
||||||
|
Möchtest du die Buchung für <em>{{ booking.groupName }}</em> verbindlich bestätigen?
|
||||||
|
<em>{{ booking.email }}</em> erhält daraufhin die Buchungsbestätigung inklusive
|
||||||
|
Preisübersicht per E-Mail.
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block button_confirm %}Bestätigen{% endblock %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<twig:page:heading>
|
<twig:page:heading>
|
||||||
{{ booking.type.label }} · {{ booking.status.label }}
|
{{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
|
||||||
{{ booking.groupName }}
|
{{ booking.groupName }}
|
||||||
</twig:page:heading>
|
</twig:page:heading>
|
||||||
|
|
||||||
@@ -56,11 +56,15 @@
|
|||||||
<dt class="font-medium">Art</dt>
|
<dt class="font-medium">Art</dt>
|
||||||
<dd>{{ booking.type.label }}</dd>
|
<dd>{{ booking.type.label }}</dd>
|
||||||
<dt class="font-medium">Status</dt>
|
<dt class="font-medium">Status</dt>
|
||||||
<dd>{{ booking.status.label }}</dd>
|
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
|
||||||
{% if booking.acceptedAt is not null %}
|
{% if booking.acceptedAt is not null %}
|
||||||
<dt class="font-medium">Angenommen am</dt>
|
<dt class="font-medium">Angenommen am</dt>
|
||||||
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
|
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if booking.confirmedAt is not null %}
|
||||||
|
<dt class="font-medium">Bestätigt am</dt>
|
||||||
|
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
|
||||||
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,6 +192,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if booking.received %}
|
||||||
|
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||||
|
<h2 class="text-lg font-bold mb-2">Bestätigung</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-2">
|
||||||
|
Der Kunde hat die Buchung verbindlich abgeschickt, aber noch keine Bestätigung
|
||||||
|
erhalten. Prüfe Leistungen und Kapazitäten und bestätige die Buchung erst dann.
|
||||||
|
</p>
|
||||||
|
<button type="button"
|
||||||
|
class="button button--primary button--small"
|
||||||
|
hx-get="{{ path('app_admin_accommodationbooking_confirm', { id: booking.id }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Buchung bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||||
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
|
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
|
||||||
{% if accessLink %}
|
{% if accessLink %}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{#- Price breakdown for booking emails. Mirrors groups/booking/_offer_summary.html.twig, but
|
||||||
|
with inline styles: the email layout ships no table styling and no Tailwind.
|
||||||
|
Context: booking, priceBreakdown, currency (set in AccommodationBookingService). -#}
|
||||||
|
{% if priceBreakdown is not null and currency is not null %}
|
||||||
|
{% set cell = 'padding: 4px 0; text-align: left; vertical-align: top;' %}
|
||||||
|
{% set amount = 'padding: 4px 0; text-align: right; vertical-align: top; white-space: nowrap;' %}
|
||||||
|
{% set note = 'display: block; font-size: 12px; color: #6b7280; margin: 0;' %}
|
||||||
|
|
||||||
|
<h2>Preise</h2>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Zeitraum</td>
|
||||||
|
<td style="{{ amount }}">
|
||||||
|
{{ booking.dateFrom|date('d.m.Y') }} – {{ booking.dateTo|date('d.m.Y') }},
|
||||||
|
{{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Anzahl Personen</td>
|
||||||
|
<td style="{{ amount }}">{{ booking.paxCount }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if booking.minorsCount > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">davon Kinder (0–3 J.)</td>
|
||||||
|
<td style="{{ amount }}">{{ booking.minorsCount }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if booking.childrenCount > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.)</td>
|
||||||
|
<td style="{{ amount }}">{{ booking.childrenCount }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if priceBreakdown.basePrice > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">
|
||||||
|
Basispreis
|
||||||
|
<span style="{{ note }}">für {{ priceBreakdown.includedPax }} Pers.</span>
|
||||||
|
</td>
|
||||||
|
<td style="{{ amount }}">{{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if priceBreakdown.additionalPersonsPrice > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">
|
||||||
|
Aufpreis Personenzahl
|
||||||
|
<span style="{{ note }}">für {{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.</span>
|
||||||
|
</td>
|
||||||
|
<td style="{{ amount }}">{{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if priceBreakdown.shortTermSurcharge > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Aufpreis Kurzzeit</td>
|
||||||
|
<td style="{{ amount }}">{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if priceBreakdown.boardPrice > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Verpflegung{% if booking.boardServiceLabel %} ({{ booking.boardServiceLabel }}){% endif %}</td>
|
||||||
|
<td style="{{ amount }}">{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if priceBreakdown.undersubscriptionSurcharge > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">
|
||||||
|
Verpflegungs-Aufschlag
|
||||||
|
<span style="{{ note }}">für Gruppen unter {{ priceBreakdown.undersubscriptionThreshold }} Pers.</span>
|
||||||
|
</td>
|
||||||
|
<td style="{{ amount }}">{{ (priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% for service in priceBreakdown.serviceDetails %}
|
||||||
|
{% if service.price > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">{{ service.label }}</td>
|
||||||
|
<td style="{{ amount }}">{{ (service.price / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if priceBreakdown.runningCosts > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Strom- und Abfallgebühren</td>
|
||||||
|
<td style="{{ amount }}">{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||||
|
Gesamtpreis
|
||||||
|
<span style="{{ note }} font-weight: normal;">zzgl. ortsabhängiger Gebühren</span>
|
||||||
|
</td>
|
||||||
|
<td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||||
|
{{ (priceBreakdown.total / 100)|format_currency(currency) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% set accommodationDiscountAmount = booking.accommodationDiscount is not null
|
||||||
|
? ((priceBreakdown.basePrice + priceBreakdown.additionalPersonsPrice) * booking.accommodationDiscount / 100) | round
|
||||||
|
: 0 %}
|
||||||
|
{% set boardDiscountAmount = booking.boardServiceDiscount is not null
|
||||||
|
? (priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round
|
||||||
|
: 0 %}
|
||||||
|
{% set servicesDiscountAmount = booking.additionalServicesDiscount is not null
|
||||||
|
? (priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round
|
||||||
|
: 0 %}
|
||||||
|
{% set discountSum = accommodationDiscountAmount + boardDiscountAmount + servicesDiscountAmount %}
|
||||||
|
|
||||||
|
{% if booking.accommodationDiscount is not null %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Rabatt Unterkunft {{ booking.accommodationDiscount }} %</td>
|
||||||
|
<td style="{{ amount }}">– {{ (accommodationDiscountAmount / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if booking.boardServiceDiscount is not null %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Rabatt Verpflegung {{ booking.boardServiceDiscount }} %</td>
|
||||||
|
<td style="{{ amount }}">– {{ (boardDiscountAmount / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if booking.additionalServicesDiscount is not null %}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }}">Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }} %</td>
|
||||||
|
<td style="{{ amount }}">– {{ (servicesDiscountAmount / 100)|format_currency(currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if discountSum > 0 %}
|
||||||
|
{#- The frozen snapshot wins where it exists; without one the discounted total has to
|
||||||
|
be derived from the very rows shown above, so the mail can never contradict itself. -#}
|
||||||
|
<tr>
|
||||||
|
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">Gesamtpreis nach Rabatt</td>
|
||||||
|
<td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||||
|
{{ ((booking.totalPrice ?? (priceBreakdown.total - discountSum)) / 100)|format_currency(currency) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
@@ -26,4 +26,6 @@
|
|||||||
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
|
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% include 'email/_price_breakdown.html.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,37 +1,25 @@
|
|||||||
{% extends 'email/layout.html.twig' %}
|
{% extends 'email/layout.html.twig' %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% set info_url = 'https://www.ep-reisen.de/reisen-fuer-gruppen/infos-zusatzleistungen/reiseinfos/ablauf-und-fristen-eurer-buchung/ablauf-und-fristen-eurer-buchung-fuer-sommeraufenthalte/' %}
|
{# Two states only: the office has published an offer (access link present), or the
|
||||||
|
inquiry has just arrived and the offer still has to be prepared. #}
|
||||||
{% if accessLink %}
|
{% if accessLink %}
|
||||||
<h1>
|
<h1>
|
||||||
{{ booking.accepted ? 'Deine Buchung ist bestätigt' : 'Dein Angebot ist bereit' }}
|
Dein Angebot ist bereit
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Hallo {{ booking.firstName }},
|
Hallo {{ booking.firstName }},
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if not booking.accepted %}
|
<p>
|
||||||
<p>
|
vielen Dank für deine Anfrage! Dein persönliches Angebot ist fertig. Schau es dir in Ruhe an und buche
|
||||||
vielen Dank! Hiermit bestätigen wir den Eingang deiner Buchung.
|
verbindlich, wenn alles passt. Wir prüfen die Buchung anschließend und bestätigen sie dir per E-Mail.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der
|
|
||||||
Gruppenabwicklung. Sie werden sich rechtzeitig bei Euch melden und alle weiteren Details mit euch
|
|
||||||
besprechen. Keine Sorge, sollte es sich ein wenig verzögern, der Termin ist fest für euch reserviert und
|
|
||||||
geblockt! Wie es danach für euch weitergeht, könnt ihr euch <a href="{{ info_url }}" class="underline">hier schon einmal anschauen</a>.
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<p>
|
|
||||||
vielen Dank für deine Anfrage! Wir melden uns so schnell wie möglich bei dir mit deinem persönlichen
|
|
||||||
Angebot.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ accessLink }}" class="button">
|
<a href="{{ accessLink }}" class="button">
|
||||||
{{ booking.accepted ? 'Buchung ansehen' : 'Angebot ansehen & bestätigen' }}
|
Angebot ansehen & buchen
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -64,6 +52,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% include 'email/_price_breakdown.html.twig' %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Bei Fragen antworte einfach auf diese E-Mail.
|
Bei Fragen antworte einfach auf diese E-Mail.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
vielen Dank! Hiermit bestätigen wir den Eingang deiner Buchung.
|
wir haben deine Buchung geprüft und bestätigen sie dir hiermit verbindlich.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der
|
Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% include 'email/_price_breakdown.html.twig' %}
|
||||||
|
|
||||||
{% if booking.remarks %}
|
{% if booking.remarks %}
|
||||||
<h2>
|
<h2>
|
||||||
Anmerkungen
|
Anmerkungen
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% include 'email/_price_breakdown.html.twig' %}
|
||||||
|
|
||||||
{% if booking.remarks %}
|
{% if booking.remarks %}
|
||||||
<h2>Anmerkungen</h2>
|
<h2>Anmerkungen</h2>
|
||||||
<p>{{ booking.remarks|nl2br }}</p>
|
<p>{{ booking.remarks|nl2br }}</p>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
{% import _self as macros %}
|
{% import _self as macros %}
|
||||||
|
|
||||||
{% block title %}{{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }}{% endblock %}
|
{% block title %}{{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}{% endblock %}
|
||||||
|
|
||||||
{% block background %}bg-outer bg-outer--summer{% endblock %}
|
{% block background %}bg-outer bg-outer--summer{% endblock %}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
|
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
|
||||||
{{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }}
|
{{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-2xl">
|
<p class="text-2xl">
|
||||||
{{ ctx.accommodation.name }}
|
{{ ctx.accommodation.name }}
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
|
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold mb-2">
|
<h2 class="text-xl font-bold mb-2">
|
||||||
{{ booking.accepted ? 'Gebuchte' : 'Angefragte' }} Leistungen
|
{{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angefragte' }} Leistungen
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="list-disc pl-4">
|
<ul class="list-disc pl-4">
|
||||||
{% if booking.boardServiceLabel %}
|
{% if booking.boardServiceLabel %}
|
||||||
@@ -86,11 +86,19 @@
|
|||||||
Angebot verbindlich buchen
|
Angebot verbindlich buchen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elseif booking.confirmedAt is not null %}
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{{ icon('info', 'w-5 h-5') }}
|
||||||
|
<div class="text-gray-500">
|
||||||
|
Buchung bestätigt am {{ booking.confirmedAt | date('d.m.Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% elseif booking.acceptedAt is not null %}
|
{% elseif booking.acceptedAt is not null %}
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{{ icon('info', 'w-5 h-5') }}
|
{{ icon('info', 'w-5 h-5') }}
|
||||||
<div class="text-gray-500">
|
<div class="text-gray-500">
|
||||||
Angebot angenommen am {{ booking.acceptedAt | date('d.m.Y') }}
|
Am {{ booking.acceptedAt | date('d.m.Y') }} bei uns eingegangen — wir prüfen deine
|
||||||
|
Buchung und melden uns mit der Bestätigung bei dir.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
title: 'Vielen Dank!',
|
title: 'Vielen Dank!',
|
||||||
messages: [
|
messages: [
|
||||||
resultType == 'booking'
|
resultType == 'booking'
|
||||||
? 'Deine Buchung ist bei uns eingegangen. Du erhältst in Kürze eine Bestätigung.'
|
? 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhälst du eine Bestätigung per E-Mail.'
|
||||||
: 'Deine Anfrage ist bei uns eingegangen. Wir melden uns so schnell wie möglich bei dir.'
|
: 'Deine Anfrage ist bei uns eingegangen. Wir melden uns so schnell wie möglich bei dir.'
|
||||||
]
|
]
|
||||||
} %}
|
} %}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Admin\AccommodationBooking\ConfirmController;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Enum\Groups\AccommodationBookingStatus;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the guards around the confirm action — the confirmation itself is tested in
|
||||||
|
* AccommodationBookingServiceTest.
|
||||||
|
*/
|
||||||
|
class ConfirmControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGetRendersTheConfirmationModal(): void
|
||||||
|
{
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('confirmBooking');
|
||||||
|
|
||||||
|
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
|
||||||
|
|
||||||
|
$response = $controller->index($this->receivedBooking(), Request::create('/admin/accommodation-booking/1/confirm'));
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('admin/accommodation_booking/modal_confirm.html.twig', $controller->renderedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostConfirmsTheBookingAndRedirectsTheBrowser(): void
|
||||||
|
{
|
||||||
|
$booking = $this->receivedBooking();
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::once())->method('confirmBooking')->with($booking);
|
||||||
|
|
||||||
|
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
|
||||||
|
|
||||||
|
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
|
||||||
|
|
||||||
|
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostWithAnInvalidTokenIsDenied(): void
|
||||||
|
{
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('confirmBooking');
|
||||||
|
|
||||||
|
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class), tokenValid: false);
|
||||||
|
|
||||||
|
$this->expectException(AccessDeniedException::class);
|
||||||
|
|
||||||
|
$controller->index($this->receivedBooking(), Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider nonReceivedStatuses
|
||||||
|
*/
|
||||||
|
public function testABookingThatIsNotAwaitingValidationCannotBeConfirmed(AccommodationBookingStatus $status): void
|
||||||
|
{
|
||||||
|
$booking = $this->receivedBooking();
|
||||||
|
$booking->setStatus($status);
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('confirmBooking');
|
||||||
|
|
||||||
|
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
|
||||||
|
|
||||||
|
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{AccommodationBookingStatus}>
|
||||||
|
*/
|
||||||
|
public static function nonReceivedStatuses(): iterable
|
||||||
|
{
|
||||||
|
yield 'draft' => [AccommodationBookingStatus::Draft];
|
||||||
|
yield 'open' => [AccommodationBookingStatus::Open];
|
||||||
|
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
|
||||||
|
yield 'discarded' => [AccommodationBookingStatus::Discarded];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testABookingWithoutAnEmailAddressIsNotConfirmed(): void
|
||||||
|
{
|
||||||
|
$booking = $this->receivedBooking();
|
||||||
|
$booking->setEmail(null);
|
||||||
|
|
||||||
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
|
$bookingService->expects(self::never())->method('confirmBooking');
|
||||||
|
|
||||||
|
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
|
||||||
|
|
||||||
|
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
|
||||||
|
|
||||||
|
self::assertSame(['error'], array_column($controller->flashes, 'type'));
|
||||||
|
self::assertStringContainsString('app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function receivedBooking(): AccommodationBooking
|
||||||
|
{
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
|
$booking->setGroupName('Schulklasse 7b');
|
||||||
|
$booking->setEmail('[email protected]');
|
||||||
|
|
||||||
|
return $booking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TestableConfirmController extends ConfirmController
|
||||||
|
{
|
||||||
|
public ?string $renderedView = null;
|
||||||
|
|
||||||
|
/** @var list<array{type: string, message: mixed}> */
|
||||||
|
public array $flashes = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
AccommodationBookingService $bookingService,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
private readonly bool $tokenValid = true,
|
||||||
|
) {
|
||||||
|
parent::__construct($bookingService, $logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||||
|
{
|
||||||
|
return $this->tokenValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||||
|
{
|
||||||
|
$this->renderedView = $view;
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addFlash(string $type, mixed $message): void
|
||||||
|
{
|
||||||
|
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||||
|
{
|
||||||
|
return '/'.$route.'?'.http_build_query($parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,11 +32,13 @@ class EditControllerTest extends TestCase
|
|||||||
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, $bookingService);
|
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, $bookingService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTransitionToAcceptedIssuesAccessLinkAndSendsCustomerEmail(): void
|
public function testTransitionToConfirmedSendsNothing(): void
|
||||||
{
|
{
|
||||||
$bookingService = $this->assertNotified(AccommodationBookingStatus::Accepted);
|
// The binding confirmation only ever goes out through the explicit confirm action,
|
||||||
|
// never as a side effect of editing the status.
|
||||||
|
$bookingService = $this->assertNotNotified();
|
||||||
|
|
||||||
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Accepted, $bookingService);
|
$this->submitStatusChange(AccommodationBookingStatus::Received, AccommodationBookingStatus::Confirmed, $bookingService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTransitionToDiscardedSendsNothing(): void
|
public function testTransitionToDiscardedSendsNothing(): void
|
||||||
@@ -50,7 +52,7 @@ class EditControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$bookingService = $this->assertNotNotified();
|
$bookingService = $this->assertNotNotified();
|
||||||
|
|
||||||
$this->submitStatusChange(AccommodationBookingStatus::Accepted, AccommodationBookingStatus::Accepted, $bookingService);
|
$this->submitStatusChange(AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Confirmed, $bookingService);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService
|
private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
$booking->setAccommodationDiscount(10);
|
$booking->setAccommodationDiscount(10);
|
||||||
$booking->setBoardServiceDiscount(20);
|
$booking->setBoardServiceDiscount(20);
|
||||||
$booking->setAdditionalServicesDiscount(30);
|
$booking->setAdditionalServicesDiscount(30);
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||||
$booking->setType(AccommodationBookingType::Booking);
|
$booking->setType(AccommodationBookingType::Booking);
|
||||||
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
|
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertSame([
|
self::assertSame([
|
||||||
'uuid' => $booking->getUuid(),
|
'uuid' => $booking->getUuid(),
|
||||||
'status' => 'accepted',
|
'status' => 'confirmed',
|
||||||
'type' => 'booking',
|
'type' => 'booking',
|
||||||
'dateFrom' => '2026-07-20',
|
'dateFrom' => '2026-07-20',
|
||||||
'dateTo' => '2026-07-25',
|
'dateTo' => '2026-07-25',
|
||||||
@@ -97,6 +97,7 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
'childrenCount' => 1,
|
'childrenCount' => 1,
|
||||||
'groupName' => 'Schulklasse 7b',
|
'groupName' => 'Schulklasse 7b',
|
||||||
'acceptedAt' => '2026-07-15T10:00:00+00:00',
|
'acceptedAt' => '2026-07-15T10:00:00+00:00',
|
||||||
|
'confirmedAt' => null,
|
||||||
'personalData' => [
|
'personalData' => [
|
||||||
'salutation' => 'Frau',
|
'salutation' => 'Frau',
|
||||||
'firstName' => 'Mia',
|
'firstName' => 'Mia',
|
||||||
@@ -218,7 +219,7 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
->method('acceptBooking')
|
->method('acceptBooking')
|
||||||
->with($booking)
|
->with($booking)
|
||||||
->willReturnCallback(static function (AccommodationBooking $b): void {
|
->willReturnCallback(static function (AccommodationBooking $b): void {
|
||||||
$b->setStatus(AccommodationBookingStatus::Accepted);
|
$b->setStatus(AccommodationBookingStatus::Received);
|
||||||
$b->setAcceptedAt(new \DateTimeImmutable());
|
$b->setAcceptedAt(new \DateTimeImmutable());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,9 +229,9 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertSame('accepted', $payload['status']);
|
self::assertSame('received', $payload['status']);
|
||||||
self::assertSame('inquiry', $payload['type']);
|
self::assertSame('inquiry', $payload['type']);
|
||||||
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus());
|
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
|
||||||
self::assertNotNull($booking->getAcceptedAt());
|
self::assertNotNull($booking->getAcceptedAt());
|
||||||
self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']);
|
self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']);
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
$booking->setFirstName('Tom');
|
$booking->setFirstName('Tom');
|
||||||
$booking->setLastName('Beispiel');
|
$booking->setLastName('Beispiel');
|
||||||
$booking->setEmail('[email protected]');
|
$booking->setEmail('[email protected]');
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
$booking->setAcceptedAt($acceptedAt);
|
$booking->setAcceptedAt($acceptedAt);
|
||||||
|
|
||||||
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
||||||
@@ -267,7 +268,7 @@ class AccommodationBookingControllerTest extends TestCase
|
|||||||
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertSame('accepted', $payload['status']);
|
self::assertSame('received', $payload['status']);
|
||||||
self::assertSame($acceptedAt, $booking->getAcceptedAt());
|
self::assertSame($acceptedAt, $booking->getAcceptedAt());
|
||||||
self::assertSame($acceptedAt->format(\DATE_ATOM), $payload['acceptedAt']);
|
self::assertSame($acceptedAt->format(\DATE_ATOM), $payload['acceptedAt']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ class OfferControllerTest extends TestCase
|
|||||||
|
|
||||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||||
self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
||||||
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bestätigt.']], $controller->flashes);
|
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
|
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
|
||||||
@@ -350,7 +350,7 @@ class OfferControllerTest extends TestCase
|
|||||||
public function testConfirmRedirectsWhenAlreadyAccepted(): void
|
public function testConfirmRedirectsWhenAlreadyAccepted(): void
|
||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
|
|
||||||
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
||||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||||
@@ -474,7 +474,7 @@ final class TestableOfferController extends OfferController
|
|||||||
$this->renderedParameters = $parameters;
|
$this->renderedParameters = $parameters;
|
||||||
|
|
||||||
if ('groups/booking/offer.html.twig' === $view) {
|
if ('groups/booking/offer.html.twig' === $view) {
|
||||||
$content = $parameters['booking']->isAccepted() ? '<html>confirmed</html>' : '<html>offer</html>';
|
$content = null !== $parameters['booking']->getAcceptedAt() ? '<html>confirmed</html>' : '<html>offer</html>';
|
||||||
} else {
|
} else {
|
||||||
$content = '<html>unavailable</html>';
|
$content = '<html>unavailable</html>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ class AccommodationBookingTypeTest extends TestCase
|
|||||||
public static function customerFacingStatuses(): iterable
|
public static function customerFacingStatuses(): iterable
|
||||||
{
|
{
|
||||||
yield 'open' => [AccommodationBookingStatus::Open];
|
yield 'open' => [AccommodationBookingStatus::Open];
|
||||||
yield 'accepted' => [AccommodationBookingStatus::Accepted];
|
yield 'received' => [AccommodationBookingStatus::Received];
|
||||||
|
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class AccommodationBookingFilterDtoTest extends TestCase
|
|||||||
$filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]'));
|
$filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]'));
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open],
|
[AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Received],
|
||||||
$filter->status,
|
$filter->status,
|
||||||
'accepted and discarded bookings need no further work, so they stay out of the way',
|
'accepted and discarded bookings need no further work, so they stay out of the way',
|
||||||
);
|
);
|
||||||
@@ -82,7 +82,7 @@ class AccommodationBookingFilterDtoTest extends TestCase
|
|||||||
$filter = new AccommodationBookingFilterDto();
|
$filter = new AccommodationBookingFilterDto();
|
||||||
$filter->q = 'meier';
|
$filter->q = 'meier';
|
||||||
$filter->dateFrom = new \DateTimeImmutable('2026-08-01');
|
$filter->dateFrom = new \DateTimeImmutable('2026-08-01');
|
||||||
$filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted];
|
$filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed];
|
||||||
$filter->type = [AccommodationBookingType::Booking];
|
$filter->type = [AccommodationBookingType::Booking];
|
||||||
|
|
||||||
self::assertSame(4, $filter->activeCount());
|
self::assertSame(4, $filter->activeCount());
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setEmail('[email protected]');
|
$booking->setEmail('[email protected]');
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||||
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
|
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
|
||||||
|
|
||||||
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
|
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
|
||||||
@@ -265,7 +265,7 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
|
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAcceptBookingAcceptsOpenInquiryAndSendsNotifications(): void
|
public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
|
||||||
{
|
{
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$entityManager->expects(self::once())->method('flush');
|
$entityManager->expects(self::once())->method('flush');
|
||||||
@@ -274,22 +274,23 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||||
$booking->setEmail('[email protected]');
|
$booking->setEmail('[email protected]');
|
||||||
|
|
||||||
|
// The customer hears nothing until the office has validated the booking.
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
$mailer
|
$mailer
|
||||||
->expects(self::exactly(2))
|
->expects(self::once())
|
||||||
->method('createAndSendEmail')
|
->method('createAndSendEmail')
|
||||||
->with(
|
->with(
|
||||||
self::anything(),
|
self::anything(),
|
||||||
self::callback(static fn (array $options) => in_array($options['to'], ['[email protected]', '[email protected]'], true)
|
self::callback(static fn (array $options) => '[email protected]' === $options['to']
|
||||||
&& '[email protected]' === $options['from']
|
&& '[email protected]' === $options['from']
|
||||||
&& in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)),
|
&& 'email/offer_accepted.html.twig' === $options['template']),
|
||||||
);
|
);
|
||||||
|
|
||||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
||||||
|
|
||||||
$service->acceptBooking($booking);
|
$service->acceptBooking($booking);
|
||||||
|
|
||||||
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus());
|
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
|
||||||
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
|
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
|
||||||
self::assertNotNull($booking->getAcceptedAt());
|
self::assertNotNull($booking->getAcceptedAt());
|
||||||
}
|
}
|
||||||
@@ -339,7 +340,7 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void
|
public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void
|
||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
$booking->setRemarks('vom Telefonat');
|
$booking->setRemarks('vom Telefonat');
|
||||||
|
|
||||||
$service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class));
|
$service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class));
|
||||||
@@ -349,32 +350,105 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
self::assertSame('vom Telefonat', $booking->getRemarks());
|
self::assertSame('vom Telefonat', $booking->getRemarks());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAcceptBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void
|
public function testConfirmBookingConfirmsReceivedBookingAndNotifiesTheCustomer(): void
|
||||||
{
|
{
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
|
$booking->setEmail('[email protected]');
|
||||||
|
|
||||||
|
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
|
||||||
|
$linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link');
|
||||||
|
|
||||||
// Only the office notification goes out — the customer copy has no recipient.
|
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
$mailer
|
$mailer
|
||||||
->expects(self::once())
|
->expects(self::once())
|
||||||
->method('createAndSendEmail')
|
->method('createAndSendEmail')
|
||||||
->with(
|
->with(
|
||||||
self::anything(),
|
self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']),
|
||||||
self::callback(static fn (array $options) => 'office@example.com' === $options['to']),
|
self::callback(static fn (array $options) => 'customer@example.com' === $options['to']
|
||||||
|
&& 'Deine Buchung ist bestätigt' === $options['subject']
|
||||||
|
&& 'email/booking_confirmed_customer.html.twig' === $options['template']),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer, linkSigner: $linkSigner);
|
||||||
|
|
||||||
|
$service->confirmBooking($booking);
|
||||||
|
|
||||||
|
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
|
||||||
|
self::assertNotNull($booking->getConfirmedAt());
|
||||||
|
self::assertNotNull($booking->getAccessLinkIssuedAt(), 'the confirmation links to the booking view');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmBookingNoOpsForAnythingNotAwaitingValidation(): void
|
||||||
|
{
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->expects(self::never())->method('flush');
|
||||||
|
|
||||||
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
$mailer->expects(self::never())->method('createAndSendEmail');
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
||||||
|
|
||||||
|
foreach ([AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Discarded] as $status) {
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
$booking->setStatus($status);
|
||||||
|
$booking->setEmail('[email protected]');
|
||||||
|
|
||||||
|
$service->confirmBooking($booking);
|
||||||
|
|
||||||
|
self::assertSame($status, $booking->getStatus());
|
||||||
|
self::assertNull($booking->getConfirmedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookingEmailsCarryThePriceBreakdown(): void
|
||||||
|
{
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
$booking->setEmail('[email protected]');
|
||||||
|
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||||
|
|
||||||
|
$breakdown = ['total' => 10000, 'currency' => 'EUR'];
|
||||||
|
$booking->setPriceSnapshot($breakdown, 9000, 'CHF', 1);
|
||||||
|
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||||
|
$breakdownCalculator->method('compute')->with($booking)->willReturn($breakdown);
|
||||||
|
|
||||||
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
$mailer
|
||||||
|
->expects(self::once())
|
||||||
|
->method('createAndSendEmail')
|
||||||
|
->with(
|
||||||
|
// The stored pricing currency wins over the one frozen in the breakdown.
|
||||||
|
self::callback(static fn (array $context) => $breakdown === $context['priceBreakdown']
|
||||||
|
&& 'CHF' === $context['currency']),
|
||||||
|
self::anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator);
|
||||||
|
|
||||||
|
$service->sendCustomerConfirmationEmail($booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void
|
||||||
|
{
|
||||||
|
$booking = new AccommodationBooking();
|
||||||
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
|
|
||||||
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
$mailer->expects(self::never())->method('createAndSendEmail');
|
||||||
|
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
$logger
|
$logger
|
||||||
->expects(self::once())
|
->expects(self::once())
|
||||||
->method('warning')
|
->method('warning')
|
||||||
->with('Failed to send offer accepted customer email', self::anything());
|
->with('Failed to send booking confirmed customer email', self::anything());
|
||||||
|
|
||||||
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
|
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
|
||||||
|
|
||||||
$service->acceptBooking($booking);
|
$service->confirmBooking($booking);
|
||||||
|
|
||||||
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus(), 'the acceptance itself must not fail');
|
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus(), 'the confirmation itself must not fail');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void
|
public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void
|
||||||
@@ -388,7 +462,7 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
||||||
|
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
|
|
||||||
$service->acceptBooking($booking);
|
$service->acceptBooking($booking);
|
||||||
|
|
||||||
@@ -418,7 +492,7 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void
|
public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void
|
||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||||
|
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
|
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
|
||||||
@@ -431,11 +505,11 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
$service->sendOfferAcceptedNotificationEmail($booking);
|
$service->sendOfferAcceptedNotificationEmail($booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void
|
public function testSendBookingConfirmedCustomerEmailLogsAndSwallowsMailerFailures(): void
|
||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setEmail('[email protected]');
|
$booking->setEmail('[email protected]');
|
||||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||||
|
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
|
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
|
||||||
@@ -445,7 +519,7 @@ class AccommodationBookingServiceTest extends TestCase
|
|||||||
|
|
||||||
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
|
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
|
||||||
|
|
||||||
$service->sendOfferAcceptedCustomerEmail($booking);
|
$service->sendBookingConfirmedCustomerEmail($booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void
|
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user