feat: feedback statistics

addresses #869bgtz2d
This commit is contained in:
Björn Fromme
2025-12-23 14:54:27 +01:00
parent 2fb4fd750b
commit cdfd172a1e
16 changed files with 745 additions and 4 deletions
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Feedback;
use App\Repository\FeedbackRepository;
use App\Repository\FeedbackSetRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:feedback:populate-feedback-set',
description: 'One-time command to populate missing feedbackSet for existing feedbacks',
)]
class PopulateFeedbackSetCommand extends Command
{
public function __construct(
private readonly FeedbackRepository $feedbackRepository,
private readonly FeedbackSetRepository $feedbackSetRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Run without persisting changes to the database'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$isDryRun = true === $input->getOption('dry-run');
if ($isDryRun) {
$io->note('Running in dry-run mode. No changes will be persisted.');
}
// Build lookup table: question texts -> FeedbackSet
$feedbackSets = $this->feedbackSetRepository->findAll();
$lookup = [];
foreach ($feedbackSets as $feedbackSet) {
$key = $this->buildQuestionKey($feedbackSet->getRatings());
$lookup[$key] = $feedbackSet;
$io->writeln(sprintf('Registered FeedbackSet #%d "%s" with %d questions', $feedbackSet->getId(), $feedbackSet->getName(), count($feedbackSet->getRatings())));
}
// Find feedbacks without feedbackSet
$qb = $this->feedbackRepository->createQueryBuilder('f');
$feedbacks = $qb
->where($qb->expr()->isNull('f.feedbackSet'))
->andWhere($qb->expr()->isNotNull('f.ratings'))
->getQuery()
->getResult()
;
$totalCount = count($feedbacks);
if (0 === $totalCount) {
$io->success('No feedbacks found that need feedbackSet assignment.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d feedback(s) without feedbackSet.', $totalCount));
$matchedCount = 0;
$fallbackCount = 0;
$unmatchedCount = 0;
/** @var Feedback $feedback */
foreach ($feedbacks as $feedback) {
$feedbackSet = $this->matchByQuestions($feedback, $lookup);
if (null !== $feedbackSet) {
$feedback->setFeedbackSet($feedbackSet);
++$matchedCount;
if ($output->isVerbose()) {
$io->writeln(sprintf(
'Matched feedback #%d to FeedbackSet "%s" by questions',
$feedback->getId(),
$feedbackSet->getName()
));
}
continue;
}
// Fallback: try via Assignment -> JobProfile -> FeedbackSet
$feedbackSet = $this->matchByAssignment($feedback);
if (null !== $feedbackSet) {
$feedback->setFeedbackSet($feedbackSet);
++$fallbackCount;
if ($output->isVerbose()) {
$io->writeln(sprintf(
'Matched feedback #%d to FeedbackSet "%s" via Assignment fallback',
$feedback->getId(),
$feedbackSet->getName()
));
}
continue;
}
++$unmatchedCount;
$io->warning(sprintf(
'Could not match feedback #%d (no matching FeedbackSet found)',
$feedback->getId()
));
}
if (false === $isDryRun && ($matchedCount > 0 || $fallbackCount > 0)) {
$this->entityManager->flush();
$io->success(sprintf(
'Successfully updated %d feedback(s): %d by questions, %d by assignment fallback. %d unmatched.',
$matchedCount + $fallbackCount,
$matchedCount,
$fallbackCount,
$unmatchedCount
));
} elseif ($isDryRun) {
$io->success(sprintf(
'Dry-run complete. Would update %d feedback(s): %d by questions, %d by assignment fallback. %d unmatched.',
$matchedCount + $fallbackCount,
$matchedCount,
$fallbackCount,
$unmatchedCount
));
} else {
$io->info(sprintf('No feedbacks updated. %d unmatched.', $unmatchedCount));
}
return Command::SUCCESS;
}
/**
* @param array<int, array{rating: string, mark: int}> $ratings
*/
private function buildQuestionKey(array $ratings): string
{
// For FeedbackSet, ratings is array of strings
if (isset($ratings[0]) && is_string($ratings[0])) {
return implode('|', $ratings);
}
// For Feedback, ratings is array of {rating, mark}
$questions = array_map(fn (array $r): string => $r['rating'], $ratings);
return implode('|', $questions);
}
/**
* @param array<string, \App\Entity\FeedbackSet> $lookup
*/
private function matchByQuestions(Feedback $feedback, array $lookup): ?\App\Entity\FeedbackSet
{
$ratings = $feedback->getRatings();
if (0 === count($ratings)) {
return null;
}
$key = $this->buildQuestionKey($ratings);
return $lookup[$key] ?? null;
}
private function matchByAssignment(Feedback $feedback): ?\App\Entity\FeedbackSet
{
$assignment = $feedback->getAssignment();
if (null === $assignment) {
return null;
}
$jobProfile = $assignment->getJobProfile();
if (null === $jobProfile) {
return null;
}
return $jobProfile->getFeedbackSet();
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Controller\Administrative\Statistics;
use App\Form\FeedbackStatisticsFilterType;
use App\Repository\FeedbackRepository;
use App\Service\Common\FeedbackStatisticsFilterHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class FeedbackRatingsController extends AbstractController
{
public function __construct(
private readonly FeedbackStatisticsFilterHandler $filterHandler,
private readonly FeedbackRepository $feedbackRepository,
) {
}
#[Route('/administrative/statistics/feedback-ratings', name: 'app_administrative_statistics_feedback_ratings')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$form = $this->createForm(FeedbackStatisticsFilterType::class, $filterDto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$filterDto = $this->filterHandler->handleRequest($form);
}
$statistics = $this->feedbackRepository->getAverageRatingsByQuestionAndFeedbackSet(
$filterDto->getDateFrom(),
$filterDto->getDateTo(),
);
// Prepare chart data for each feedback set
$chartsData = [];
foreach ($statistics as $stat) {
$feedbackSet = $stat['feedbackSet'];
$questions = $feedbackSet->getRatings();
$averages = $stat['averages'];
// Build labels (1, 2, 3, ...) for x-axis
$labels = [];
$data = [];
foreach ($questions as $index => $question) {
$labels[] = (string) ($index + 1);
$data[] = $averages[$index] ?? 0.0;
}
$chartsData[] = [
'feedbackSet' => $feedbackSet,
'labels' => $labels,
'data' => $data,
'questions' => $questions,
'count' => $stat['count'],
];
}
return $this->render('administrative/statistics/feedback_ratings.html.twig', [
'form' => $form->createView(),
'filterDto' => $filterDto,
'chartsData' => $chartsData,
]);
}
}
+3 -1
View File
@@ -78,7 +78,8 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
#[ORM\Column(length: 255)]
private ?string $author;
// Transient property for form only
#[ORM\ManyToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['admin'])]
private ?FeedbackSet $feedbackSet = null;
@@ -105,6 +106,7 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
->setHotelBusProId($destination->getHotelBusProId())
->setJobProfileName($jobProfile->getName())
->setJobProfileCategory($jobProfile->getCategory())
->setFeedbackSet($jobProfile->getFeedbackSet())
;
return $instance;
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Model\FeedbackStatisticsFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FeedbackStatisticsFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('dateFrom', DatepickerType::class, [
'label' => 'Zeitraum von',
'required' => false,
])
->add('dateTo', DatepickerType::class, [
'label' => 'Zeitraum bis',
'required' => false,
])
->add('apply', SubmitType::class, [
'label' => 'filtern',
])
->add('reset', SubmitType::class, [
'label' => 'reset',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => FeedbackStatisticsFilterDto::class,
])
;
}
}
-2
View File
@@ -4,11 +4,9 @@ namespace App\Form;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Entity\Training;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
+16
View File
@@ -113,6 +113,22 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'icon' => 'calendar',
],
]);
$statisticsMenu = $menu->addChild('Statistiken', [
'extras' => [
'icon' => 'chart',
],
]);
$statisticsMenu->addChild('Feedback-Bewertungen', [
'route' => 'app_administrative_statistics_feedback_ratings',
'linkAttributes' => [
'title' => 'Feedback-Bewertungen',
],
'extras' => [
'routes' => [
['pattern' => '/^app_administrative_statistics_/'],
],
],
]);
$settingsMenu = $menu->addChild('Einstellungen', [
'linkAttributes' => [
'title' => 'Einstellungen',
+16
View File
@@ -94,6 +94,22 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
],
],
]);
$statisticsMenu = $menu->addChild('Statistiken', [
'extras' => [
'icon' => 'chart',
],
]);
$statisticsMenu->addChild('Feedback-Bewertungen', [
'route' => 'app_administrative_statistics_feedback_ratings',
'linkAttributes' => [
'title' => 'Feedback-Bewertungen',
],
'extras' => [
'routes' => [
['pattern' => '/^app_administrative_statistics_/'],
],
],
]);
$settingsMenu = $menu->addChild('Einstellungen', [
'linkAttributes' => [
'title' => 'Einstellungen',
-1
View File
@@ -42,7 +42,6 @@ class DestinationDto
->setCountry($destination->getCountry())
->setPickups($pickupIds)
->setCostUnit($destination->getCostUnit());
;
return $instance;
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Model;
class FeedbackStatisticsFilterDto extends AbstractFilterDto
{
protected ?\DateTimeImmutable $dateFrom = null;
protected ?\DateTimeImmutable $dateTo = null;
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(?\DateTimeImmutable $dateTo): static
{
$this->dateTo = $dateTo;
return $this;
}
}
+100
View File
@@ -3,6 +3,7 @@
namespace App\Repository;
use App\Entity\Feedback;
use App\Entity\FeedbackSet;
use App\Entity\Teamer;
use App\Model\FeedbackFilterDto;
use App\Repository\Traits\QueryHelperTrait;
@@ -116,4 +117,103 @@ class FeedbackRepository extends ServiceEntityRepository
->getResult()
;
}
/**
* Returns average ratings per question index grouped by FeedbackSet.
*
* @return array<int, array{
* feedbackSet: FeedbackSet,
* averages: array<int, float>,
* count: int
* }>
*/
public function getAverageRatingsByQuestionAndFeedbackSet(
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
): array {
$qb = $this->createQueryBuilder('f');
$qb
->select('f.ratings', 'IDENTITY(f.feedbackSet) AS feedbackSetId')
->where('f.status = :status')
->andWhere('f.feedbackSet IS NOT NULL')
->setParameter('status', Feedback::STATUS_PUBLISHED)
;
if (null !== $dateFrom) {
$qb
->andWhere($qb->expr()->gte('f.assignmentDateFrom', ':dateFrom'))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo) {
$qb
->andWhere($qb->expr()->lte('f.assignmentDateTo', ':dateTo'))
->setParameter('dateTo', $dateTo)
;
}
$results = $qb->getQuery()->getResult();
// Group by feedbackSet and calculate averages per question index
$grouped = [];
foreach ($results as $row) {
$feedbackSetId = $row['feedbackSetId'];
$ratings = $row['ratings'];
if (false === isset($grouped[$feedbackSetId])) {
$grouped[$feedbackSetId] = [
'sums' => [],
'counts' => [],
'totalCount' => 0,
];
}
foreach ($ratings as $index => $rating) {
if (false === isset($rating['mark'])) {
continue;
}
$mark = (float) $rating['mark'];
if (false === isset($grouped[$feedbackSetId]['sums'][$index])) {
$grouped[$feedbackSetId]['sums'][$index] = 0.0;
$grouped[$feedbackSetId]['counts'][$index] = 0;
}
$grouped[$feedbackSetId]['sums'][$index] += $mark;
++$grouped[$feedbackSetId]['counts'][$index];
}
++$grouped[$feedbackSetId]['totalCount'];
}
// Convert sums to averages and fetch FeedbackSet entities
$feedbackSetRepository = $this->getEntityManager()->getRepository(FeedbackSet::class);
$output = [];
foreach ($grouped as $feedbackSetId => $data) {
$feedbackSet = $feedbackSetRepository->find($feedbackSetId);
if (null === $feedbackSet) {
continue;
}
$averages = [];
foreach ($data['sums'] as $index => $sum) {
$count = $data['counts'][$index];
$averages[$index] = $count > 0 ? round($sum / $count, 2) : 0.0;
}
$output[] = [
'feedbackSet' => $feedbackSet,
'averages' => $averages,
'count' => $data['totalCount'],
];
}
return $output;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service\Common;
use App\Model\AbstractFilterDto;
use App\Model\FeedbackStatisticsFilterDto;
class FeedbackStatisticsFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:feedback_statistics';
protected string $modelClass = FeedbackStatisticsFilterDto::class;
public function getFilterSettings(): FeedbackStatisticsFilterDto
{
$filterDto = new $this->modelClass();
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
if (isset($data['date_from'])) {
$filterDto->setDateFrom($data['date_from']);
}
if (isset($data['date_to'])) {
$filterDto->setDateTo($data['date_to']);
}
return $filterDto;
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/* @var FeedbackStatisticsFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'date_from' => $filterDto->getDateFrom(),
'date_to' => $filterDto->getDateTo(),
]);
}
}