Initial survey bundle import
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
|
||||
final class SurveyCategoryAssignmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly SurveyRepository $surveyRepository,
|
||||
private readonly SurveyEditorRepository $surveyEditorRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getMemberCategoryIds(int $memberId): array
|
||||
{
|
||||
$value = $this->connection->fetchOne('SELECT surveyCategories FROM tl_member WHERE id = ?', [$memberId]);
|
||||
|
||||
return $this->normalizeCategoryIds(false === $value ? [] : $value);
|
||||
}
|
||||
|
||||
public function applyMemberCategoriesToSurvey(int $memberId, int $surveyId): void
|
||||
{
|
||||
if ($memberId <= 0 || $surveyId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->syncSurvey($surveyId, [
|
||||
$memberId => $this->getMemberCategoryIds($memberId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int>|null $categoryIds
|
||||
*/
|
||||
public function syncAssignedSurveys(int $memberId, ?array $categoryIds = null): void
|
||||
{
|
||||
if ($memberId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedCategoryIds = $this->normalizeCategoryIds($categoryIds ?? $this->getMemberCategoryIds($memberId));
|
||||
|
||||
foreach ($this->surveyEditorRepository->findSurveyIdsByMember($memberId) as $surveyId) {
|
||||
$this->syncSurvey($surveyId, [
|
||||
$memberId => $normalizedCategoryIds,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncSurvey(int $surveyId, array $memberCategoryOverrides = []): void
|
||||
{
|
||||
if ($surveyId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = [];
|
||||
|
||||
foreach ($this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) as $memberId) {
|
||||
$memberCategoryIds = $memberCategoryOverrides[$memberId] ?? $this->getMemberCategoryIds($memberId);
|
||||
|
||||
foreach ($this->normalizeCategoryIds($memberCategoryIds) as $categoryId) {
|
||||
$categoryIds[] = $categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
$this->surveyRepository->updateCategories($surveyId, $this->normalizeCategoryIds($categoryIds));
|
||||
}
|
||||
|
||||
public function syncSurveyWithoutMember(int $surveyId, int $memberId): void
|
||||
{
|
||||
if ($surveyId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = [];
|
||||
|
||||
foreach ($this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) as $assignedMemberId) {
|
||||
if ($assignedMemberId === $memberId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->getMemberCategoryIds($assignedMemberId) as $categoryId) {
|
||||
$categoryIds[] = $categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
$this->surveyRepository->updateCategories($surveyId, $this->normalizeCategoryIds($categoryIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function normalizeCategoryIds(mixed $value): array
|
||||
{
|
||||
$categoryIds = is_array($value) ? $value : StringUtil::deserialize($value, true);
|
||||
$categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds), static fn (int $id): bool => $id > 0)));
|
||||
sort($categoryIds);
|
||||
|
||||
return $categoryIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
|
||||
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
|
||||
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
|
||||
final class SurveyEditorService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SurveyRepository $surveyRepository,
|
||||
private readonly SurveyQuestionRepository $surveyQuestionRepository,
|
||||
private readonly SurveyConditionRepository $surveyConditionRepository,
|
||||
private readonly SurveyEditorRepository $surveyEditorRepository,
|
||||
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createSurvey(int $memberId, SurveyEditorData $data): SurveyModel
|
||||
{
|
||||
$payload = $data->toArray();
|
||||
$payload['category'] = $this->surveyCategoryAssignmentService->getMemberCategoryIds($memberId);
|
||||
|
||||
$survey = $this->surveyRepository->create($memberId, $payload);
|
||||
$this->surveyEditorRepository->assignEditor((int) $survey->id, $memberId);
|
||||
$this->surveyRepository->refreshListMetadata((int) $survey->id);
|
||||
|
||||
return $survey;
|
||||
}
|
||||
|
||||
public function updateSurvey(SurveyModel $survey, int $memberId, SurveyEditorData $data): void
|
||||
{
|
||||
$this->surveyRepository->update($survey, $memberId, $data->toArray());
|
||||
}
|
||||
|
||||
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): void
|
||||
{
|
||||
$this->assertUnlocked($survey);
|
||||
|
||||
if ($questionId > 0) {
|
||||
$question = $this->surveyQuestionRepository->findByIdForSurvey((int) $survey->id, $questionId);
|
||||
|
||||
if (null === $question) {
|
||||
throw new \RuntimeException('Die Frage wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
$this->surveyQuestionRepository->update($question, $data->toArray());
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->surveyQuestionRepository->create((int) $survey->id, $data->toArray());
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
}
|
||||
|
||||
public function deleteQuestion(SurveyModel $survey, int $questionId): void
|
||||
{
|
||||
$this->assertUnlocked($survey);
|
||||
|
||||
$this->surveyQuestionRepository->delete((int) $survey->id, $questionId);
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
}
|
||||
|
||||
public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void
|
||||
{
|
||||
$this->assertUnlocked($survey);
|
||||
|
||||
if ($conditionId > 0) {
|
||||
$condition = $this->surveyConditionRepository->findById($conditionId);
|
||||
|
||||
if (null === $condition || (int) $condition->pid !== (int) $survey->id) {
|
||||
throw new \RuntimeException('Die Bedingung wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
$this->surveyConditionRepository->update($condition, $data->toArray());
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->surveyConditionRepository->create((int) $survey->id, $data->toArray());
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
}
|
||||
|
||||
public function deleteCondition(SurveyModel $survey, int $conditionId): void
|
||||
{
|
||||
$this->assertUnlocked($survey);
|
||||
$this->surveyConditionRepository->delete((int) $survey->id, $conditionId);
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
}
|
||||
|
||||
private function assertUnlocked(SurveyModel $survey): void
|
||||
{
|
||||
if ('1' === (string) $survey->isLocked) {
|
||||
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
|
||||
|
||||
final class SurveyEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SurveyConditionRepository $surveyConditionRepository,
|
||||
private readonly SurveyQuestionRepository $surveyQuestionRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveConditionalTarget(SurveyModel $survey, SurveyContentModel $sourceQuestion, string $normalizedAnswer): ?SurveyContentModel
|
||||
{
|
||||
$condition = $this->surveyConditionRepository->findMatchingCondition(
|
||||
(int) $survey->id,
|
||||
(int) $sourceQuestion->id,
|
||||
mb_strtolower(trim($normalizedAnswer)),
|
||||
);
|
||||
|
||||
if (null === $condition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->surveyQuestionRepository->findByIdForSurvey(
|
||||
(int) $survey->id,
|
||||
(int) $condition->targetQuestion,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
|
||||
|
||||
final class SurveyFlowService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SurveyEngine $surveyEngine,
|
||||
private readonly SurveyRepository $surveyRepository,
|
||||
private readonly SurveyQuestionRepository $surveyQuestionRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveCurrentQuestion(SurveyModel $survey, SurveySubmissionModel $submission): ?SurveyContentModel
|
||||
{
|
||||
if ('1' === (string) $submission->isFinished) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$questions = $this->getOrderedQuestions($survey);
|
||||
$currentQuestionId = (int) $submission->currentQuestion;
|
||||
|
||||
if ($currentQuestionId > 0) {
|
||||
foreach ($questions as $question) {
|
||||
if ((int) $question->id === $currentQuestionId) {
|
||||
return $question;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $questions[0] ?? null;
|
||||
}
|
||||
|
||||
public function determineNextQuestion(SurveyModel $survey, SurveyContentModel $currentQuestion, string $normalizedAnswer): ?SurveyContentModel
|
||||
{
|
||||
$questions = $this->getOrderedQuestions($survey);
|
||||
$configuredJumpTarget = $this->resolveYesNoJumpTarget($currentQuestion, $questions, $normalizedAnswer);
|
||||
|
||||
if ($configuredJumpTarget instanceof SurveyContentModel) {
|
||||
return $configuredJumpTarget;
|
||||
}
|
||||
|
||||
$conditionalTarget = $this->surveyEngine->resolveConditionalTarget($survey, $currentQuestion, $normalizedAnswer);
|
||||
|
||||
if ($conditionalTarget instanceof SurveyContentModel) {
|
||||
return $conditionalTarget;
|
||||
}
|
||||
|
||||
foreach ($questions as $index => $question) {
|
||||
if ((int) $question->id === (int) $currentQuestion->id) {
|
||||
return $questions[$index + 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{current:int,total:int,percentage:int}
|
||||
*/
|
||||
public function getProgress(SurveyModel $survey, ?SurveyContentModel $currentQuestion): array
|
||||
{
|
||||
$questions = $this->getOrderedQuestions($survey);
|
||||
$total = max(1, count($questions));
|
||||
$current = 1;
|
||||
|
||||
if ($currentQuestion instanceof SurveyContentModel) {
|
||||
foreach ($questions as $index => $question) {
|
||||
if ((int) $question->id === (int) $currentQuestion->id) {
|
||||
$current = $index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$current = $total;
|
||||
}
|
||||
|
||||
return [
|
||||
'current' => $current,
|
||||
'total' => $total,
|
||||
'percentage' => (int) round(($current / $total) * 100),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SurveyContentModel>
|
||||
*/
|
||||
private function getOrderedQuestions(SurveyModel $survey): array
|
||||
{
|
||||
$surveyId = (int) $survey->id;
|
||||
|
||||
if ($this->surveyQuestionRepository->countPublishedBySurvey($surveyId) > 0) {
|
||||
return $this->surveyQuestionRepository->findAllBySurvey($surveyId, true);
|
||||
}
|
||||
|
||||
return $this->surveyQuestionRepository->findAllBySurvey($surveyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<SurveyContentModel> $questions
|
||||
*/
|
||||
private function resolveYesNoJumpTarget(SurveyContentModel $currentQuestion, array $questions, string $normalizedAnswer): ?SurveyContentModel
|
||||
{
|
||||
if ('yes_no_maybe' !== (string) $currentQuestion->type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetId = match ($normalizedAnswer) {
|
||||
'yes' => (int) ($currentQuestion->jumpOnYes ?? 0),
|
||||
'no' => (int) ($currentQuestion->jumpOnNo ?? 0),
|
||||
default => 0,
|
||||
};
|
||||
|
||||
if ($targetId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($questions as $question) {
|
||||
if ((int) $question->id === $targetId) {
|
||||
return $question;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final class SurveyPermissionService
|
||||
{
|
||||
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
|
||||
{
|
||||
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
|
||||
}
|
||||
|
||||
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
|
||||
{
|
||||
if (!$this->canEditSurvey($survey, $member)) {
|
||||
throw new AccessDeniedHttpException('Sie duerfen diese Umfrage nicht bearbeiten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?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,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $finishedSubmissions
|
||||
*/
|
||||
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
|
||||
{
|
||||
[$headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
||||
$rows = [
|
||||
[(string) $survey->title],
|
||||
$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));
|
||||
$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<string>,1:list<array{questionId:int,kind:string,value:string}>}
|
||||
*/
|
||||
private function buildExportColumns(array $questions): array
|
||||
{
|
||||
$headers = [];
|
||||
$columns = [];
|
||||
|
||||
foreach (array_values($questions) as $index => $question) {
|
||||
$questionNumber = $index + 1;
|
||||
$prefix = 'F'.$questionNumber;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return [$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<list<int|string|float>> $rows
|
||||
*/
|
||||
private function buildSpreadsheetContent(array $rows): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$worksheet->setTitle('Ergebnisse');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($rows[1] ?? $rows[0] ?? [])));
|
||||
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
|
||||
$worksheet->freezePane('A3');
|
||||
|
||||
for ($column = 1; $column <= max(1, count($rows[1] ?? $rows[0] ?? [])); ++$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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Contao\Config;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Symfony\Component\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadGatewayHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
final class SurveyResultsPdfService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return null !== $this->getGotenbergBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $questionResults
|
||||
*/
|
||||
public function createPdfExportResponse(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): Response
|
||||
{
|
||||
$gotenbergBaseUrl = $this->getGotenbergBaseUrl();
|
||||
|
||||
if (null === $gotenbergBaseUrl) {
|
||||
throw new BadRequestHttpException('Es wurde keine Gotenberg-URL in den Einstellungen hinterlegt.');
|
||||
}
|
||||
|
||||
$html = $this->renderTemplate(
|
||||
$survey,
|
||||
$completedSubmissionCount,
|
||||
$this->prepareQuestionResults($questionResults),
|
||||
);
|
||||
$filename = $this->buildFilename((string) $survey->title);
|
||||
$pdfContent = $this->requestPdfFromGotenberg($gotenbergBaseUrl, $html, $filename);
|
||||
|
||||
return new Response(
|
||||
$pdfContent,
|
||||
Response::HTTP_OK,
|
||||
[
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function getGotenbergBaseUrl(): ?string
|
||||
{
|
||||
$url = trim((string) Config::get('surveyGotenbergUrl'));
|
||||
|
||||
return '' !== $url ? rtrim($url, '/') : null;
|
||||
}
|
||||
|
||||
private function requestPdfFromGotenberg(string $gotenbergBaseUrl, string $html, string $filename): string
|
||||
{
|
||||
$formData = new FormDataPart([
|
||||
'files' => new DataPart($html, 'index.html', 'text/html'),
|
||||
'paperWidth' => '8.27',
|
||||
'paperHeight' => '11.7',
|
||||
'marginTop' => '0.5',
|
||||
'marginBottom' => '0.5',
|
||||
'marginLeft' => '0.5',
|
||||
'marginRight' => '0.5',
|
||||
'emulatedMediaType' => 'screen',
|
||||
'printBackground' => 'true',
|
||||
'failOnConsoleExceptions' => 'true',
|
||||
'failOnResourceLoadingFailed' => 'true',
|
||||
]);
|
||||
|
||||
$headers = $formData->getPreparedHeaders()->toArray();
|
||||
$headers['Gotenberg-Output-Filename'] = pathinfo($filename, PATHINFO_FILENAME);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $gotenbergBaseUrl.'/forms/chromium/convert/html', [
|
||||
'headers' => $headers,
|
||||
'body' => $formData->bodyToIterable(),
|
||||
'timeout' => 60,
|
||||
]);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw new BadGatewayHttpException('Die Gotenberg-Instanz konnte nicht erreicht werden.', $exception);
|
||||
}
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$content = $response->getContent(false);
|
||||
|
||||
if (Response::HTTP_OK !== $statusCode) {
|
||||
$message = trim(strip_tags(substr($content, 0, 500)));
|
||||
|
||||
throw new BadGatewayHttpException(sprintf(
|
||||
'Die Gotenberg-Instanz hat den PDF-Export mit HTTP %d abgelehnt%s',
|
||||
$statusCode,
|
||||
'' !== $message ? ': '.$message : '.'
|
||||
));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $questionResults
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function prepareQuestionResults(array $questionResults): array
|
||||
{
|
||||
$preparedResults = [];
|
||||
|
||||
foreach ($questionResults as $questionResult) {
|
||||
$questionResult['pdfChartType'] = (string) (($questionResult['chart']['type'] ?? '') ?: '');
|
||||
$questionResult['pdfChartDataUri'] = $this->buildChartDataUri($questionResult);
|
||||
$questionResult['pdfChartLegend'] = $this->buildChartLegend($questionResult);
|
||||
$preparedResults[] = $questionResult;
|
||||
}
|
||||
|
||||
return $preparedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $questionResult
|
||||
*
|
||||
* @return list<array{label:string,fillColor:string,borderColor:string,count:int,percentage:int}>
|
||||
*/
|
||||
private function buildChartLegend(array $questionResult): array
|
||||
{
|
||||
$chart = $questionResult['chart'] ?? null;
|
||||
|
||||
if (!is_array($chart)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chartType = (string) ($chart['type'] ?? '');
|
||||
$sourceRows = match ($chartType) {
|
||||
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
|
||||
'line' => $questionResult['distribution'] ?? [],
|
||||
default => [],
|
||||
};
|
||||
|
||||
if (!is_array($sourceRows) || [] === $sourceRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dataset = $chart['data']['datasets'][0] ?? [];
|
||||
$fillColors = match ($chartType) {
|
||||
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
|
||||
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
||||
default => [],
|
||||
};
|
||||
$borderColors = match ($chartType) {
|
||||
'pie' => is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [],
|
||||
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$legend = [];
|
||||
|
||||
foreach (array_values($sourceRows) as $index => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legend[] = [
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
'fillColor' => (string) ($fillColors[$index] ?? '#D3E4F1'),
|
||||
'borderColor' => (string) ($borderColors[$index] ?? '#0064AD'),
|
||||
'count' => (int) ($row['count'] ?? 0),
|
||||
'percentage' => (int) ($row['percentage'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $legend;
|
||||
}
|
||||
|
||||
private function renderTemplate(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): string
|
||||
{
|
||||
$templatePath = __DIR__.'/../Resources/views/pdf/survey_results.html.twig';
|
||||
$templateContent = file_get_contents($templatePath);
|
||||
|
||||
if (false === $templateContent) {
|
||||
throw new \RuntimeException('Die PDF-Template-Datei konnte nicht gelesen werden: '.$templatePath);
|
||||
}
|
||||
|
||||
return $this->twig->createTemplate($templateContent)->render([
|
||||
'survey' => $survey,
|
||||
'completedSubmissionCount' => $completedSubmissionCount,
|
||||
'questionResults' => $questionResults,
|
||||
'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $questionResult
|
||||
*/
|
||||
private function buildChartDataUri(array $questionResult): ?string
|
||||
{
|
||||
$chart = $questionResult['chart'] ?? null;
|
||||
|
||||
if (!is_array($chart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$svg = match ((string) ($chart['type'] ?? '')) {
|
||||
'pie' => $this->buildPieChartSvg($questionResult, $chart),
|
||||
'line' => $this->buildLineChartSvg($questionResult, $chart),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $svg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'data:image/svg+xml;base64,'.base64_encode($svg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $questionResult
|
||||
* @param array<string, mixed> $chart
|
||||
*/
|
||||
private function buildPieChartSvg(array $questionResult, array $chart): ?string
|
||||
{
|
||||
$options = $questionResult['options'] ?? $questionResult['responseSummary'] ?? [];
|
||||
|
||||
if (!is_array($options) || [] === $options) {
|
||||
return $this->buildEmptyChartSvg('Noch keine Daten');
|
||||
}
|
||||
|
||||
$dataset = $chart['data']['datasets'][0] ?? null;
|
||||
$fillColors = is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [];
|
||||
$borderColors = is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [];
|
||||
$counts = array_map(static fn (array $option): int => (int) ($option['count'] ?? 0), $options);
|
||||
$total = array_sum($counts);
|
||||
|
||||
if ($total <= 0) {
|
||||
return $this->buildEmptyChartSvg('Noch keine Daten');
|
||||
}
|
||||
|
||||
$centerX = 140.0;
|
||||
$centerY = 140.0;
|
||||
$radius = 88.0;
|
||||
$startAngle = -90.0;
|
||||
$segments = [];
|
||||
|
||||
foreach ($options as $index => $option) {
|
||||
$count = (int) ($option['count'] ?? 0);
|
||||
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sweepAngle = ($count / $total) * 360.0;
|
||||
$endAngle = $startAngle + $sweepAngle;
|
||||
$largeArcFlag = $sweepAngle > 180.0 ? 1 : 0;
|
||||
|
||||
$startPoint = $this->polarToCartesian($centerX, $centerY, $radius, $startAngle);
|
||||
$endPoint = $this->polarToCartesian($centerX, $centerY, $radius, $endAngle);
|
||||
$fill = (string) ($fillColors[$index] ?? '#D3E4F1');
|
||||
$stroke = (string) ($borderColors[$index] ?? '#0064AD');
|
||||
|
||||
$segments[] = sprintf(
|
||||
'<path d="M %.2f %.2f L %.2f %.2f A %.2f %.2f 0 %d 1 %.2f %.2f Z" fill="%s" stroke="%s" stroke-width="2" />',
|
||||
$centerX,
|
||||
$centerY,
|
||||
$startPoint['x'],
|
||||
$startPoint['y'],
|
||||
$radius,
|
||||
$radius,
|
||||
$largeArcFlag,
|
||||
$endPoint['x'],
|
||||
$endPoint['y'],
|
||||
$this->escapeXml($fill),
|
||||
$this->escapeXml($stroke),
|
||||
);
|
||||
|
||||
$startAngle = $endAngle;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="280" height="280" viewBox="0 0 280 280" role="img" aria-label="Kreisdiagramm"><rect width="280" height="280" rx="24" fill="#ffffff"/><circle cx="140" cy="140" r="108" fill="#f6f8fb"/><g>%s</g><circle cx="140" cy="140" r="26" fill="#ffffff"/><text x="140" y="146" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#222222">%d</text></svg>',
|
||||
implode('', $segments),
|
||||
$total,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $questionResult
|
||||
* @param array<string, mixed> $chart
|
||||
*/
|
||||
private function buildLineChartSvg(array $questionResult, array $chart): ?string
|
||||
{
|
||||
$distribution = $questionResult['distribution'] ?? [];
|
||||
|
||||
if (!is_array($distribution) || [] === $distribution) {
|
||||
return $this->buildEmptyChartSvg('Noch keine Bewertungen');
|
||||
}
|
||||
|
||||
$labels = array_map(static fn (array $row): string => (string) ($row['label'] ?? ''), $distribution);
|
||||
$values = array_map(static fn (array $row): int => (int) ($row['count'] ?? 0), $distribution);
|
||||
$maxValue = max(1, max($values));
|
||||
|
||||
$width = 560.0;
|
||||
$height = 280.0;
|
||||
$paddingLeft = 58.0;
|
||||
$paddingRight = 22.0;
|
||||
$paddingTop = 24.0;
|
||||
$paddingBottom = 44.0;
|
||||
$plotWidth = $width - $paddingLeft - $paddingRight;
|
||||
$plotHeight = $height - $paddingTop - $paddingBottom;
|
||||
$steps = max(1, count($values) - 1);
|
||||
$dataset = is_array($chart['data']['datasets'][0] ?? null) ? $chart['data']['datasets'][0] : [];
|
||||
$lineColor = (string) ($dataset['borderColor'] ?? '#0064AD');
|
||||
$areaFill = (string) ($dataset['backgroundColor'] ?? 'rgba(236, 124, 50, 0.12)');
|
||||
$pointColors = is_array($chart['data']['datasets'][0]['pointBackgroundColor'] ?? null) ? $chart['data']['datasets'][0]['pointBackgroundColor'] : [];
|
||||
$polylinePoints = [];
|
||||
$areaPolylinePoints = [];
|
||||
$pointCircles = [];
|
||||
$xLabels = [];
|
||||
$yGrid = [];
|
||||
|
||||
for ($tick = 0; $tick <= $maxValue; ++$tick) {
|
||||
$ratio = $maxValue > 0 ? $tick / $maxValue : 0;
|
||||
$y = $paddingTop + $plotHeight - ($ratio * $plotHeight);
|
||||
$yGrid[] = sprintf('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
|
||||
$yGrid[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="end" font-size="11" font-family="DejaVu Sans" fill="#555e68">%d</text>', $paddingLeft - 8, $y + 4, $tick);
|
||||
}
|
||||
|
||||
foreach ($values as $index => $value) {
|
||||
$x = $paddingLeft + ($plotWidth * ($index / $steps));
|
||||
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
|
||||
$polylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
||||
$areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
||||
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="5.5" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
|
||||
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $x, $height - 16, $this->escapeXml($labels[$index]));
|
||||
}
|
||||
|
||||
$areaPoints = sprintf(
|
||||
'%.2f,%.2f %s %.2f,%.2f',
|
||||
$paddingLeft,
|
||||
$paddingTop + $plotHeight,
|
||||
implode(' ', $areaPolylinePoints),
|
||||
$width - $paddingRight,
|
||||
$paddingTop + $plotHeight,
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="280" viewBox="0 0 560 280" role="img" aria-label="Liniendiagramm"><rect width="560" height="280" rx="22" fill="#ffffff"/><rect x="1" y="1" width="558" height="278" rx="22" fill="none" stroke="#e3e7ee"/><g>%s</g><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><polygon fill="%s" stroke="none" points="%s"/><polyline fill="none" stroke="%s" stroke-width="3.5" stroke-linejoin="round" stroke-linecap="round" points="%s"/><g>%s</g><g>%s</g></svg>',
|
||||
implode('', $yGrid),
|
||||
$paddingLeft,
|
||||
$paddingTop + $plotHeight,
|
||||
$width - $paddingRight,
|
||||
$paddingTop + $plotHeight,
|
||||
$paddingLeft,
|
||||
$paddingTop,
|
||||
$paddingLeft,
|
||||
$paddingTop + $plotHeight,
|
||||
$this->escapeXml($areaFill),
|
||||
$areaPoints,
|
||||
$this->escapeXml($lineColor),
|
||||
implode(' ', $polylinePoints),
|
||||
implode('', $pointCircles),
|
||||
implode('', $xLabels),
|
||||
);
|
||||
}
|
||||
|
||||
private function buildEmptyChartSvg(string $label): string
|
||||
{
|
||||
return sprintf(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="220" viewBox="0 0 560 220" role="img" aria-label="Leeres Diagramm"><rect width="560" height="220" rx="22" fill="#f6f8fb"/><rect x="1" y="1" width="558" height="218" rx="22" fill="none" stroke="#e3e7ee"/><text x="280" y="114" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#555e68">%s</text></svg>',
|
||||
$this->escapeXml($label),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{x:float,y:float}
|
||||
*/
|
||||
private function polarToCartesian(float $centerX, float $centerY, float $radius, float $angleInDegrees): array
|
||||
{
|
||||
$angleInRadians = deg2rad($angleInDegrees);
|
||||
|
||||
return [
|
||||
'x' => $centerX + ($radius * cos($angleInRadians)),
|
||||
'y' => $centerY + ($radius * sin($angleInRadians)),
|
||||
];
|
||||
}
|
||||
|
||||
private function escapeXml(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
|
||||
private function buildFilename(string $title): string
|
||||
{
|
||||
$normalizedTitle = preg_replace('/\s+/', '_', trim($title)) ?: 'Umfrage';
|
||||
$sanitizedTitle = preg_replace('/[^\pL\pN_-]+/u', '', $normalizedTitle) ?: 'Umfrage';
|
||||
|
||||
return sprintf('%s_Ergebnisse.pdf', trim($sanitizedTitle, '_-'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
|
||||
|
||||
final class SurveyResultsViewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SurveyQuestionRepository $surveyQuestionRepository,
|
||||
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
||||
private readonly SurveySubmissionService $surveySubmissionService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{completedSubmissionCount:int,questionResults:list<array<string, mixed>>}
|
||||
*/
|
||||
public function buildForSurvey(SurveyModel $survey): array
|
||||
{
|
||||
$overview = $this->surveySubmissionService->getSubmissionOverview($survey);
|
||||
$finishedSubmissions = array_values(array_filter(
|
||||
$overview,
|
||||
static fn (array $submission): bool => '1' === (string) ($submission['isFinished'] ?? '')
|
||||
));
|
||||
|
||||
$completedSubmissionCount = count($finishedSubmissions);
|
||||
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
|
||||
$answers = $this->surveyAnswerRepository->findFinishedAnswersBySurvey((int) $survey->id);
|
||||
|
||||
return [
|
||||
'completedSubmissionCount' => $completedSubmissionCount,
|
||||
'questionResults' => $this->buildQuestionResults($questions, $answers, $completedSubmissionCount),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<SurveyContentModel> $questions
|
||||
* @param list<array{questionId:int,value:string}> $answers
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
|
||||
{
|
||||
$answersByQuestion = [];
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
$answersByQuestion[$answer['questionId']][] = $answer['value'];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
$questionId = (int) $question->id;
|
||||
$values = $answersByQuestion[$questionId] ?? [];
|
||||
$totalAnswers = count($values);
|
||||
$responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
||||
|
||||
$result = [
|
||||
'id' => $questionId,
|
||||
'question' => (string) $question->question,
|
||||
'description' => (string) ($question->description ?? ''),
|
||||
'type' => (string) $question->type,
|
||||
'totalAnswers' => $totalAnswers,
|
||||
'responseRate' => $responseRate,
|
||||
'chart' => null,
|
||||
];
|
||||
|
||||
switch ((string) $question->type) {
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = [
|
||||
'Ja' => 0,
|
||||
'Nein' => 0,
|
||||
];
|
||||
|
||||
if ('1' === (string) $question->allowMaybe) {
|
||||
$optionCounts['Vielleicht'] = 0;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedValue = mb_strtolower(trim($value));
|
||||
|
||||
if ('yes' === $normalizedValue) {
|
||||
++$optionCounts['Ja'];
|
||||
} elseif ('no' === $normalizedValue) {
|
||||
++$optionCounts['Nein'];
|
||||
} elseif ('maybe' === $normalizedValue && isset($optionCounts['Vielleicht'])) {
|
||||
++$optionCounts['Vielleicht'];
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'choice':
|
||||
$optionCounts = [];
|
||||
|
||||
for ($index = 1; $index <= 10; ++$index) {
|
||||
$option = trim((string) $question->{'answerOption'.$index});
|
||||
|
||||
if ('' !== $option) {
|
||||
$optionCounts[$option] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
|
||||
if (!array_key_exists($selectedOption, $optionCounts)) {
|
||||
$optionCounts[$selectedOption] = 0;
|
||||
}
|
||||
|
||||
++$optionCounts[$selectedOption];
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
||||
$distribution = [];
|
||||
|
||||
foreach ($numericValues as $numericValue) {
|
||||
$label = (string) $numericValue;
|
||||
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
|
||||
}
|
||||
|
||||
ksort($distribution, SORT_NATURAL);
|
||||
|
||||
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
||||
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
||||
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
||||
$result['distribution'] = $this->buildOptionStats($distribution);
|
||||
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
|
||||
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['responseSummary']);
|
||||
break;
|
||||
}
|
||||
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $optionCounts
|
||||
*
|
||||
* @return list<array{label:string,count:int,percentage:int}>
|
||||
*/
|
||||
private function buildOptionStats(array $optionCounts): array
|
||||
{
|
||||
$total = array_sum($optionCounts);
|
||||
$options = [];
|
||||
|
||||
foreach ($optionCounts as $label => $count) {
|
||||
$options[] = [
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
'percentage' => $total > 0 ? (int) round(($count / $total) * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{label:string,count:int,percentage:int}> $options
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function buildPieChartConfig(array $options): ?array
|
||||
{
|
||||
if ([] === $options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$palette = $this->buildChartPalette(count($options));
|
||||
|
||||
return [
|
||||
'type' => 'pie',
|
||||
'data' => [
|
||||
'labels' => array_column($options, 'label'),
|
||||
'datasets' => [[
|
||||
'data' => array_column($options, 'count'),
|
||||
'backgroundColor' => array_column($palette, 'background'),
|
||||
'borderColor' => array_column($palette, 'border'),
|
||||
'borderWidth' => 2,
|
||||
'hoverOffset' => 18,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'responsive' => true,
|
||||
'maintainAspectRatio' => false,
|
||||
'plugins' => [
|
||||
'legend' => [
|
||||
'position' => 'bottom',
|
||||
'labels' => [
|
||||
'usePointStyle' => true,
|
||||
'boxWidth' => 12,
|
||||
'padding' => 18,
|
||||
'color' => '#555E68',
|
||||
'font' => [
|
||||
'family' => 'PT Sans Narrow',
|
||||
'size' => 12,
|
||||
'weight' => '600',
|
||||
],
|
||||
],
|
||||
],
|
||||
'tooltip' => [
|
||||
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
|
||||
'titleFont' => [
|
||||
'family' => 'Blogger Sans',
|
||||
'size' => 14,
|
||||
],
|
||||
'bodyFont' => [
|
||||
'family' => 'PT Sans Narrow',
|
||||
'size' => 12,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{label:string,count:int,percentage:int}> $distribution
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function buildRangeChartConfig(array $distribution): ?array
|
||||
{
|
||||
if ([] === $distribution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
|
||||
$palette = $this->buildChartPalette(count($distribution));
|
||||
|
||||
return [
|
||||
'type' => 'line',
|
||||
'data' => [
|
||||
'labels' => array_column($distribution, 'label'),
|
||||
'datasets' => [[
|
||||
'label' => 'Antworten',
|
||||
'data' => array_column($distribution, 'count'),
|
||||
'borderColor' => '#0064AD',
|
||||
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
|
||||
'pointBackgroundColor' => array_column($palette, 'border'),
|
||||
'pointBorderColor' => '#ffffff',
|
||||
'pointBorderWidth' => 3,
|
||||
'pointHoverBorderWidth' => 3,
|
||||
'pointRadius' => 7,
|
||||
'pointHoverRadius' => 9,
|
||||
'pointStyle' => array_map(
|
||||
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
|
||||
array_keys($distribution)
|
||||
),
|
||||
'fill' => true,
|
||||
'tension' => 0.32,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'responsive' => true,
|
||||
'maintainAspectRatio' => false,
|
||||
'plugins' => [
|
||||
'legend' => [
|
||||
'display' => false,
|
||||
],
|
||||
'tooltip' => [
|
||||
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
|
||||
'titleFont' => [
|
||||
'family' => 'Blogger Sans',
|
||||
'size' => 14,
|
||||
],
|
||||
'bodyFont' => [
|
||||
'family' => 'PT Sans Narrow',
|
||||
'size' => 12,
|
||||
],
|
||||
],
|
||||
],
|
||||
'scales' => [
|
||||
'x' => [
|
||||
'grid' => [
|
||||
'display' => false,
|
||||
],
|
||||
'ticks' => [
|
||||
'color' => '#555E68',
|
||||
'font' => [
|
||||
'family' => 'PT Sans Narrow',
|
||||
'weight' => '600',
|
||||
],
|
||||
],
|
||||
],
|
||||
'y' => [
|
||||
'beginAtZero' => true,
|
||||
'ticks' => [
|
||||
'precision' => 0,
|
||||
'color' => '#555E68',
|
||||
'font' => [
|
||||
'family' => 'PT Sans Narrow',
|
||||
'weight' => '600',
|
||||
],
|
||||
],
|
||||
'grid' => [
|
||||
'color' => 'rgba(162, 168, 180, 0.22)',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $responses
|
||||
*
|
||||
* @return list<array{label:string,count:int,percentage:int}>
|
||||
*/
|
||||
private function buildTextResponseSummary(array $responses): array
|
||||
{
|
||||
if ([] === $responses) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
$label = trim($response);
|
||||
|
||||
if ('' === $label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$label] = ($counts[$label] ?? 0) + 1;
|
||||
}
|
||||
|
||||
arsort($counts);
|
||||
$summary = [];
|
||||
$remainingCount = 0;
|
||||
$index = 0;
|
||||
|
||||
foreach ($counts as $label => $count) {
|
||||
if ($index < 5) {
|
||||
$summary[$label] = $count;
|
||||
} else {
|
||||
$remainingCount += $count;
|
||||
}
|
||||
|
||||
++$index;
|
||||
}
|
||||
|
||||
if ($remainingCount > 0) {
|
||||
$summary['Weitere Antworten'] = $remainingCount;
|
||||
}
|
||||
|
||||
return $this->buildOptionStats($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{background:string,border:string}>
|
||||
*/
|
||||
private function buildChartPalette(int $count): array
|
||||
{
|
||||
$palette = [
|
||||
['background' => 'rgba(0, 100, 173, 0.16)', 'border' => '#0064AD'],
|
||||
['background' => 'rgba(236, 124, 50, 0.24)', 'border' => '#EC7C32'],
|
||||
['background' => 'rgba(157, 82, 118, 0.18)', 'border' => '#9D5276'],
|
||||
['background' => 'rgba(0, 174, 151, 0.22)', 'border' => '#00AE97'],
|
||||
['background' => 'rgba(252, 228, 214, 0.9)', 'border' => '#EC7C32'],
|
||||
['background' => 'rgba(162, 168, 180, 0.28)', 'border' => '#555E68'],
|
||||
['background' => 'rgba(233, 233, 235, 0.9)', 'border' => '#A2A8B4'],
|
||||
['background' => 'rgba(34, 34, 34, 0.12)', 'border' => '#222222'],
|
||||
];
|
||||
|
||||
$colors = [];
|
||||
|
||||
for ($index = 0; $index < max(1, $count); ++$index) {
|
||||
$colors[] = $palette[$index % count($palette)];
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
|
||||
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
|
||||
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveySubmissionRepository;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
final class SurveySubmissionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SurveySubmissionRepository $surveySubmissionRepository,
|
||||
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
||||
private readonly SurveyRepository $surveyRepository,
|
||||
private readonly QuestionTypeRegistry $questionTypeRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveActiveSubmission(SurveyModel $survey, SessionInterface $session, ?SurveyContentModel $firstQuestion): SurveySubmissionModel
|
||||
{
|
||||
$sessionKey = $this->buildSessionKey((int) $survey->id);
|
||||
$token = (string) $session->get($sessionKey, '');
|
||||
|
||||
if ('' !== $token) {
|
||||
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
|
||||
|
||||
if ($existing instanceof SurveySubmissionModel) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$token = (string) new Ulid();
|
||||
$submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null);
|
||||
$session->set($sessionKey, $token);
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
|
||||
{
|
||||
$questionType = $this->questionTypeRegistry->get((string) $question->type);
|
||||
$normalizedAnswer = $questionType->normalizeAnswer($rawAnswer, $question);
|
||||
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
|
||||
|
||||
$this->surveyAnswerRepository->saveAnswer(
|
||||
(int) $submission->id,
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
$normalizedAnswer,
|
||||
);
|
||||
|
||||
if (!$surveyHadAnswers && '1' !== (string) $survey->isLocked) {
|
||||
$this->surveyRepository->lock((int) $survey->id);
|
||||
}
|
||||
|
||||
return $normalizedAnswer;
|
||||
}
|
||||
|
||||
public function advance(SurveySubmissionModel $submission, ?SurveyContentModel $nextQuestion): void
|
||||
{
|
||||
if ($nextQuestion instanceof SurveyContentModel) {
|
||||
$this->surveySubmissionRepository->updateCurrentQuestion((int) $submission->id, (int) $nextQuestion->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->surveySubmissionRepository->markFinished((int) $submission->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function getAnswersForSubmission(SurveySubmissionModel $submission): array
|
||||
{
|
||||
return $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function getSubmissionOverview(SurveyModel $survey): array
|
||||
{
|
||||
return $this->surveySubmissionRepository->findOverviewBySurvey((int) $survey->id);
|
||||
}
|
||||
|
||||
public function hasFinishedSubmissionForSurvey(int $surveyId, SessionInterface $session): bool
|
||||
{
|
||||
if ($surveyId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = (string) $session->get($this->buildSessionKey($surveyId), '');
|
||||
|
||||
if ('' === $token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->surveySubmissionRepository->hasFinishedBySurveyAndToken($surveyId, $token);
|
||||
}
|
||||
|
||||
private function buildSessionKey(int $surveyId): string
|
||||
{
|
||||
return 'mummert_survey_submission_'.$surveyId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user