- 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>
362 lines
13 KiB
PHP
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';
|
|
}
|
|
} |