feat: teamer season disposition count in info modal
addresses #869dv94n4
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user