feat: xlsx export of booking edit draft data for admins

This commit is contained in:
Björn Fromme
2026-01-13 10:22:03 +01:00
parent 5221cea53b
commit 3d4d88e986
7 changed files with 912 additions and 84 deletions
+1
View File
@@ -21,6 +21,7 @@
"nelexa/zip": "^4.0",
"nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4",
"phpstan/phpdoc-parser": "^2.0",
"spatie/crypto": "^2.1",
"symfony/apache-pack": "^1.0",
Generated
+425 -80
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": "a8cba38524e7e05db6de57adc2077b59",
"content-hash": "2249b13be37460c47093abb5ccde86a4",
"packages": [
{
"name": "carbonphp/carbon-doctrine-types",
@@ -75,6 +75,85 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "[email protected]",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "defuse/php-encryption",
"version": "v2.4.0",
@@ -2366,6 +2445,191 @@
],
"time": "2025-12-07T16:03:21+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.1.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.2"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^11.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "[email protected]"
},
{
"name": "Jonatan Männchen",
"email": "[email protected]"
},
{
"name": "Jesse Donat",
"email": "[email protected]"
},
{
"name": "András Kolesár",
"email": "[email protected]"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-01-27T12:07:53+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "[email protected]"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "[email protected]"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@@ -3086,6 +3350,115 @@
},
"time": "2025-11-21T15:09:14+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.4.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/48f2fe37d64c2dece0ef71fb2ac55497566782af",
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.4.0"
},
"time": "2026-01-11T04:52:00+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.48",
@@ -3770,6 +4143,57 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "spatie/crypto",
"version": "2.1.1",
@@ -10458,85 +10882,6 @@
],
"time": "2022-12-23T10:58:28+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "[email protected]",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260113140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add date_id and hotel_id columns to booking_edit_draft table for export functionality';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_edit_draft ADD date_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE booking_edit_draft ADD hotel_id INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE booking_edit_draft DROP date_id');
$this->addSql('ALTER TABLE booking_edit_draft DROP hotel_id');
}
}
@@ -1,24 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Admin\Field\JsonDataField;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Service\BookingExportService;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Bundle\MakerBundle\Doctrine\RelationManyToOne;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingEditDraftCrudController extends AbstractCrudController
{
public function __construct(
private readonly BookingExportService $exportService,
private readonly AdminUrlGenerator $adminUrlGenerator,
) {
}
public static function getEntityFqcn(): string
{
return BookingEditDraft::class;
@@ -26,14 +34,51 @@ class BookingEditDraftCrudController extends AbstractCrudController
public function configureActions(Actions $actions): Actions
{
$exportAction = Action::new('exportExcel', 'Excel Export', 'fa fa-file-excel')
->linkToRoute(
'admin_booking_draft_export',
static fn (BookingEditDraft $entity): array => ['id' => $entity->getId()]
)
->displayIf(static fn (BookingEditDraft $entity): bool => $entity->hasExportData());
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_INDEX, $exportAction)
->add(Crud::PAGE_DETAIL, $exportAction)
->remove(Crud::PAGE_INDEX, Action::NEW)
->remove(Crud::PAGE_INDEX, Action::EDIT)
->remove(Crud::PAGE_DETAIL, Action::EDIT)
;
}
#[Route('/admin/booking-draft/{id}/export', name: 'admin_booking_draft_export', requirements: ['id' => '\d+'])]
public function export(BookingEditDraft $draft): Response
{
if (false === $draft->hasExportData()) {
$this->addFlash('danger', 'Export nicht möglich: Reisedaten fehlen');
$url = $this->adminUrlGenerator
->setController(self::class)
->setAction(Action::INDEX)
->generateUrl();
return $this->redirect($url);
}
try {
return $this->exportService->createExportResponse($draft);
} catch (\RuntimeException $e) {
$this->addFlash('danger', 'Export fehlgeschlagen: ' . $e->getMessage());
$url = $this->adminUrlGenerator
->setController(self::class)
->setAction(Action::INDEX)
->generateUrl();
return $this->redirect($url);
}
}
public function configureCrud(Crud $crud): Crud
{
return $crud
+44
View File
@@ -37,6 +37,18 @@ class BookingEditDraft
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $travelDate;
/**
* BusProNet travel date ID (termin idbuspro) for loading travel data.
*/
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $dateId = null;
/**
* BusProNet hotel ID for loading travel data.
*/
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $hotelId = null;
#[ORM\Column(type: 'json')]
private array $formData = [];
@@ -95,6 +107,38 @@ class BookingEditDraft
return $this;
}
public function getDateId(): ?int
{
return $this->dateId;
}
public function setDateId(?int $dateId): static
{
$this->dateId = $dateId;
return $this;
}
public function getHotelId(): ?int
{
return $this->hotelId;
}
public function setHotelId(?int $hotelId): static
{
$this->hotelId = $hotelId;
return $this;
}
/**
* Checks if the draft has the required travel IDs for export functionality.
*/
public function hasExportData(): bool
{
return null !== $this->dateId && null !== $this->hotelId;
}
public function getFormData(): array
{
return $this->formData;
+12
View File
@@ -63,6 +63,8 @@ class BookingEditDraftService
$existingDraft = $this->findDraft($user, $bookingId);
$bookingNumber = $bookingDto->booking?->bookingNumber;
$dateId = $bookingDto->travel->id;
$hotelId = $bookingDto->travel->hotelId;
if (null !== $existingDraft) {
$existingDraft->setFormData($formData);
@@ -70,9 +72,19 @@ class BookingEditDraftService
if (null === $existingDraft->getBookingNumber() && null !== $bookingNumber) {
$existingDraft->setBookingNumber($bookingNumber);
}
if (null === $existingDraft->getDateId() && null !== $dateId) {
$existingDraft->setDateId($dateId);
}
if (null === $existingDraft->getHotelId() && null !== $hotelId) {
$existingDraft->setHotelId($hotelId);
}
} else {
$draft = new BookingEditDraft($user, $bookingId, $travelDate, $formData);
$draft->setBookingNumber($bookingNumber);
$draft->setDateId($dateId);
$draft->setHotelId($hotelId);
$this->entityManager->persist($draft);
}
+353
View File
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\BookingEditDraft;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Generates Excel exports from booking edit drafts.
*
* This service transforms draft booking data into a downloadable Excel file,
* converting all service, room, and pickup IDs to human-readable labels using
* travel data loaded from XML files.
*/
class BookingExportService
{
private const COLUMN_HEADERS = [
'Vorname',
'Nachname',
'Geburtsdatum',
'E-Mail',
'Mobil',
'Geschlecht',
'Nationalität',
'Straße',
'PLZ',
'Ort',
'Land',
'Größe',
'Gewicht',
'Schuhgröße',
'Zimmer',
'Zimmerwünsche',
'Kennzeichen',
'Skipass',
'Kurse',
'Verpflegung',
'Leihmaterial',
'Leihmaterial-Versicherung',
'Zusatzleistungen',
'Hinfahrt',
'Rückfahrt',
'Zustieg',
'Parkplatz',
'Versicherung',
'Sammelversicherung',
'Kaufgutschein',
'Aktionsgutschein',
];
public function __construct(
private readonly TravelDataService $travelDataService,
) {
}
/**
* Creates a StreamedResponse with the Excel export for the given draft.
*
* @param BookingEditDraft $draft The draft to export
*
* @return StreamedResponse The response containing the Excel file
*
* @throws \RuntimeException If travel data cannot be loaded
*/
public function createExportResponse(BookingEditDraft $draft): StreamedResponse
{
if (false === $draft->hasExportData()) {
throw new \RuntimeException('Draft is missing required travel IDs for export');
}
$travel = $this->travelDataService->getTravelData(
$draft->getDateId(),
$draft->getHotelId()
);
if (null === $travel) {
throw new \RuntimeException('Could not load travel data for export');
}
$lookups = $this->buildLookups($travel);
$spreadsheet = $this->createSpreadsheet($draft, $lookups);
$filename = sprintf(
'buchung_%d_%s.xlsx',
$draft->getBookingNumber() ?? $draft->getBookingId(),
$draft->getTravelDate()->format('Y-m-d')
);
$response = new StreamedResponse(function () use ($spreadsheet): void {
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
});
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
$response->headers->set('Cache-Control', 'max-age=0');
return $response;
}
/**
* Builds lookup arrays for converting IDs to labels.
*
* @param \App\BusProNet\Model\Travel $travel The travel data
*
* @return array{services: array<int, string>, rooms: array<int, string>, pickups: array<int, string>}
*/
private function buildLookups(\App\BusProNet\Model\Travel $travel): array
{
$services = [];
$rooms = [];
$pickups = [];
foreach ($travel->additionalServices as $service) {
if (null !== $service->id && null !== $service->label) {
$services[$service->id] = $service->label;
}
}
foreach ($travel->transportationServices as $service) {
if (null !== $service->id && null !== $service->label) {
$services[$service->id] = $service->label;
}
}
foreach ($travel->rooms as $room) {
if (null !== $room->id && null !== $room->label) {
$rooms[$room->id] = $room->label;
}
}
foreach ($travel->pickupsOutbound as $pickup) {
if (null !== $pickup->id) {
$pickups[$pickup->id] = $pickup->getLabel();
}
}
foreach ($travel->pickupsInbound as $pickup) {
if (null !== $pickup->id) {
$pickups[$pickup->id] = $pickup->getLabel();
}
}
return [
'services' => $services,
'rooms' => $rooms,
'pickups' => $pickups,
];
}
/**
* Creates the spreadsheet with participant data.
*
* @param BookingEditDraft $draft The draft containing form data
* @param array $lookups The ID to label lookup arrays
*/
private function createSpreadsheet(BookingEditDraft $draft, array $lookups): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Teilnehmer');
// Write headers
foreach (self::COLUMN_HEADERS as $col => $header) {
$sheet->setCellValue([$col + 1, 1], $header);
}
// Style header row
$headerStyle = [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
];
$lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count(self::COLUMN_HEADERS));
$sheet->getStyle('A1:' . $lastColumn . '1')->applyFromArray($headerStyle);
// Write participant data
$formData = $draft->getFormData();
$participants = $formData['participants'] ?? [];
$row = 2;
foreach ($participants as $participant) {
$this->writeParticipantRow($sheet, $row, $participant, $lookups);
++$row;
}
// Auto-size columns
foreach (range(1, count(self::COLUMN_HEADERS)) as $col) {
$colLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col);
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
}
return $spreadsheet;
}
/**
* Writes a single participant row to the spreadsheet.
*/
private function writeParticipantRow(
\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet,
int $row,
array $participant,
array $lookups
): void {
$personal = $participant['personalData'] ?? [];
$address = $participant['address'] ?? [];
$body = $participant['bodyDimensions'] ?? [];
$room = $participant['roomAssignment'] ?? [];
$services = $participant['services'] ?? [];
$vouchers = $participant['vouchers'] ?? [];
$data = [
$personal['firstName'] ?? '',
$personal['lastName'] ?? '',
$this->formatDate($personal['dateOfBirth'] ?? null),
$personal['email'] ?? '',
$personal['mobile'] ?? '',
$this->mapGender($personal['gender'] ?? null),
$personal['nationality'] ?? '',
$address['street'] ?? '',
$address['postCode'] ?? '',
$address['city'] ?? '',
$address['country'] ?? '',
$body['height'] ?? '',
$body['weight'] ?? '',
$body['shoeSize'] ?? '',
$this->resolveRoom($room['assignedRoomId'] ?? null, $lookups['rooms']),
$room['remarksRoom'] ?? '',
$participant['licensePlate'] ?? '',
$this->resolveService($services['skiPass'] ?? null, $lookups['services']),
$this->resolveServiceArray($services['courses'] ?? [], $lookups['services']),
$this->resolveServiceArray($services['board'] ?? [], $lookups['services']),
$this->resolveServiceArray($services['rentals'] ?? [], $lookups['services']),
$this->resolveService($services['rentalInsurance'] ?? null, $lookups['services']),
$this->resolveServiceArray($services['additionalServices'] ?? [], $lookups['services']),
$this->resolveService($services['transportationOutbound'] ?? null, $lookups['services']),
$this->resolveService($services['transportationInbound'] ?? null, $lookups['services']),
$this->resolvePickup($services['pickup'] ?? null, $lookups['pickups']),
$this->formatBoolean($services['parking'] ?? false),
$this->resolveService($services['insurance'] ?? null, $lookups['services']),
$this->formatBoolean($services['bulkInsuranceBooking'] ?? false),
$vouchers['purchaseVoucherCode'] ?? '',
$vouchers['promoVoucherCode'] ?? '',
];
foreach ($data as $col => $value) {
$sheet->setCellValue([$col + 1, $row], $value);
}
}
/**
* Formats a date string to German format.
*/
private function formatDate(?string $date): string
{
if (null === $date || '' === $date) {
return '';
}
try {
$dateTime = new \DateTimeImmutable($date);
return $dateTime->format('d.m.Y');
} catch (\Exception) {
return $date;
}
}
/**
* Maps gender code to German label.
*/
private function mapGender(?string $gender): string
{
return match ($gender) {
'M' => 'männlich',
'W' => 'weiblich',
'D' => 'divers',
default => $gender ?? '',
};
}
/**
* Formats a boolean value to German text.
*/
private function formatBoolean(bool $value): string
{
return $value ? 'Ja' : 'Nein';
}
/**
* Resolves a single service ID to its label.
*/
private function resolveService(int|string|null $serviceId, array $lookup): string
{
if (null === $serviceId || '' === $serviceId) {
return '';
}
$id = (int) $serviceId;
return $lookup[$id] ?? (string) $serviceId;
}
/**
* Resolves an array of service IDs to labels, joined by pipe.
*/
private function resolveServiceArray(array $serviceIds, array $lookup): string
{
if (true === empty($serviceIds)) {
return '';
}
$labels = [];
foreach ($serviceIds as $serviceId) {
if (null !== $serviceId && '' !== $serviceId) {
$id = (int) $serviceId;
$labels[] = $lookup[$id] ?? (string) $serviceId;
}
}
return implode(', ', $labels);
}
/**
* Resolves a room ID to its label.
*/
private function resolveRoom(?int $roomId, array $lookup): string
{
if (null === $roomId) {
return '';
}
return $lookup[$roomId] ?? (string) $roomId;
}
/**
* Resolves a pickup ID to its label.
*/
private function resolvePickup(?int $pickupId, array $lookup): string
{
if (null === $pickupId) {
return '';
}
return $lookup[$pickupId] ?? (string) $pickupId;
}
}