feat: booking details pdf
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Service\AccommodationBookingPdfGenerator;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
class PdfController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly AccommodationBookingPdfGenerator $pdfGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/{id}/pdf', name: 'app_admin_accommodationbooking_pdf')]
|
||||
public function index(AccommodationBooking $booking): Response
|
||||
{
|
||||
if (false === $booking->isConfirmed()) {
|
||||
$this->addFlash('warning', 'Ein PDF kann nur für bestätigte Buchungen erstellt werden.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->pdfGenerator->createDownloadResponse($booking);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('warning', 'Das PDF konnte nicht erstellt werden: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
|
||||
/**
|
||||
* An already rendered PDF, ready to be hung on an email.
|
||||
*/
|
||||
readonly class PdfAttachment implements EmailAttachmentInterface
|
||||
{
|
||||
public function __construct(
|
||||
private string $content,
|
||||
private string $filename,
|
||||
) {
|
||||
}
|
||||
|
||||
public function attachTo(TemplatedEmail $email): void
|
||||
{
|
||||
$email->attach($this->content, $this->filename, 'application/pdf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Email\PdfAttachment;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Renders a one-page A4 booking document for a group accommodation booking.
|
||||
*
|
||||
* The document repeats the data of the admin detail page and includes the very same
|
||||
* price breakdown partial the booking emails use, so all three stay in sync.
|
||||
*/
|
||||
class AccommodationBookingPdfGenerator
|
||||
{
|
||||
private const TEMPLATE = 'pdf/accommodation_booking.html.twig';
|
||||
private const LOGO_PATH = '/assets/images/logo.png';
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \RuntimeException when the booking carries no price information
|
||||
*/
|
||||
public function render(AccommodationBooking $booking): string
|
||||
{
|
||||
$breakdown = $this->breakdownCalculator->compute($booking);
|
||||
|
||||
if (null === $breakdown) {
|
||||
throw new \RuntimeException('Für diese Buchung liegt keine Preisaufschlüsselung vor.');
|
||||
}
|
||||
|
||||
$html = $this->twig->render(self::TEMPLATE, [
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $breakdown,
|
||||
'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'],
|
||||
'logoSrc' => $this->logoDataUri(),
|
||||
]);
|
||||
|
||||
$options = new Options();
|
||||
// DejaVu Sans ships with dompdf and covers umlauts and the currency symbols
|
||||
// emitted by format_currency.
|
||||
$options->setDefaultFont('DejaVu Sans');
|
||||
$options->setDefaultPaperSize('A4');
|
||||
$options->setDefaultPaperOrientation('portrait');
|
||||
$options->setIsRemoteEnabled(false);
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->render();
|
||||
|
||||
return (string) $dompdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \RuntimeException when the booking carries no price information
|
||||
*/
|
||||
public function createDownloadResponse(AccommodationBooking $booking): Response
|
||||
{
|
||||
$response = new Response($this->render($booking));
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$this->buildFilename($booking)
|
||||
));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named after arrival date and group rather than the entity id, which is a local
|
||||
* detail with no counterpart in BusPro and would read as a booking number.
|
||||
*/
|
||||
/**
|
||||
* @throws \RuntimeException when the booking carries no price information
|
||||
*/
|
||||
public function createAttachment(AccommodationBooking $booking): PdfAttachment
|
||||
{
|
||||
return new PdfAttachment($this->render($booking), $this->buildFilename($booking));
|
||||
}
|
||||
|
||||
private function buildFilename(AccommodationBooking $booking): string
|
||||
{
|
||||
$groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString();
|
||||
|
||||
return sprintf(
|
||||
'buchung-%s%s.pdf',
|
||||
$booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum',
|
||||
'' !== $groupName ? '-'.$groupName : ''
|
||||
);
|
||||
}
|
||||
|
||||
private function logoDataUri(): string
|
||||
{
|
||||
$logo = $this->projectDir.self::LOGO_PATH;
|
||||
|
||||
if (false === is_readable($logo)) {
|
||||
throw new \RuntimeException('Das Logo konnte nicht gelesen werden: '.self::LOGO_PATH);
|
||||
}
|
||||
|
||||
return 'data:image/png;base64,'.base64_encode((string) file_get_contents($logo));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Email\EmailAttachmentInterface;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
@@ -39,6 +40,7 @@ class AccommodationBookingService
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly AccommodationBookingPdfGenerator $pdfGenerator,
|
||||
private readonly string $accommodationEmail,
|
||||
) {
|
||||
}
|
||||
@@ -396,6 +398,7 @@ class AccommodationBookingService
|
||||
string $subject,
|
||||
string $template,
|
||||
string $errorMessage,
|
||||
bool $withPdf = false,
|
||||
): void {
|
||||
if (null === $to || '' === trim($to)) {
|
||||
$this->logger->warning($errorMessage, [
|
||||
@@ -421,7 +424,7 @@ class AccommodationBookingService
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'template' => $template,
|
||||
'attachments' => [],
|
||||
'attachments' => $withPdf ? $this->bookingPdfAttachment($booking) : [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -562,9 +565,31 @@ class AccommodationBookingService
|
||||
'Deine Buchung ist bestätigt',
|
||||
'email/booking_confirmed_customer.html.twig',
|
||||
'Failed to send booking confirmed customer email',
|
||||
withPdf: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing price snapshot must not cost the customer their confirmation, so a failed
|
||||
* render degrades to sending the email without the document. Called only once a
|
||||
* recipient is known, so no PDF is rendered for a mail that never goes out.
|
||||
*
|
||||
* @return list<EmailAttachmentInterface>
|
||||
*/
|
||||
private function bookingPdfAttachment(AccommodationBooking $booking): array
|
||||
{
|
||||
try {
|
||||
return [$this->pdfGenerator->createAttachment($booking)];
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Failed to attach the booking PDF to the confirmation email', [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function accessLinkOrNull(AccommodationBooking $booking): ?string
|
||||
{
|
||||
return null !== $booking->getAccessLinkIssuedAt() ? $this->linkSigner->sign($booking) : null;
|
||||
|
||||
Reference in New Issue
Block a user