From b2cc6e2dcf773d8817a0346e89c663ba932a5136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 1 Sep 2026 09:28:41 +0200 Subject: [PATCH] feat: applications by destination statistics addresses #869dv9fh6 --- .../ApplicationsByDestinationController.php | 48 +++++++++++ src/Menu/AdminMenuBuilder.php | 6 ++ src/Menu/ManagerMenuBuilder.php | 6 ++ src/Repository/ApplicationRepository.php | 66 +++++++++++++++ .../applications_by_destination.html.twig | 84 +++++++++++++++++++ .../Repository/ApplicationRepositoryTest.php | 55 ++++++++++++ 6 files changed, 265 insertions(+) create mode 100644 src/Controller/Administrative/Statistics/ApplicationsByDestinationController.php create mode 100644 templates/administrative/statistics/applications_by_destination.html.twig diff --git a/src/Controller/Administrative/Statistics/ApplicationsByDestinationController.php b/src/Controller/Administrative/Statistics/ApplicationsByDestinationController.php new file mode 100644 index 0000000..7865afd --- /dev/null +++ b/src/Controller/Administrative/Statistics/ApplicationsByDestinationController.php @@ -0,0 +1,48 @@ +filterHandler->getFilterSettings(); + + $form = $this->createForm(StatisticsFilterType::class, $filterDto); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $filterDto = $this->filterHandler->handleRequest($form); + } + + $statistics = $this->applicationRepository->getApplicationsByDestination( + $filterDto->getDateFrom(), + $filterDto->getDateTo(), + ); + + return $this->render('administrative/statistics/applications_by_destination.html.twig', [ + 'form' => $form->createView(), + 'filterDto' => $filterDto, + 'statistics' => $statistics, + ]); + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index 78dd046..a6a84c0 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -136,6 +136,12 @@ class AdminMenuBuilder extends AbstractMenuBuilder 'title' => 'Bewertungen nach Destination', ], ]); + $statisticsMenu->addChild('Bewerbungen pro Destination', [ + 'route' => 'app_administrative_statistics_applications_by_destination', + 'linkAttributes' => [ + 'title' => 'Bewerbungen pro Destination', + ], + ]); $settingsMenu = $menu->addChild('Einstellungen', [ 'linkAttributes' => [ 'title' => 'Einstellungen', diff --git a/src/Menu/ManagerMenuBuilder.php b/src/Menu/ManagerMenuBuilder.php index 7f6e9f1..d3367bf 100644 --- a/src/Menu/ManagerMenuBuilder.php +++ b/src/Menu/ManagerMenuBuilder.php @@ -117,6 +117,12 @@ class ManagerMenuBuilder extends AbstractMenuBuilder 'title' => 'Bewertungen nach Destination', ], ]); + $statisticsMenu->addChild('Bewerbungen pro Destination', [ + 'route' => 'app_administrative_statistics_applications_by_destination', + 'linkAttributes' => [ + 'title' => 'Bewerbungen pro Destination', + ], + ]); $settingsMenu = $menu->addChild('Einstellungen', [ 'linkAttributes' => [ 'title' => 'Einstellungen', diff --git a/src/Repository/ApplicationRepository.php b/src/Repository/ApplicationRepository.php index a00bebe..4d3538d 100644 --- a/src/Repository/ApplicationRepository.php +++ b/src/Repository/ApplicationRepository.php @@ -7,6 +7,7 @@ use App\Entity\Assignment; use App\Entity\Teamer; use App\Model\ApplicationFilterDto; use App\Model\AssignmentFilterDto; +use App\Repository\Filter\SeasonPeriodFilter; use App\Repository\Traits\QueryHelperTrait; use Carbon\CarbonPeriodImmutable; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -250,4 +251,69 @@ class ApplicationRepository extends ServiceEntityRepository ->getSingleScalarResult() ; } + + /** + * The raw tally grouped by normalized hotel code, split by application status. + * + * Mirrors DispositionRepository::getFeedbackStatisticsByNormalizedHotelCodeQuery() - the + * "Bewerbungen pro Destination" screen sits next to "Feedback pro Destination" and means the + * same thing by a destination and a season, so the normalization and the date filter are the + * shared ones. + */ + public function getApplicationsByDestinationQuery( + ?\DateTimeImmutable $dateFrom = null, + ?\DateTimeImmutable $dateTo = null, + ): Query { + $qb = $this->createQueryBuilder('application'); + + // Normalize hotel code: strip SER prefix to get base 3-char code + $normalizedHotelCode = "CASE WHEN destination.hotelCode LIKE 'SER%' THEN SUBSTRING(destination.hotelCode, 4, 3) ELSE SUBSTRING(destination.hotelCode, 1, 3) END"; + + $qb + ->select( + $normalizedHotelCode.' AS hotelCode', + 'SUM(CASE WHEN application.status = :statusNew THEN 1 ELSE 0 END) AS newCount', + 'SUM(CASE WHEN application.status = :statusPending THEN 1 ELSE 0 END) AS pendingCount', + 'SUM(CASE WHEN application.status = :statusRejected THEN 1 ELSE 0 END) AS rejectedCount', + 'COUNT(application.id) AS totalCount' + ) + ->innerJoin('application.assignment', 'assignment') + ->innerJoin('assignment.destination', 'destination') + ->setParameter('statusNew', Application::STATUS_NEW) + ->setParameter('statusPending', Application::STATUS_PENDING) + ->setParameter('statusRejected', Application::STATUS_REJECTED) + ->groupBy('hotelCode') + ->orderBy('totalCount', 'DESC') + ->addOrderBy('hotelCode', 'ASC') + ; + + SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo); + + return $qb->getQuery(); + } + + /** + * Returns application counts grouped by normalized hotel code (base 3-char code), + * split into the three application statuses plus a total. + * + * @return array + */ + public function getApplicationsByDestination( + ?\DateTimeImmutable $dateFrom = null, + ?\DateTimeImmutable $dateTo = null, + ): array { + return array_map(static fn (array $row): array => [ + 'hotelCode' => $row['hotelCode'], + 'new' => (int) $row['newCount'], + 'pending' => (int) $row['pendingCount'], + 'rejected' => (int) $row['rejectedCount'], + 'total' => (int) $row['totalCount'], + ], $this->getApplicationsByDestinationQuery($dateFrom, $dateTo)->getResult()); + } } diff --git a/templates/administrative/statistics/applications_by_destination.html.twig b/templates/administrative/statistics/applications_by_destination.html.twig new file mode 100644 index 0000000..264aaaa --- /dev/null +++ b/templates/administrative/statistics/applications_by_destination.html.twig @@ -0,0 +1,84 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Bewerbungen pro Destination{% endblock %} + +{% block content %} +
+

