feat: teamer season disposition count in info modal

addresses #869dv94n4
This commit is contained in:
2026-08-31 16:26:53 +02:00
parent 39e6f35374
commit 86c5d23181
8 changed files with 344 additions and 2 deletions
+9
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Config;
use Carbon\CarbonImmutable;
/**
* The recurring season window, as configured by the "season_start"/"season_end" parameters.
*
* A season is a MM-DD..MM-DD window that normally wraps the turn of the year (01.11.01.04.)
* and is named after the calendar year it starts in - "Saison 2024/25". It is used to bucket
* a teamer's dispositions per season in the info modal; the date that decides the bucket is
* the assignment's effective start, mirroring App\Repository\Filter\SeasonPeriodFilter.
*
* Both ends are inclusive: an assignment starting on 01.04. still belongs to the season that
* began the previous 01.11. A start date between the two (02.04.31.10. for the default
* window) belongs to no season and is reported separately.
*/
final class SeasonCalendar
{
public function __construct(
private readonly string $seasonStart,
private readonly string $seasonEnd,
) {
}
/**
* The calendar year the season containing $date starts in, or null when $date lies
* outside any season window.
*/
public function seasonYearFor(\DateTimeInterface $date): ?int
{
$day = CarbonImmutable::instance($date)->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();
}
}
@@ -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,
]);
}
+31
View File
@@ -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<int, array{seasonDate: string}>
*/
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');
@@ -0,0 +1,58 @@
<?php
namespace App\Service\Teamer;
use App\Config\SeasonCalendar;
use App\Entity\Teamer;
use App\Repository\DispositionRepository;
/**
* Tallies a teamer's dispositions per season for the info modal.
*
* Each non-called-off disposition is bucketed by the season its assignment starts in
* (see SeasonCalendar); assignments starting outside any season window land in a single
* "außerhalb der Saison" bucket that is always listed last.
*/
class SeasonDispositionCounter
{
private const OFF_SEASON = 'off_season';
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly SeasonCalendar $seasonCalendar,
) {
}
/**
* @return list<array{label: string, count: int}> 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;
}
}
@@ -65,6 +65,21 @@
</li>
{% endfor %}
</ul>
<h2 class="text-lg font-bold">
Einsätze pro Saison
</h2>
<ul class="pb-4 divide-y divide-gray-200">
{% for season in dispositionsPerSeason %}
<li class="py-1 flex justify-between">
<span>{{ season.label }}</span>
<span>{{ season.count }}</span>
</li>
{% else %}
<li class="py-2">
-
</li>
{% endfor %}
</ul>
{% if is_granted('ROLE_ADMIN') %}
<h2 class="text-lg font-bold">
Interne Anmerkungen
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\SeasonCalendar;
use PHPUnit\Framework\TestCase;
class SeasonCalendarTest extends TestCase
{
private function winter(): SeasonCalendar
{
return new SeasonCalendar('11-01', '04-01');
}
/**
* @dataProvider winterDates
*/
public function testSeasonYearForTheWrappingWinterWindow(string $date, ?int $expected): void
{
$this->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'));
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Teamer;
use App\Config\SeasonCalendar;
use App\Entity\Teamer;
use App\Repository\DispositionRepository;
use App\Service\Teamer\SeasonDispositionCounter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SeasonDispositionCounterTest extends TestCase
{
private DispositionRepository&MockObject $dispositionRepository;
private SeasonDispositionCounter $counter;
protected function setUp(): void
{
$this->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<string> $dates
*/
private function stubDates(array $dates): void
{
$this->dispositionRepository
->method('findDispositionSeasonDatesByTeamer')
->willReturn(array_map(static fn (string $date): array => ['seasonDate' => $date], $dates))
;
}
}