diff --git a/config/services.yaml b/config/services.yaml index 2c09a95..d09d0cf 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -61,6 +61,13 @@ parameters: - '04-01' - '10-01' + # The season window as MM-DD. It wraps the turn of the year and is named after the + # calendar year it starts in ("Saison 2024/25"). The teamer info modal buckets a + # teamer's dispositions by the season their assignment starts in; one whose + # assignment starts outside this window is counted as "außerhalb der Saison". + season_start: '11-01' + season_end: '04-01' + # Houses, hotel code: name. The name is used both as a label and to match # destination.hotel, the code to match destination.hotelCode. houses: @@ -104,6 +111,8 @@ services: # App\Config\EmailTextCatalog, since its contents matter on few requests. $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' $mailingBusTransport: '%mailing_bus_transport%' + $seasonStart: '%season_start%' + $seasonEnd: '%season_end%' # Tagged by interface rather than listed by hand: order between collectors is # irrelevant, so the only thing an explicit list could add here is the chance to diff --git a/src/Config/SeasonCalendar.php b/src/Config/SeasonCalendar.php new file mode 100644 index 0000000..484330b --- /dev/null +++ b/src/Config/SeasonCalendar.php @@ -0,0 +1,81 @@ +startOfDay(); + $year = (int) $day->format('Y'); + + $start = $this->boundaryFor($this->seasonStart, $year); + $end = $this->boundaryFor($this->seasonEnd, $year); + + // Wrapping window (the default 01.11.–01.04. case): the season straddles New Year. + if ($end < $start) { + if ($day >= $start) { + return $year; + } + + if ($day <= $end) { + return $year - 1; + } + + return null; + } + + // Non-wrapping window: kept correct should the config ever describe a window that + // stays within one calendar year. + return $day >= $start && $day <= $end ? $year : null; + } + + /** + * null -> "außerhalb der Saison"; 2024 -> "Saison 2024/25". + */ + public function label(?int $seasonYear): string + { + if (null === $seasonYear) { + return 'außerhalb der Saison'; + } + + return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100); + } + + private function boundaryFor(string $monthDay, int $year): CarbonImmutable + { + $boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay)); + + if (false === $boundary instanceof CarbonImmutable) { + throw new \InvalidArgumentException(sprintf('Season boundary "%s" is not a valid MM-DD date.', $monthDay)); + } + + return $boundary->startOfDay(); + } +} diff --git a/src/Controller/Administrative/Teamer/InfoController.php b/src/Controller/Administrative/Teamer/InfoController.php index 44a7476..855b032 100644 --- a/src/Controller/Administrative/Teamer/InfoController.php +++ b/src/Controller/Administrative/Teamer/InfoController.php @@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Teamer; use App\Controller\Traits\ReturnUrlTrait; use App\Entity\Teamer; use App\Repository\DispositionRepository; +use App\Service\Teamer\SeasonDispositionCounter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\Request; @@ -16,8 +17,10 @@ class InfoController extends AbstractController { use ReturnUrlTrait; - public function __construct(private readonly DispositionRepository $dispositionRepository) - { + public function __construct( + private readonly DispositionRepository $dispositionRepository, + private readonly SeasonDispositionCounter $seasonDispositionCounter, + ) { } #[Route('/administrative/teamer/info/{uuid}', name: 'app_administrative_teamer_info')] @@ -37,6 +40,7 @@ class InfoController extends AbstractController return $this->render('administrative/teamer/modal_info.html.twig', [ 'teamer' => $teamer, 'recentDispositions' => $recentDispositions, + 'dispositionsPerSeason' => $this->seasonDispositionCounter->countPerSeason($teamer), 'returnUrl' => $returnUrl, ]); } diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index a07c323..e022854 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -144,6 +144,37 @@ class DispositionRepository extends ServiceEntityRepository ; } + /** + * The effective start date of every non-called-off disposition of a teamer, one row + * each, for the per-season tally in the info modal. + * + * Same teamer/called-off filtering as findRecentDispositionsByTeamer(), but unbounded + * and stripped to the one date it needs: assignment.dateFrom falling back to the + * destination's, the season convention shared with SeasonPeriodFilter. + * + * @return array + */ + public function findDispositionSeasonDatesByTeamer(Teamer $teamer): array + { + $qb = $this->createQueryBuilder('disposition'); + + return $qb + ->select('COALESCE(assignment.dateFrom, destination.dateFrom) AS seasonDate') + ->innerJoin('disposition.assignment', 'assignment') + ->innerJoin('assignment.destination', 'destination') + ->where($qb->expr()->andX( + $qb->expr()->eq('disposition.teamer', ':teamer'), + $qb->expr()->neq('assignment.status', ':assignmentStatus'), + $qb->expr()->neq('disposition.status', ':dispositionStatus') + )) + ->setParameter('teamer', $teamer) + ->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF) + ->setParameter('dispositionStatus', Disposition::STATUS_CALLED_OFF) + ->getQuery() + ->getScalarResult() + ; + } + public function findCurrentDispositionsByTeamer(Teamer $teamer): array { $qb = $this->createQueryBuilder('disposition'); diff --git a/src/Service/Teamer/SeasonDispositionCounter.php b/src/Service/Teamer/SeasonDispositionCounter.php new file mode 100644 index 0000000..fd80ee3 --- /dev/null +++ b/src/Service/Teamer/SeasonDispositionCounter.php @@ -0,0 +1,58 @@ + newest season first, off-season last + */ + public function countPerSeason(Teamer $teamer): array + { + $counts = []; + + foreach ($this->dispositionRepository->findDispositionSeasonDatesByTeamer($teamer) as $row) { + if (empty($row['seasonDate'])) { + continue; + } + + $seasonYear = $this->seasonCalendar->seasonYearFor(new \DateTimeImmutable($row['seasonDate'])); + $key = $seasonYear ?? self::OFF_SEASON; + $counts[$key] = ($counts[$key] ?? 0) + 1; + } + + $offSeasonCount = $counts[self::OFF_SEASON] ?? null; + unset($counts[self::OFF_SEASON]); + krsort($counts); + + $rows = []; + foreach ($counts as $seasonYear => $count) { + $rows[] = ['label' => $this->seasonCalendar->label((int) $seasonYear), 'count' => $count]; + } + + if (null !== $offSeasonCount) { + $rows[] = ['label' => $this->seasonCalendar->label(null), 'count' => $offSeasonCount]; + } + + return $rows; + } +} diff --git a/templates/administrative/teamer/modal_info.html.twig b/templates/administrative/teamer/modal_info.html.twig index 8fd1df7..8f63a70 100644 --- a/templates/administrative/teamer/modal_info.html.twig +++ b/templates/administrative/teamer/modal_info.html.twig @@ -65,6 +65,21 @@ {% endfor %} +

