feat: feedback export to excel

addresses #869dv9k9z
This commit is contained in:
fromme committed 2026-09-01 10:20:31 +02:00
1 parent 2f747fc22f
commit 496a707a16
7 files changed
+835 -79

No files matched your search

@@ -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);
}
}
}
}