From 3d4d88e986c9c81d760ad53ea254167fb1c7cdf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 13 Jan 2026 10:22:03 +0100 Subject: [PATCH] feat: xlsx export of booking edit draft data for admins --- composer.json | 1 + composer.lock | 505 +++++++++++++++--- migrations/Version20260113140000.php | 28 + .../Admin/BookingEditDraftCrudController.php | 53 +- src/Entity/BookingEditDraft.php | 44 ++ src/Service/BookingEditDraftService.php | 12 + src/Service/BookingExportService.php | 353 ++++++++++++ 7 files changed, 912 insertions(+), 84 deletions(-) create mode 100644 migrations/Version20260113140000.php create mode 100644 src/Service/BookingExportService.php diff --git a/composer.json b/composer.json index cff7deb..c7b5018 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index f56e041..a47ec1f 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "j.boggiano@seld.be", + "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": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "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": "mark@lange.demon.co.uk" + } + ], + "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": "mark@demon-angel.eu" + } + ], + "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": "j.boggiano@seld.be", - "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", diff --git a/migrations/Version20260113140000.php b/migrations/Version20260113140000.php new file mode 100644 index 0000000..fb0d98e --- /dev/null +++ b/migrations/Version20260113140000.php @@ -0,0 +1,28 @@ +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'); + } +} diff --git a/src/Controller/Admin/BookingEditDraftCrudController.php b/src/Controller/Admin/BookingEditDraftCrudController.php index 5ac68c4..1218c47 100644 --- a/src/Controller/Admin/BookingEditDraftCrudController.php +++ b/src/Controller/Admin/BookingEditDraftCrudController.php @@ -1,24 +1,32 @@ 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 diff --git a/src/Entity/BookingEditDraft.php b/src/Entity/BookingEditDraft.php index ff48d1f..2186659 100644 --- a/src/Entity/BookingEditDraft.php +++ b/src/Entity/BookingEditDraft.php @@ -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; diff --git a/src/Service/BookingEditDraftService.php b/src/Service/BookingEditDraftService.php index a6dcee3..c1935af 100644 --- a/src/Service/BookingEditDraftService.php +++ b/src/Service/BookingEditDraftService.php @@ -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); } diff --git a/src/Service/BookingExportService.php b/src/Service/BookingExportService.php new file mode 100644 index 0000000..7425e42 --- /dev/null +++ b/src/Service/BookingExportService.php @@ -0,0 +1,353 @@ +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, rooms: array, pickups: array} + */ + 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; + } +}