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,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