Files
survey-bundle/src/Service/SurveyResultsExportService.php
T
Claude Fable 5 833a8964cf Add topic sections, ordered inserts, rich-text descriptions and grouped results (Ausbaustufe 3)
- section content type with structure service, reader context and grouped results/PDF/Excel
- new questions append at the end (backend oncreate) and per-section add buttons
- rich-text descriptions: Quill 2 (frontend), reduced TinyMCE (backend), server-side whitelist sanitizer
- externalize inline assets, bundle Chart.js locally, harden debug access, validate range bounds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 07:57:40 +02:00

362 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\StringUtil;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
final class SurveyResultsExportService
{
public function __construct(
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveyStructureService $surveyStructureService,
) {
}
/**
* @param list<array<string, mixed>> $finishedSubmissions
*/
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
{
$content = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$questions = array_values(array_filter($content, static fn (SurveyContentModel $item): bool => !SurveyQuestionRepository::isSection($item)));
$sectionMap = $this->surveyStructureService->buildSectionMap($content);
$hasSections = count($content) > count($questions);
[$questionSpans, $headers, $columns] = $this->buildExportColumns($questions);
// Eigene Zeile mit dem Fragetext oberhalb der Antwortspalten; die Zellen
// werden je Frage über alle zugehörigen Spalten zusammengeführt.
$questionRow = array_fill(0, max(1, count($headers)), '');
foreach ($questionSpans as $span) {
$questionRow[$span['start'] - 1] = $span['label'];
}
$rows = [[(string) $survey->title]];
$sectionSpans = [];
// Bei Themenbereichen zusätzlich eine Bereichszeile über den Fragetexten.
if ($hasSections) {
$sectionSpans = $this->buildSectionSpans($questionSpans, $sectionMap);
$sectionRow = array_fill(0, max(1, count($headers)), '');
foreach ($sectionSpans as $span) {
$sectionRow[$span['start'] - 1] = $span['label'];
}
$rows[] = $sectionRow;
}
$rows[] = $questionRow;
$rows[] = $headers;
foreach ($finishedSubmissions as $submission) {
$answers = $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission['id']);
$answersByQuestion = [];
foreach ($answers as $answer) {
$answersByQuestion[(int) ($answer['questionId'] ?? 0)] = (string) ($answer['value'] ?? '');
}
$row = [];
foreach ($columns as $column) {
$questionId = (int) $column['questionId'];
$value = $answersByQuestion[$questionId] ?? '';
switch ((string) $column['kind']) {
case 'option':
$selectedValues = array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry);
$row[] = in_array((string) $column['value'], $selectedValues, true) ? 1 : '';
break;
case 'range':
case 'text':
default:
$row[] = $value;
break;
}
}
$rows[] = $row;
}
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans, $sectionSpans));
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->buildExportFilename((string) $survey->title)
);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Cache-Control', 'private, no-store, no-cache, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* @param list<SurveyContentModel> $questions
*
* @return array{0:list<array{label:string,start:int,end:int,questionId:int}>,1:list<string>,2:list<array{questionId:int,kind:string,value:string}>}
*/
private function buildExportColumns(array $questions): array
{
$questionSpans = [];
$headers = [];
$columns = [];
foreach (array_values($questions) as $index => $question) {
$questionNumber = $index + 1;
$prefix = 'F'.$questionNumber;
$firstColumnIndex = count($headers) + 1;
switch ((string) $question->type) {
case 'yes_no_maybe':
foreach ($this->getYesNoMaybeLabels($question) as $label => $value) {
$headers[] = $prefix.' '.$label;
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'option',
'value' => $value,
];
}
break;
case 'choice':
foreach ($this->getChoiceLabels($question) as $label) {
$headers[] = $prefix.' '.$label;
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'option',
'value' => $label,
];
}
break;
case 'range':
$headers[] = $prefix.' Wert';
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'range',
'value' => '',
];
break;
case 'text':
default:
$headers[] = $prefix.' Text';
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'text',
'value' => '',
];
break;
}
$questionLabel = trim((string) $question->question);
$questionSpans[] = [
'label' => sprintf('%s: %s', $prefix, '' !== $questionLabel ? $questionLabel : 'Frage #'.(int) $question->id),
'start' => $firstColumnIndex,
'end' => count($headers),
'questionId' => (int) $question->id,
];
}
return [$questionSpans, $headers, $columns];
}
/**
* @return array<string, string>
*/
private function getYesNoMaybeLabels(SurveyContentModel $question): array
{
$labels = [
'ja' => 'yes',
'nein' => 'no',
];
if ('1' === (string) $question->allowMaybe) {
$labels['vielleicht'] = 'maybe';
}
return $labels;
}
/**
* @return list<string>
*/
private function getChoiceLabels(SurveyContentModel $question): array
{
$labels = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$labels[] = $option;
}
}
return $labels;
}
/**
* Bereichsspannen über den Fragespalten (zusammenhängende Fragen desselben Bereichs).
*
* @param list<array{label:string,start:int,end:int,questionId:int}> $questionSpans
* @param array<int, array{id:int,title:string|null}> $sectionMap
*
* @return list<array{label:string,start:int,end:int}>
*/
private function buildSectionSpans(array $questionSpans, array $sectionMap): array
{
$sectionSpans = [];
$currentSectionId = null;
foreach ($questionSpans as $span) {
$section = $sectionMap[$span['questionId']] ?? ['id' => 0, 'title' => null];
$sectionId = (int) $section['id'];
if ($sectionId !== $currentSectionId) {
$sectionSpans[] = [
'label' => $sectionId > 0 ? (string) $section['title'] : 'Allgemeine Fragen',
'start' => $span['start'],
'end' => $span['end'],
];
$currentSectionId = $sectionId;
continue;
}
$sectionSpans[count($sectionSpans) - 1]['end'] = $span['end'];
}
return $sectionSpans;
}
/**
* @param list<list<int|string|float>> $rows
* @param list<array{label:string,start:int,end:int}> $questionSpans
* @param list<array{label:string,start:int,end:int}> $sectionSpans
*/
private function buildSpreadsheetContent(array $rows, array $questionSpans, array $sectionSpans = []): string
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setTitle('Ergebnisse');
// Zeilenlayout: 1 Titel, [2 Themenbereiche], n Fragetexte, n+1 Spaltenköpfe, danach Daten.
$hasSectionRow = [] !== $sectionSpans;
$sectionRowIndex = $hasSectionRow ? 2 : 0;
$questionRowIndex = $hasSectionRow ? 3 : 2;
$headerRowIndex = $questionRowIndex + 1;
foreach ($rows as $rowIndex => $row) {
foreach ($row as $columnIndex => $cellValue) {
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).($rowIndex + 1);
if (is_int($cellValue) || is_float($cellValue)) {
$worksheet->setCellValue($coordinate, $cellValue);
continue;
}
$worksheet->setCellValueExplicit($coordinate, (string) $cellValue, DataType::TYPE_STRING);
}
}
// Fragetext-Zellen je Frage über die zugehörigen Antwortspalten zusammenführen.
foreach ($questionSpans as $span) {
if ($span['end'] > $span['start']) {
$worksheet->mergeCells(sprintf(
'%s%d:%s%d',
Coordinate::stringFromColumnIndex($span['start']),
$questionRowIndex,
Coordinate::stringFromColumnIndex($span['end']),
$questionRowIndex,
));
}
}
// Themenbereich-Zellen über alle Fragen des Bereichs zusammenführen.
foreach ($sectionSpans as $span) {
if ($span['end'] > $span['start']) {
$worksheet->mergeCells(sprintf(
'%s%d:%s%d',
Coordinate::stringFromColumnIndex($span['start']),
$sectionRowIndex,
Coordinate::stringFromColumnIndex($span['end']),
$sectionRowIndex,
));
}
}
$columnCount = max(1, count($rows[$headerRowIndex - 1] ?? $rows[0] ?? []));
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
if ($hasSectionRow) {
$sectionRange = 'A'.$sectionRowIndex.':'.$lastColumn.$sectionRowIndex;
$worksheet->getStyle($sectionRange)->getFont()->setBold(true)->getColor()->setRGB('0F4F88');
$worksheet->getStyle($sectionRange)->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('EAF2FA');
$worksheet->getStyle($sectionRange)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER);
}
$questionRange = 'A'.$questionRowIndex.':'.$lastColumn.$questionRowIndex;
$worksheet->getStyle($questionRange)->getFont()->setBold(true);
$worksheet->getStyle($questionRange)->getAlignment()->setWrapText(true)->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
$worksheet->getRowDimension($questionRowIndex)->setRowHeight(42);
$worksheet->getStyle('A'.$headerRowIndex.':'.$lastColumn.$headerRowIndex)->getFont()->setBold(true);
$worksheet->freezePane('A'.($headerRowIndex + 1));
for ($column = 1; $column <= $columnCount; ++$column) {
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
try {
$writer->save('php://output');
$content = ob_get_clean();
} catch (\Throwable $throwable) {
ob_end_clean();
$spreadsheet->disconnectWorksheets();
throw $throwable;
}
$spreadsheet->disconnectWorksheets();
return false === $content ? '' : $content;
}
private function buildExportFilename(string $title): string
{
$baseName = StringUtil::stripInsertTags($title);
$baseName = preg_replace('/[^A-Za-z0-9]+/', '-', $baseName ?? '') ?? 'umfrage-ergebnisse';
$baseName = trim($baseName, '-');
if ('' === $baseName) {
$baseName = 'umfrage-ergebnisse';
}
return $baseName.'.xlsx';
}
}