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
+1
View File
@@ -14,6 +14,7 @@
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"dompdf/dompdf": "^3.1",
"fakerphp/faker": "^1.24",
"flagception/flagception-bundle": "^6.1",
"knplabs/knp-menu-bundle": "^3.7",
Generated
+379 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f04202a445d42ebff0dd0e8dce335163",
"content-hash": "2cd2256c9e4ddda2be9bb1c611b9b7e8",
"packages": [
{
"name": "carbonphp/carbon-doctrine-types",
@@ -1344,6 +1344,161 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.6",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
},
"time": "2026-07-20T12:29:38+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.6.0",
@@ -4527,6 +4682,86 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "[email protected]"
},
{
"name": "Jake Hotson",
"email": "[email protected]"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{
"name": "spatie/crypto",
"version": "2.1.2",
@@ -10679,6 +10914,149 @@
],
"time": "2026-07-20T15:18:49+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "twig/extra-bundle",
"version": "v3.24.0",
@@ -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;
@@ -89,6 +89,11 @@
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}">
bearbeiten
</twig:dropdown:link>
{% if booking.confirmed %}
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
PDF herunterladen
</twig:dropdown:link>
{% endif %}
</twig:dropdown>
</td>
</tr>
@@ -4,9 +4,8 @@
{% 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.
Möchtest du die Buchung für <em>{{ booking.groupName }}</em> verbindlich bestätigen und die Bestätigungs-E-Mail
versenden?
</div>
{% endblock %}
@@ -181,15 +181,14 @@
<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.
Die Buchung des Kunden ist eingegangen, aber es wurde noch keine Bestätigung verschickt.
</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
Buchung per E-Mail bestätigen
</button>
</div>
{% endif %}
@@ -238,8 +237,15 @@
<a href="{{ returnUrl }}" class="button button--secondary button--small">
zurück
</a>
<a href="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}" class="button button--primary button--small">
bearbeiten
</a>
<div class="flex items-center gap-2">
{% if booking.confirmed %}
<a href="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}" class="button button--secondary button--small">
PDF herunterladen
</a>
{% endif %}
<a href="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}" class="button button--primary button--small">
bearbeiten
</a>
</div>
</div>
{% endblock %}
+5 -26
View File
@@ -1,6 +1,8 @@
{#- 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). -#}
{#- Prices only — travel dates and group size belong to _stay_details.html.twig and the
PDF's own data block. Mirrors groups/booking/_offer_summary.html.twig, but with inline
styles: neither the email layout nor dompdf ships table styling or Tailwind.
Context: booking, priceBreakdown, currency (set in AccommodationBookingService and
AccommodationBookingPdfGenerator). -#}
{% 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;' %}
@@ -9,29 +11,6 @@
<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 (03 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 }}">
+38
View File
@@ -0,0 +1,38 @@
{#- Accommodation and group facts for booking emails, kept out of _price_breakdown.html.twig
so that partial holds prices only. Context: booking. -#}
<h2>Unterkunft</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Nächte</strong></td>
<td>{{ booking.nights }}</td>
</tr>
<tr>
<td><strong>Anzahl Personen</strong></td>
<td>{{ booking.paxCount }}</td>
</tr>
{% if booking.minorsCount > 0 %}
<tr>
<td><strong>davon Kinder (03 J.)</strong></td>
<td>{{ booking.minorsCount }}</td>
</tr>
{% endif %}
{% if booking.childrenCount > 0 %}
<tr>
<td><strong>davon Kinder (4{{ booking.accommodation.maxAdolescentAge }} J.)</strong></td>
<td>{{ booking.childrenCount }}</td>
</tr>
{% endif %}
</table>
@@ -11,21 +11,7 @@
</p>
{% endif %}
<h2>Unterkunft</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
</table>
{% include 'email/_stay_details.html.twig' %}
{% include 'email/_price_breakdown.html.twig' %}
{% endblock %}
@@ -36,21 +36,7 @@
</p>
{% endif %}
<h2>Unterkunft</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
</table>
{% include 'email/_stay_details.html.twig' %}
{% include 'email/_price_breakdown.html.twig' %}
@@ -27,24 +27,7 @@
</p>
{% endif %}
<h2>
Unterkunft
</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
</table>
{% include 'email/_stay_details.html.twig' %}
{% include 'email/_price_breakdown.html.twig' %}
+1 -15
View File
@@ -9,21 +9,7 @@
</p>
{% endif %}
<h2>Unterkunft</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
</table>
{% include 'email/_stay_details.html.twig' %}
{% include 'email/_price_breakdown.html.twig' %}
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Buchung {{ booking.id }} {{ booking.groupName }}</title>
<style>
@page { size: A4 portrait; margin: 16mm 15mm; }
body {
font-family: 'DejaVu Sans', sans-serif;
font-size: 11px;
line-height: 1.4;
color: #111827;
margin: 0;
}
/* The included price breakdown emits a bare <h2>; without these rules it would
fall back to the browser defaults instead of the email layout's typography. */
h1 { font-size: 16px; margin: 0 0 2px; }
h2 { font-size: 12px; margin: 14px 0 4px; padding-bottom: 2px; border-bottom: 1px solid #d1d5db; }
table { width: 100%; border-collapse: collapse; }
td { vertical-align: top; }
.header { width: 100%; margin-bottom: 14px; }
.header td { vertical-align: middle; }
.header .meta { text-align: right; font-size: 10px; color: #6b7280; }
.columns { table-layout: fixed; }
.columns > tr > td { width: 50%; padding-right: 12px; }
.data th { width: 42%; text-align: left; font-weight: normal; color: #6b7280; padding: 2px 8px 2px 0; }
.data td { padding: 2px 0; }
/* The shared breakdown partial is sized for emails and carries its metrics as
inline styles, so scaling it down for print needs !important. */
.prices table { font-size: 11px !important; }
.prices td { padding: 2px 0 !important; }
.prices span { font-size: 9px !important; }
.footer { margin-top: 18px; font-size: 9px; color: #9ca3af; }
</style>
</head>
<body>
<table class="header">
<tr>
<td><img src="{{ logoSrc }}" alt="" style="height: 34px;"></td>
{#- No booking number: the local id has no counterpart in BusPro. -#}
<td class="meta">
{% if booking.confirmedAt %}Bestätigt am {{ booking.confirmedAt|date('d.m.Y') }}{% endif %}
</td>
</tr>
</table>
<h1>{{ booking.type.label }} · {{ booking.groupName }}</h1>
<table class="columns">
<tr>
<td>
<h2>Kontakt</h2>
<table class="data">
<tr><th>Gruppenname</th><td>{{ booking.groupName }}</td></tr>
<tr><th>Anrede</th><td>{{ booking.salutation|default('') }}</td></tr>
<tr><th>Name</th><td>{{ booking.firstName }} {{ booking.lastName }}</td></tr>
<tr><th>E-Mail</th><td>{{ booking.email|default('') }}</td></tr>
<tr><th>Telefon</th><td>{{ booking.phone|default('') }}</td></tr>
<tr>
<th>Adresse</th>
<td>
{% if booking.street %}
{{ booking.street }}<br>{{ booking.zip }} {{ booking.city }}
{% else %}
{% endif %}
</td>
</tr>
</table>
</td>
<td>
<h2>Reisedaten</h2>
<table class="data">
<tr><th>Gruppenhaus</th><td>{{ booking.accommodation.name }}</td></tr>
<tr><th>Anreise</th><td>{{ booking.dateFrom|date('d.m.Y') }}</td></tr>
<tr><th>Abreise</th><td>{{ booking.dateTo|date('d.m.Y') }}</td></tr>
<tr><th>Nächte</th><td>{{ booking.nights }}</td></tr>
<tr><th>Anzahl Personen</th><td>{{ booking.paxCount }}</td></tr>
{% if booking.minorsCount > 0 %}
<tr><th>davon Kinder (03 J.)</th><td>{{ booking.minorsCount }}</td></tr>
{% endif %}
{% if booking.childrenCount > 0 %}
<tr><th>davon Kinder (4{{ booking.accommodation.maxAdolescentAge }} J.)</th><td>{{ booking.childrenCount }}</td></tr>
{% endif %}
<tr><th>Art</th><td>{{ booking.type.label }}</td></tr>
</table>
</td>
</tr>
</table>
{% if booking.boardServiceLabel or booking.additionalServices|length > 0 %}
<h2>Gebuchte Leistungen</h2>
<table class="data">
{% if booking.boardServiceLabel %}
<tr><th>Verpflegung</th><td>{{ booking.boardServiceLabel }}</td></tr>
{% endif %}
{% for service in booking.additionalServices %}
<tr><th>{{ loop.first ? 'Zusatzleistungen' : '' }}</th><td>{{ service.label }}</td></tr>
{% endfor %}
</table>
{% endif %}
<div class="prices">
{% include 'email/_price_breakdown.html.twig' %}
</div>
{% if booking.remarks %}
<h2>Bemerkungen</h2>
<p style="margin: 0;">{{ booking.remarks|nl2br }}</p>
{% endif %}
<p class="footer">Erstellt am {{ 'now'|date('d.m.Y') }}</p>
</body>
</html>
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\PdfController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingPdfGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Covers the guards around the PDF download — the rendering itself is tested in
* AccommodationBookingPdfGeneratorTest.
*/
class PdfControllerTest extends TestCase
{
public function testAConfirmedBookingIsDownloadedAsPdf(): void
{
$booking = $this->booking(AccommodationBookingStatus::Confirmed);
$generator = $this->createMock(AccommodationBookingPdfGenerator::class);
$generator->expects(self::once())
->method('createDownloadResponse')
->with($booking)
->willReturn(new Response('%PDF-1.7'));
$response = (new TestablePdfController($generator))->index($booking);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
/**
* @dataProvider nonConfirmedStatuses
*/
public function testABookingThatIsNotConfirmedCannotBeDownloaded(AccommodationBookingStatus $status): void
{
$generator = $this->createMock(AccommodationBookingPdfGenerator::class);
$generator->expects(self::never())->method('createDownloadResponse');
$controller = new TestablePdfController($generator);
$response = $controller->index($this->booking($status));
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
self::assertSame(['warning'], array_column($controller->flashes, 'type'));
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function nonConfirmedStatuses(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'open' => [AccommodationBookingStatus::Open];
yield 'received' => [AccommodationBookingStatus::Received];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testAFailingGeneratorRedirectsWithAFlash(): void
{
$generator = $this->createMock(AccommodationBookingPdfGenerator::class);
$generator->method('createDownloadResponse')->willThrowException(new \RuntimeException('keine Preise'));
$controller = new TestablePdfController($generator);
$response = $controller->index($this->booking(AccommodationBookingStatus::Confirmed));
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
self::assertStringContainsString('keine Preise', (string) $controller->flashes[0]['message']);
}
private function booking(AccommodationBookingStatus $status): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setGroupName('Schulklasse 7b');
return $booking;
}
}
final class TestablePdfController extends PdfController
{
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
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);
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingPdfGenerator;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Extra\Intl\IntlExtension;
use Twig\Loader\FilesystemLoader;
/**
* Renders through the real templates and dompdf — the point of this service is that the
* document survives the trip through dompdf, which a mocked Twig would not prove.
*/
class AccommodationBookingPdfGeneratorTest extends TestCase
{
public function testTheDocumentFitsOnASingleA4Page(): void
{
$pdf = $this->createGenerator($this->breakdown())->render($this->booking());
self::assertStringStartsWith('%PDF-', $pdf);
self::assertSame(1, $this->pageCount($pdf));
}
public function testTheDownloadIsAnAttachmentNamedAfterTheGroup(): void
{
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
self::assertSame('application/pdf', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment;', (string) $response->headers->get('Content-Disposition'));
self::assertStringContainsString('buchung-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition'));
}
public function testABookingWithoutPricesCannotBeRendered(): void
{
$this->expectException(\RuntimeException::class);
$this->createGenerator(null)->render($this->booking());
}
/**
* @param array<string, mixed>|null $breakdown
*/
private function createGenerator(?array $breakdown): AccommodationBookingPdfGenerator
{
$twig = new Environment(new FilesystemLoader(__DIR__.'/../../templates'));
$twig->addExtension(new IntlExtension());
$calculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$calculator->method('compute')->willReturn($breakdown);
return new AccommodationBookingPdfGenerator($twig, $calculator, \dirname(__DIR__, 2));
}
private function booking(): AccommodationBooking
{
$accommodation = new Accommodation();
$accommodation->setName('Haus Bergblick');
$accommodation->setMaxAdolescentAge(11);
$accommodation->setCurrency('EUR');
$booking = new AccommodationBooking();
$booking->setAccommodation($accommodation);
$booking->setGroupName('Schulklasse 7b');
$booking->setSalutation('Frau');
$booking->setFirstName('Änne');
$booking->setLastName('Müller');
$booking->setEmail('[email protected]');
$booking->setStreet('Hauptstraße 1');
$booking->setZip('50667');
$booking->setCity('Köln');
$booking->setDateFrom(new \DateTimeImmutable('2026-07-06'));
$booking->setDateTo(new \DateTimeImmutable('2026-07-11'));
$booking->setPaxCount(32);
$booking->setChildrenCount(4);
$booking->setBoardServiceLabel('Vollpension');
$booking->addAdditionalServiceSnapshot('Bettwäsche', 900, 'per_person', 5);
$booking->setRemarks('Zwei Vegetarier.');
$booking->setConfirmedAt(new \DateTimeImmutable('2026-08-18'));
return $booking;
}
/**
* @return array<string, mixed>
*/
private function breakdown(): array
{
return [
'effectivePax' => 32,
'totalPax' => 32,
'includedPax' => 25,
'nights' => 5,
'basePrice' => 250000,
'additionalPersonsPrice' => 70000,
'shortTermSurcharge' => 0,
'undersubscriptionSurcharge' => 0,
'undersubscriptionThreshold' => 40,
'boardPrice' => 480000,
'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]],
'servicesPrice' => 28800,
'runningCosts' => 16000,
'total' => 844800,
'currency' => 'EUR',
'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]],
'discountedTotal' => 828800,
];
}
/**
* dompdf's CPDF backend writes one /MediaBox for the page tree node and one per page,
* so the page count is the number of occurrences minus one. Reading it back this way
* avoids re-implementing the render pipeline just to reach the canvas.
*/
private function pageCount(string $pdf): int
{
return substr_count($pdf, '/MediaBox') - 1;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\Email\Mailer;
use App\Email\PdfAttachment;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
@@ -17,6 +18,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingPdfGenerator;
use App\Service\AccommodationBookingService;
use App\Service\CmsDataProvider;
use App\Service\PriceTimelineBuilder;
@@ -522,6 +524,57 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendBookingConfirmedCustomerEmail($booking);
}
public function testTheConfirmationEmailCarriesTheBookingPdf(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$attachment = new PdfAttachment('%PDF-1.7', 'buchung.pdf');
$pdfGenerator = $this->createMock(AccommodationBookingPdfGenerator::class);
$pdfGenerator->expects(self::once())->method('createAttachment')->with($booking)->willReturn($attachment);
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(fn (array $options) => [$attachment] === $options['attachments']),
);
$service = $this->createServiceWithAccommodation(mailer: $mailer, pdfGenerator: $pdfGenerator);
$service->sendBookingConfirmedCustomerEmail($booking);
}
public function testAFailingPdfStillLetsTheConfirmationEmailGoOut(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$pdfGenerator = $this->createMock(AccommodationBookingPdfGenerator::class);
$pdfGenerator->method('createAttachment')->willThrowException(new \RuntimeException('no prices'));
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(fn (array $options) => [] === $options['attachments']),
);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('warning');
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger, pdfGenerator: $pdfGenerator);
$service->sendBookingConfirmedCustomerEmail($booking);
}
/**
* The discount arithmetic itself lives in — and is tested with —
* AccommodationBookingBreakdownCalculator; what matters here is that the raw breakdown is
@@ -563,6 +616,7 @@ class AccommodationBookingServiceTest extends TestCase
?LoggerInterface $logger = null,
?AccommodationBookingLinkSigner $linkSigner = null,
?AccommodationBookingBreakdownCalculator $breakdownCalculator = null,
?AccommodationBookingPdfGenerator $pdfGenerator = null,
): AccommodationBookingService {
$accommodation = (new Accommodation())
->setName('Hotel')
@@ -592,6 +646,7 @@ class AccommodationBookingServiceTest extends TestCase
$this->createMock(CmsDataProvider::class),
$linkSigner ?? $this->createMock(AccommodationBookingLinkSigner::class),
$breakdownCalculator ?? $this->createMock(AccommodationBookingBreakdownCalculator::class),
$pdfGenerator ?? $this->createMock(AccommodationBookingPdfGenerator::class),
'[email protected]',
);
}