feat: booking details pdf
This commit is contained in:
@@ -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
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 (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 }}">
|
||||
|
||||
@@ -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 (0–3 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' %}
|
||||
|
||||
|
||||
@@ -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 (0–3 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]',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user