feat: booking details pdf

This commit is contained in:
Björn Fromme
2026-08-18 14:26:24 +02:00
parent d3666c4618
commit 2d7a02341d
19 changed files with 1054 additions and 100 deletions
@@ -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()]);
}
}
}
+24
View File
@@ -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));
}
}
+26 -1
View File
@@ -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;