feat: feedback statistics
addresses #869bgtz2d
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -42,7 +42,6 @@ class DestinationDto
|
||||
->setCountry($destination->getCountry())
|
||||
->setPickups($pickupIds)
|
||||
->setCostUnit($destination->getCostUnit());
|
||||
;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user