feat: feedback export to excel
addresses #869dv9k9z
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user