feat: feedback export to excel

addresses #869dv9k9z
This commit is contained in:
2026-09-01 10:20:31 +02:00
parent 2f747fc22f
commit 496a707a16
7 changed files with 835 additions and 79 deletions
+1
View File
@@ -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",
Generated
+344 -77
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": "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": "[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.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": "[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": "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": "[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.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",
@@ -0,0 +1,69 @@
<?php
namespace App\Controller\Administrative\Feedback;
use App\Repository\FeedbackRepository;
use App\Service\Common\FeedbackFilterHandler;
use App\Service\Export\FeedbackExporter;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ExportController extends AbstractController
{
public function __construct(
private readonly FeedbackFilterHandler $filterHandler,
private readonly FeedbackRepository $feedbackRepository,
private readonly FeedbackExporter $feedbackExporter,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/feedback/export', name: 'app_administrative_feedback_export')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
$filterDto = $this->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;
}
}
+33 -2
View File
@@ -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
+262
View File
@@ -0,0 +1,262 @@
<?php
namespace App\Service\Export;
use App\Config\SeasonCalendar;
use App\Entity\Feedback;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* Builds an .xlsx workbook of feedback records, one worksheet per FeedbackSet, with the
* per-question marks stored in Feedback::$ratings expanded into one column per question.
*/
final class FeedbackExporter
{
private const FIXED_HEADERS = [
'Name',
'Vorname',
'Saison',
'Einsatz von',
'Einsatz bis',
'Reiseziel',
'Haus',
'Haus-Code',
'Job-Profil',
'Erfasst am',
'Erfasst von',
];
private const TRAILING_HEADERS = [
'Ø Bewertung',
'Kommentar (öffentlich)',
'Kommentar (intern)',
];
/**
* Excel's hard limit on the number of characters a single cell can hold.
*/
private const CELL_CHARACTER_LIMIT = 32767;
public function __construct(private readonly SeasonCalendar $seasonCalendar)
{
}
/**
* @param iterable<Feedback> $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<Feedback> $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<Feedback> $rows
*
* @return list<string>
*/
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<string, int|string>
*/
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<string> $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);
}
}
}
}
@@ -26,6 +26,10 @@
Reset
</a>
{% endif %}
<a href="{{ path('app_administrative_feedback_export') }}" class="btn btn--small" title="Nach Excel exportieren">
{{ icon('excel', 'w-4 h-4 shrink-0') }}
<span>Export</span>
</a>
</div>
</div>
@@ -0,0 +1,122 @@
<?php
namespace App\Tests\Service\Export;
use App\Config\SeasonCalendar;
use App\Entity\Feedback;
use App\Entity\FeedbackSet;
use App\Entity\Teamer;
use App\Entity\User;
use App\Service\Export\FeedbackExporter;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class FeedbackExporterTest extends WebTestCase
{
public function testExportsOneSheetPerFeedbackSetWithExpandedQuestions(): void
{
$projectDir = static::getContainer()->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<array{rating: string, mark: int}> $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;
}
}