From 496a707a1623ebc8baaaeb46263a1aa41ada2483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 1 Sep 2026 10:20:31 +0200 Subject: [PATCH] feat: feedback export to excel addresses #869dv9k9z --- composer.json | 1 + composer.lock | 421 ++++++++++++++---- .../Feedback/ExportController.php | 69 +++ src/Repository/FeedbackRepository.php | 35 +- src/Service/Export/FeedbackExporter.php | 262 +++++++++++ .../administrative/feedback/index.html.twig | 4 + tests/Service/Export/FeedbackExporterTest.php | 122 +++++ 7 files changed, 835 insertions(+), 79 deletions(-) create mode 100644 src/Controller/Administrative/Feedback/ExportController.php create mode 100644 src/Service/Export/FeedbackExporter.php create mode 100644 tests/Service/Export/FeedbackExporterTest.php diff --git a/composer.json b/composer.json index 09be261..3fc1b3f 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "nesbot/carbon": "^2.70", "oneup/uploader-bundle": "^5.1", "phpdocumentor/reflection-docblock": "^5.3", + "phpoffice/phpspreadsheet": "^5.9", "phpstan/phpdoc-parser": "^1.22", "scienta/doctrine-json-functions": "^6.3", "setasign/fpdf": "^1.8", diff --git a/composer.lock b/composer.lock index 1e2f873..bfdeba0 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": "f243bce9751e17382a0b20bc5926bc4c", + "content-hash": "68abd860121347dcd60c10cbfc4e3434", "packages": [ { "name": "beberlei/doctrineextensions", @@ -209,6 +209,82 @@ ], "time": "2026-08-21T14:57:29+00:00" }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^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.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -3326,6 +3402,113 @@ ], "time": "2026-04-11T18:38:28+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": "monolog/monolog", "version": "3.10.0", @@ -4154,6 +4337,115 @@ }, "time": "2025-11-21T15:09:14+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.9.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", + "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.2", + "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 || ^11.0", + "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.9.0" + }, + "time": "2026-07-12T19:17:39+00:00" + }, { "name": "phpseclib/phpseclib", "version": "3.0.57", @@ -4777,6 +5069,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": "ralouphie/getallheaders", "version": "3.0.3", @@ -11810,82 +12153,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "composer/pcre", - "version": "3.4.0", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", - "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<2.2.2" - }, - "require-dev": { - "phpstan/phpstan": "^2", - "phpstan/phpstan-deprecation-rules": "^2", - "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^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.4.0" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2026-06-07T11:47:49+00:00" - }, { "name": "composer/semver", "version": "3.4.4", diff --git a/src/Controller/Administrative/Feedback/ExportController.php b/src/Controller/Administrative/Feedback/ExportController.php new file mode 100644 index 0000000..e5b52e8 --- /dev/null +++ b/src/Controller/Administrative/Feedback/ExportController.php @@ -0,0 +1,69 @@ +filterHandler->getFilterSettings(); + + $feedback = $this + ->feedbackRepository + ->getExportQuery($filterDto) + ->getResult() + ; + + if (0 === count($feedback)) { + $this->addFlash('error', 'Keine Daten für den Export vorhanden'); + + return $this->redirectToRoute('app_administrative_feedback_index'); + } + + $spreadsheet = $this->feedbackExporter->export($feedback); + + $filename = sprintf('feedback-export_%s.xlsx', date('YmdHi')); + + $response = new StreamedResponse(static function () use ($spreadsheet): void { + (new Xlsx($spreadsheet))->save('php://output'); + }); + + $response->headers->set( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + $response->headers->set( + 'Content-Disposition', + HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename)) + ); + + $this->logger->info('Export feedback', [ + 'count' => count($feedback), + 'filter_active' => $filterDto->isActive(), + ]); + + return $response; + } +} diff --git a/src/Repository/FeedbackRepository.php b/src/Repository/FeedbackRepository.php index 17da4bc..c4ffcf7 100644 --- a/src/Repository/FeedbackRepository.php +++ b/src/Repository/FeedbackRepository.php @@ -9,6 +9,7 @@ use App\Model\FeedbackFilterDto; use App\Repository\Traits\QueryHelperTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -38,6 +39,38 @@ class FeedbackRepository extends ServiceEntityRepository ->innerJoin('teamer.user', 'user') ; + $this->applyFilterCriteria($qb, $filterDto); + + return $qb->getQuery(); + } + + /** + * Published feedback matching the given filter, one row per record, with the + * FeedbackSet eagerly loaded and rows grouped per teamer and ordered by season. + */ + public function getExportQuery(FeedbackFilterDto $filterDto): Query + { + $qb = $this->createQueryBuilder('feedback'); + + $qb + ->select('feedback', 'teamer', 'user', 'feedbackSet') + ->innerJoin('feedback.teamer', 'teamer') + ->innerJoin('teamer.user', 'user') + ->innerJoin('feedback.feedbackSet', 'feedbackSet') + ->andWhere($qb->expr()->eq('feedback.status', ':status')) + ->setParameter('status', Feedback::STATUS_PUBLISHED) + ->orderBy('teamer.lastName', 'ASC') + ->addOrderBy('teamer.firstName', 'ASC') + ->addOrderBy('feedback.assignmentDateFrom', 'ASC') + ; + + $this->applyFilterCriteria($qb, $filterDto); + + return $qb->getQuery(); + } + + private function applyFilterCriteria(QueryBuilder $qb, FeedbackFilterDto $filterDto): void + { if (null !== $teamer = $filterDto->getTeamer()) { $qb ->andWhere($qb->expr()->eq('feedback.teamer', ':teamer')) @@ -73,8 +106,6 @@ class FeedbackRepository extends ServiceEntityRepository ->setParameter('dateTo', $dateTo) ; } - - return $qb->getQuery(); } public function getNew(int $limit = 5): array diff --git a/src/Service/Export/FeedbackExporter.php b/src/Service/Export/FeedbackExporter.php new file mode 100644 index 0000000..e626556 --- /dev/null +++ b/src/Service/Export/FeedbackExporter.php @@ -0,0 +1,262 @@ + $feedback + */ + public function export(iterable $feedback): Spreadsheet + { + // Keyed by object identity: Doctrine hands back the same FeedbackSet instance for every + // row that shares it, and this stays correct for not-yet-persisted sets (id still null). + $buckets = []; + + foreach ($feedback as $item) { + $set = $item->getFeedbackSet(); + + if (null === $set) { + continue; + } + + $key = spl_object_id($set); + $buckets[$key]['set'] = $set; + $buckets[$key]['rows'][] = $item; + } + + $spreadsheet = new Spreadsheet(); + + if ([] === $buckets) { + $spreadsheet->getActiveSheet()->setTitle('Feedback'); + + return $spreadsheet; + } + + $spreadsheet->removeSheetByIndex(0); + + $usedTitles = []; + + foreach ($buckets as $bucket) { + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle($this->uniqueTitle($bucket['set']->getName() ?? 'Feedback', $usedTitles)); + + $this->fillSheet($sheet, $bucket['rows']); + } + + return $spreadsheet; + } + + /** + * @param list $rows + */ + private function fillSheet(Worksheet $sheet, array $rows): void + { + $questions = $this->collectQuestionLabels($rows); + + $headers = [...self::FIXED_HEADERS, ...$questions, ...self::TRAILING_HEADERS]; + $sheet->fromArray($headers, null, 'A1'); + + $rowNumber = 2; + + foreach ($rows as $feedback) { + $marks = $this->marksByLabel($feedback); + + $values = [ + $feedback->getTeamer()?->getLastName(), + $feedback->getTeamer()?->getFirstName(), + $this->season($feedback), + $this->formatDate($feedback->getAssignmentDateFrom()), + $this->formatDate($feedback->getAssignmentDateTo()), + $feedback->getDestinationName(), + $feedback->getHotel(), + $feedback->getHotelCode(), + $feedback->getJobProfileName(), + $this->formatDate($feedback->getCreatedAt()), + $feedback->getAuthor(), + ]; + + foreach ($questions as $label) { + $values[] = $marks[$label] ?? null; + } + + $values[] = null !== $feedback->getAverageRating() + ? round($feedback->getAverageRating() / 100, 1) + : null; + $values[] = $this->clip($feedback->getComment()); + $values[] = $this->clip($feedback->getCommentInternal()); + + $sheet->fromArray($values, null, 'A'.$rowNumber); + ++$rowNumber; + } + + $this->style($sheet, count($headers), count($questions)); + } + + /** + * The distinct question labels across the given rows, in first-seen order. Columns are + * derived from the stored data rather than FeedbackSet::getRatings() so that reworded or + * reordered set questions never drop marks that were captured under the old wording. + * + * @param list $rows + * + * @return list + */ + private function collectQuestionLabels(array $rows): array + { + $labels = []; + + foreach ($rows as $feedback) { + foreach ($feedback->getRatings() as $entry) { + if (false === is_array($entry) || false === isset($entry['rating'])) { + continue; + } + + $label = (string) $entry['rating']; + + if (false === in_array($label, $labels, true)) { + $labels[] = $label; + } + } + } + + return $labels; + } + + /** + * @return array + */ + private function marksByLabel(Feedback $feedback): array + { + $marks = []; + + foreach ($feedback->getRatings() as $entry) { + if (false === is_array($entry) || false === isset($entry['rating'], $entry['mark'])) { + continue; + } + + $marks[(string) $entry['rating']] = $entry['mark']; + } + + return $marks; + } + + private function season(Feedback $feedback): string + { + $from = $feedback->getAssignmentDateFrom(); + + if (null === $from) { + return '—'; + } + + return $this->seasonCalendar->label($this->seasonCalendar->seasonYearFor($from)); + } + + private function formatDate(?\DateTimeInterface $date): ?string + { + return $date?->format('d.m.Y'); + } + + private function clip(?string $value): ?string + { + if (null === $value) { + return null; + } + + return mb_substr($value, 0, self::CELL_CHARACTER_LIMIT); + } + + /** + * Sanitises a feedback-set name into a worksheet title: strips the characters Excel forbids, + * caps it at 31 characters and appends a numeric suffix on collision. + * + * @param list $usedTitles lower-cased titles already handed out; appended to in place + */ + private function uniqueTitle(string $name, array &$usedTitles): string + { + $clean = preg_replace('#[\\\\/*?:\[\]]#', ' ', $name) ?? ''; + $clean = trim(preg_replace('/\s+/', ' ', $clean) ?? ''); + + if ('' === $clean) { + $clean = 'Feedback'; + } + + $clean = mb_substr($clean, 0, 31); + $title = $clean; + $suffix = 2; + + while (in_array(mb_strtolower($title), $usedTitles, true)) { + $tail = ' ('.$suffix.')'; + $title = mb_substr($clean, 0, 31 - mb_strlen($tail)).$tail; + ++$suffix; + } + + $usedTitles[] = mb_strtolower($title); + + return $title; + } + + private function style(Worksheet $sheet, int $columnCount, int $questionCount): void + { + $lastColumn = Coordinate::stringFromColumnIndex($columnCount); + + $sheet->getStyle('A1:'.$lastColumn.'1')->getFont()->setBold(true); + $sheet->freezePane('A2'); + + $fixedCount = count(self::FIXED_HEADERS); + $firstQuestion = $fixedCount + 1; + $lastQuestion = $fixedCount + $questionCount; + $commentStart = $lastQuestion + 2; // the column after "Ø Bewertung" + + for ($i = 1; $i <= $columnCount; ++$i) { + $dimension = $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i)); + + if ($i >= $firstQuestion && $i <= $lastQuestion) { + $dimension->setWidth(14); + } elseif ($i >= $commentStart) { + $dimension->setWidth(60); + } else { + $dimension->setAutoSize(true); + } + } + } +} diff --git a/templates/administrative/feedback/index.html.twig b/templates/administrative/feedback/index.html.twig index 8d9e8e8..9ea8fe5 100644 --- a/templates/administrative/feedback/index.html.twig +++ b/templates/administrative/feedback/index.html.twig @@ -26,6 +26,10 @@ Reset {% endif %} + + {{ icon('excel', 'w-4 h-4 shrink-0') }} + Export + diff --git a/tests/Service/Export/FeedbackExporterTest.php b/tests/Service/Export/FeedbackExporterTest.php new file mode 100644 index 0000000..add941c --- /dev/null +++ b/tests/Service/Export/FeedbackExporterTest.php @@ -0,0 +1,122 @@ +getParameter('kernel.project_dir'); + /** @var SeasonCalendar $seasonCalendar */ + $seasonCalendar = static::getContainer()->get(SeasonCalendar::class); + + $exporter = new FeedbackExporter($seasonCalendar); + + $author = (new User())->setFirstName('Carmen')->setLastName('Bär'); + + $teamerMueller = (new Teamer())->setFirstName('Mara')->setLastName('Müller'); + $teamerSchmidt = (new Teamer())->setFirstName('Timo')->setLastName('Schmidt'); + + $guideSet = (new FeedbackSet())->setName('Reiseleiter-Feedback'); + $careSet = (new FeedbackSet())->setName('Betreuer-Feedback'); + + // Müller, season 2024/25, guide set - full question list. + $first = $this->makeFeedback($author, $teamerMueller, $guideSet, new \DateTimeImmutable('2024-12-01'), [ + ['rating' => 'Pünktlichkeit', 'mark' => 5], + ['rating' => 'Freundlichkeit', 'mark' => 4], + ]); + $first->setComment('Alles bestens'); + + // Müller again, same season, guide set - extra question the first row lacks. + $second = $this->makeFeedback($author, $teamerMueller, $guideSet, new \DateTimeImmutable('2025-02-20'), [ + ['rating' => 'Pünktlichkeit', 'mark' => 3], + ['rating' => 'Freundlichkeit', 'mark' => 4], + ['rating' => 'Sprachkenntnisse', 'mark' => 2], + ]); + + // Schmidt, care set, no assignment date -> season falls back to a dash. + $third = $this->makeFeedback($author, $teamerSchmidt, $careSet, null, [ + ['rating' => 'Organisation', 'mark' => 4], + ]); + + $spreadsheet = $exporter->export([$first, $second, $third]); + + self::assertSame(2, $spreadsheet->getSheetCount()); + self::assertSame('Reiseleiter-Feedback', $spreadsheet->getSheet(0)->getTitle()); + self::assertSame('Betreuer-Feedback', $spreadsheet->getSheet(1)->getTitle()); + + $guideSheet = $spreadsheet->getSheet(0); + $header = $guideSheet->rangeToArray('A1:Q1')[0]; + + self::assertSame('Name', $header[0]); + self::assertSame('Saison', $header[2]); + // Fixed block is 11 columns (A-K); questions follow in first-seen order. + self::assertSame('Pünktlichkeit', $header[11]); + self::assertSame('Freundlichkeit', $header[12]); + self::assertSame('Sprachkenntnisse', $header[13]); + self::assertSame('Ø Bewertung', $header[14]); + self::assertSame('Kommentar (öffentlich)', $header[15]); + self::assertSame('Kommentar (intern)', $header[16]); + + // Row 2 = first Müller feedback: mark present, missing question is blank. + self::assertSame('Müller', $guideSheet->getCell('A2')->getValue()); + self::assertSame('Saison 2024/25', $guideSheet->getCell('C2')->getValue()); + self::assertSame(5, $guideSheet->getCell('L2')->getValue()); + self::assertSame(4, $guideSheet->getCell('M2')->getValue()); + self::assertNull($guideSheet->getCell('N2')->getValue()); + self::assertSame(4.5, $guideSheet->getCell('O2')->getValue()); + self::assertSame('Alles bestens', $guideSheet->getCell('P2')->getValue()); + + // Row 3 = second Müller feedback: the third question now carries a mark. + self::assertSame(2, $guideSheet->getCell('N3')->getValue()); + + $careSheet = $spreadsheet->getSheet(1); + self::assertSame('—', $careSheet->getCell('C2')->getValue()); + self::assertSame('Organisation', $careSheet->rangeToArray('A1:O1')[0][11]); + + (new Xlsx($spreadsheet))->save($projectDir.'/temp/feedback-export.xlsx'); + } + + /** + * @param list $ratings + */ + private function makeFeedback( + User $author, + Teamer $teamer, + FeedbackSet $set, + ?\DateTimeImmutable $dateFrom, + array $ratings, + ): Feedback { + $feedback = (new Feedback($author)) + ->setStatus(Feedback::STATUS_PUBLISHED) + ->setTeamer($teamer) + ->setFeedbackSet($set) + ->setDestinationName('Skireise') + ->setHotel('Unterkunft') + ->setHotelCode('ABC123') + ->setJobProfileName('Allrounder') + ->setRatings($ratings) + ; + + $feedback->setCreatedAt(new \DateTimeImmutable('2025-03-01')); + $feedback->setAverageRating(); + + if (null !== $dateFrom) { + $feedback + ->setAssignmentDateFrom($dateFrom) + ->setAssignmentDateTo($dateFrom->modify('+2 months')) + ; + } + + return $feedback; + } +}