+ Bewerbungen pro Destination +

+
+ +
+ {{ form_start(form, { attr: { class: 'flex flex-wrap items-end gap-4' } }) }} +
+ {{ form_row(form.dateFrom) }} +
+
+ {{ form_row(form.dateTo) }} +
+
+ {{ form_widget(form.apply, { attr: { class: 'btn btn--small' } }) }} + {{ form_widget(form.reset, { attr: { class: 'btn btn--small btn--secondary' } }) }} +
+ {{ form_end(form) }} +
+ + {% if filterDto.active %} +

+ Filter aktiv: + {% if filterDto.dateFrom %}von {{ filterDto.dateFrom|date('d.m.Y') }}{% endif %} + {% if filterDto.dateTo %}bis {{ filterDto.dateTo|date('d.m.Y') }}{% endif %} +

+ {% endif %} + + {% if statistics is empty %} +
+ Keine Daten im ausgewählten Zeitraum vorhanden. +
+ {% else %} +
+ + + + + + + + + + + + {% for stat in statistics %} + + + + + + + + {% endfor %} + + + {% set totalNew = 0 %} + {% set totalPending = 0 %} + {% set totalRejected = 0 %} + {% set totalAll = 0 %} + {% for stat in statistics %} + {% set totalNew = totalNew + stat.new %} + {% set totalPending = totalPending + stat.pending %} + {% set totalRejected = totalRejected + stat.rejected %} + {% set totalAll = totalAll + stat.total %} + {% endfor %} + + + + + + + + +
DestinationNeuIn PrüfungAbgelehntGesamt
{{ stat.hotelCode }}{{ stat.new }}{{ stat.pending }}{{ stat.rejected }}{{ stat.total }}
Gesamt{{ totalNew }}{{ totalPending }}{{ totalRejected }}{{ totalAll }}
+
+ {% endif %} +{% endblock %} diff --git a/tests/Repository/ApplicationRepositoryTest.php b/tests/Repository/ApplicationRepositoryTest.php index b16b057..43ba6a1 100644 --- a/tests/Repository/ApplicationRepositoryTest.php +++ b/tests/Repository/ApplicationRepositoryTest.php @@ -71,4 +71,59 @@ class ApplicationRepositoryTest extends KernelTestCase $period ?? new CarbonPeriodImmutable('2025-01-10', '2025-01-20') ); } + + public function testApplicationsByDestinationGroupsByTheNormalizedHotelCode(): void + { + $dql = $this->createApplicationsByDestinationQuery()->getDQL(); + + // the SER prefix is stripped so a house counts once, however its code is spelled + $this->assertStringContainsString('SUBSTRING(destination.hotelCode, 4, 3)', $dql); + $this->assertStringContainsString('SUBSTRING(destination.hotelCode, 1, 3)', $dql); + $this->assertStringContainsString('GROUP BY hotelCode', $dql); + + // a destination lives on the assignment, not the application + $this->assertStringContainsString('INNER JOIN application.assignment assignment', $dql); + $this->assertStringContainsString('INNER JOIN assignment.destination destination', $dql); + } + + public function testApplicationsByDestinationBindsTheStatusBuckets(): void + { + $query = $this->createApplicationsByDestinationQuery(); + + $this->assertSame(Application::STATUS_NEW, $query->getParameter('statusNew')->getValue()); + $this->assertSame(Application::STATUS_PENDING, $query->getParameter('statusPending')->getValue()); + $this->assertSame(Application::STATUS_REJECTED, $query->getParameter('statusRejected')->getValue()); + } + + public function testApplicationsByDestinationAppliesTheSeasonBoundsOnlyWhenGiven(): void + { + $unfiltered = $this->createApplicationsByDestinationQuery(); + $this->assertNull($unfiltered->getParameter('dateFrom')); + $this->assertNull($unfiltered->getParameter('dateTo')); + + $filtered = $this->createApplicationsByDestinationQuery( + new \DateTimeImmutable('2025-05-01'), + new \DateTimeImmutable('2025-09-30'), + ); + $dql = $filtered->getDQL(); + + // the season is the destination's date range, same as every other statistics screen + $this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql); + $this->assertStringContainsString('destination.dateTo <= :dateTo', $dql); + $this->assertSame('2025-05-01', $filtered->getParameter('dateFrom')->getValue()->format('Y-m-d')); + $this->assertSame('2025-09-30', $filtered->getParameter('dateTo')->getValue()->format('Y-m-d')); + } + + private function createApplicationsByDestinationQuery( + ?\DateTimeImmutable $dateFrom = null, + ?\DateTimeImmutable $dateTo = null, + ): Query { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var ApplicationRepository $repository */ + $repository = $entityManager->getRepository(Application::class); + + return $repository->getApplicationsByDestinationQuery($dateFrom, $dateTo); + } }