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
+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;
}
}