+ Einsätze pro Saison +

+ {% if is_granted('ROLE_ADMIN') %}

Interne Anmerkungen diff --git a/tests/Config/SeasonCalendarTest.php b/tests/Config/SeasonCalendarTest.php new file mode 100644 index 0000000..308b2e1 --- /dev/null +++ b/tests/Config/SeasonCalendarTest.php @@ -0,0 +1,73 @@ +assertSame($expected, $this->winter()->seasonYearFor(new \DateTimeImmutable($date))); + } + + public static function winterDates(): iterable + { + yield 'first day of the season' => ['2024-11-01', 2024]; + yield 'december, same calendar year' => ['2024-12-20', 2024]; + yield 'february, next calendar year' => ['2025-02-10', 2024]; + yield 'last day of the season is inclusive' => ['2025-04-01', 2024]; + yield 'time of day does not matter' => ['2025-04-01 23:59:00', 2024]; + yield 'day after the season ends' => ['2025-04-02', null]; + yield 'high summer' => ['2025-07-15', null]; + yield 'day before the next season starts' => ['2025-10-31', null]; + yield 'start of the following season' => ['2025-11-01', 2025]; + } + + public function testLabel(): void + { + $calendar = $this->winter(); + + $this->assertSame('Saison 2024/25', $calendar->label(2024)); + $this->assertSame('Saison 2009/10', $calendar->label(2009)); + $this->assertSame('außerhalb der Saison', $calendar->label(null)); + } + + /** + * @dataProvider summerDates + */ + public function testSeasonYearForANonWrappingWindow(string $date, ?int $expected): void + { + $calendar = new SeasonCalendar('06-01', '09-01'); + + $this->assertSame($expected, $calendar->seasonYearFor(new \DateTimeImmutable($date))); + } + + public static function summerDates(): iterable + { + yield 'before the window' => ['2025-05-31', null]; + yield 'first day' => ['2025-06-01', 2025]; + yield 'inside' => ['2025-07-15', 2025]; + yield 'last day' => ['2025-09-01', 2025]; + yield 'after the window' => ['2025-09-02', null]; + yield 'turn of the year is not in season' => ['2025-01-01', null]; + } + + public function testAnInvalidBoundaryIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + (new SeasonCalendar('xx-yy', '04-01'))->seasonYearFor(new \DateTimeImmutable('2025-01-01')); + } +} diff --git a/tests/Service/Teamer/SeasonDispositionCounterTest.php b/tests/Service/Teamer/SeasonDispositionCounterTest.php new file mode 100644 index 0000000..c9353d5 --- /dev/null +++ b/tests/Service/Teamer/SeasonDispositionCounterTest.php @@ -0,0 +1,71 @@ +dispositionRepository = $this->createMock(DispositionRepository::class); + + $this->counter = new SeasonDispositionCounter( + $this->dispositionRepository, + new SeasonCalendar('11-01', '04-01'), + ); + } + + public function testCountsAreGroupedPerSeasonNewestFirstWithOffSeasonLast(): void + { + $this->stubDates([ + '2024-12-10', // Saison 2024/25 + '2025-02-01', // Saison 2024/25 + '2024-01-15', // Saison 2023/24 + '2025-07-20', // außerhalb der Saison + ]); + + $this->assertSame([ + ['label' => 'Saison 2024/25', 'count' => 2], + ['label' => 'Saison 2023/24', 'count' => 1], + ['label' => 'außerhalb der Saison', 'count' => 1], + ], $this->counter->countPerSeason(new Teamer())); + } + + public function testATeamerWithNoDispositionsYieldsNoRows(): void + { + $this->stubDates([]); + + $this->assertSame([], $this->counter->countPerSeason(new Teamer())); + } + + public function testOnlyOffSeasonDispositionsYieldASingleRow(): void + { + $this->stubDates(['2025-05-01', '2025-09-30']); + + $this->assertSame([ + ['label' => 'außerhalb der Saison', 'count' => 2], + ], $this->counter->countPerSeason(new Teamer())); + } + + /** + * @param list $dates + */ + private function stubDates(array $dates): void + { + $this->dispositionRepository + ->method('findDispositionSeasonDatesByTeamer') + ->willReturn(array_map(static fn (string $date): array => ['seasonDate' => $date], $dates)) + ; + } +}