feat: all-season assignments

This commit is contained in:
Björn Fromme authored and Björn Fromme committed 2026-09-26 12:13:16 +02:00
1 parent 675cec1b69
commit cf876ed224
119 files changed
+6194 -294

No files matched your search

+1
View File
@@ -22,4 +22,5 @@ enum EmailTextKey: string
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
case REMINDER_DISPOSITION = 'reminder_disposition';
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
}
+48 -10
View File
@@ -57,23 +57,52 @@ final class SeasonCalendar
}
/**
* The season a mail written now would call "die kommende Saison": the one currently running,
* or - between two seasons - the next one to start. Never null, unlike seasonYearFor(): a
* recruiting mail sent in September is outside every season window but is unmistakably about
* the winter ahead.
* The season that is running today, or - when today lies outside any window - the one that
* starts next.
*
* The fall-forward is what keeps the teamer's season page reachable all year: without it the
* page would disappear between 02.04. and 31.10. for the default window, which covers the
* default first deadline of 01.10. for a season starting 01.11. - teamers would be reminded
* about documents with nowhere to upload them.
*/
public function currentOrUpcomingSeasonYear(\DateTimeInterface $date): int
public function currentOrUpcomingSeasonYear(?\DateTimeInterface $date = null): int
{
if (null !== $seasonYear = $this->seasonYearFor($date)) {
$day = CarbonImmutable::instance($date ?? new \DateTimeImmutable())->startOfDay();
if (null !== $seasonYear = $this->seasonYearFor($day)) {
return $seasonYear;
}
$day = CarbonImmutable::instance($date)->startOfDay();
$year = (int) $day->format('Y');
// Outside any window, so the next start is this year's unless it has already passed,
// which for a wrapping window can only mean the season already began - handled above.
return $day <= $this->boundaryFor($this->seasonStart, $year) ? $year : $year + 1;
// Off-season, so the next window is the one whose start still lies ahead. The candidates
// bracket the turn of the year, which is all a MM-DD window can ever straddle.
foreach ([$year - 1, $year, $year + 1] as $candidate) {
if ($this->boundaryFor($this->seasonStart, $candidate) > $day) {
return $candidate;
}
}
throw new \LogicException(sprintf('No season starts after %s.', $day->format('Y-m-d')));
}
/**
* The inclusive first and last day of a season, for queries that cannot call seasonYearFor()
* per row - which is every query, since DQL knows nothing about this class.
*
* @return array{CarbonImmutable, CarbonImmutable}
*/
public function windowFor(int $seasonYear): array
{
$start = $this->boundaryFor($this->seasonStart, $seasonYear);
$end = $this->boundaryFor($this->seasonEnd, $seasonYear);
// Wrapping window: the season ends in the following calendar year.
if ($end < $start) {
$end = $this->boundaryFor($this->seasonEnd, $seasonYear + 1);
}
return [$start, $end];
}
/**
@@ -97,6 +126,15 @@ final class SeasonCalendar
return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100);
}
/**
* The season label for a date, for the common case of "which season does this belong to" -
* "?" when the date is not known yet, which a half-filled create form can be in.
*/
public function labelFor(?\DateTimeInterface $date): string
{
return null === $date ? '?' : $this->label($this->seasonYearFor($date));
}
private function boundaryFor(string $monthDay, int $year): CarbonImmutable
{
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Config;
/**
* The formalities the office works through for every season assignment.
*
* This enum is the single source of truth: the entries themselves, their German labels, their
* order and both of their properties live here and nowhere else. Rows in season_assignment_task
* only carry state, and App\Service\Season\SeasonTaskSynchronizer creates the ones an assignment
* is missing - so adding a case here is all it takes to add an entry everywhere, including on
* assignments that already exist.
*
* Removing a case leaves its rows behind on purpose. Every read resolves a stored key through
* tryFrom() and skips what no longer exists, which keeps the history recoverable instead of
* deleting it.
*/
enum SeasonTask: string
{
case MASTER_DATA = 'master_data';
case VEHICLE = 'vehicle';
case CONTRACT = 'contract';
case ADDITIONAL_CONTRACT = 'additional_contract';
case SAAS_REGISTERED = 'saas_registered';
case MUNICIPALITY_REGISTERED = 'municipality_registered';
public function label(): string
{
return match ($this) {
self::MASTER_DATA => 'Stammdaten',
self::VEHICLE => 'KfZ',
self::CONTRACT => 'Vertrag',
self::ADDITIONAL_CONTRACT => 'Zusatzvertrag',
self::SAAS_REGISTERED => 'SaaS angemeldet',
self::MUNICIPALITY_REGISTERED => 'Gemeinde angemeldet',
};
}
public function provides(): SeasonTaskProvision
{
return match ($this) {
self::MASTER_DATA, self::VEHICLE => SeasonTaskProvision::STANDARD,
self::CONTRACT, self::ADDITIONAL_CONTRACT => SeasonTaskProvision::INDIVIDUAL,
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => SeasonTaskProvision::NONE,
};
}
/**
* Whether the teamer has to send something back. This is what puts a task on the teamer's
* own page and into their weekly reminder; everything else is office-internal.
*/
public function requiresReturn(): bool
{
return match ($this) {
self::MASTER_DATA, self::VEHICLE, self::CONTRACT, self::ADDITIONAL_CONTRACT => true,
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => false,
};
}
/**
* @return self[]
*/
public static function requiringReturn(): array
{
return array_values(array_filter(
self::cases(),
static fn (self $task): bool => true === $task->requiresReturn()
));
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Config;
/**
* What the office hands out for a season task.
*
* NONE nothing is issued, the task is either a pure return or an internal check
* INDIVIDUAL a document produced for this one teamer and uploaded onto their season assignment
* STANDARD the same document for everyone, taken from the global document library
*/
enum SeasonTaskProvision: string
{
case NONE = 'none';
case INDIVIDUAL = 'individual';
case STANDARD = 'standard';
}