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

@@ -0,0 +1,54 @@
<?php
namespace App\Service\Common;
use App\Entity\JobProfile;
use App\Model\AbstractFilterDto;
use App\Model\SeasonAssignmentFilterDto;
class SeasonAssignmentFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:season_assignment';
protected string $modelClass = SeasonAssignmentFilterDto::class;
public function getFilterSettings(): SeasonAssignmentFilterDto
{
$filterDto = new $this->modelClass();
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
$filterDto
->setTeamerName($data['teamer_name'])
// ?? []: sessions written before the houses filter replaced the destination one
// still carry the old payload under this same namespace
->setHotelCodes($data['hotel_codes'] ?? [])
->setIncludeDeleted((bool) ($data['include_deleted'] ?? false))
;
if (0 < count($data['job_profiles'])) {
$jobProfiles = $this
->entityManager
->getRepository(JobProfile::class)
->findBy(['id' => $data['job_profiles']])
;
$filterDto->setJobProfiles($jobProfiles);
}
return $filterDto;
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/* @var SeasonAssignmentFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'teamer_name' => $filterDto->getTeamerName(),
'hotel_codes' => $filterDto->getHotelCodes(),
'include_deleted' => $filterDto->isIncludeDeleted(),
'job_profiles' => array_map(function (JobProfile $jobProfile) {
return $jobProfile->getId();
}, $filterDto->getJobProfiles()),
]);
}
}
+31 -1
View File
@@ -8,13 +8,43 @@ use App\Model\TeamerFilterDto;
class TeamerFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:teamer';
/**
* The value the teamer lists pass around as a 'scope' query parameter to say which
* of the two they are working on.
*/
public const SCOPE_ALL_SEASON = 'all_season';
public const NAMESPACE_DEFAULT = 'filter:teamer';
/**
* The all-season list keeps its own session bucket so filtering one teamer list
* never silently changes what the other one shows.
*/
public const NAMESPACE_ALL_SEASON = 'filter:teamer:all_season';
protected string $namespace = self::NAMESPACE_DEFAULT;
protected string $modelClass = TeamerFilterDto::class;
/**
* Point the handler at the list the request belongs to. Every caller reading teamer
* filter settings has to do this first - the mailing included, otherwise it would
* resolve its recipients from the other list's filter.
*/
public function setScope(?string $scope): static
{
return $this->setNamespace(self::SCOPE_ALL_SEASON === $scope
? self::NAMESPACE_ALL_SEASON
: self::NAMESPACE_DEFAULT);
}
public function getFilterSettings(): TeamerFilterDto
{
$filterDto = new $this->modelClass();
// the bucket the settings live in is what makes a list an all-season list, so the
// scope is derived here rather than left to each caller to remember
$filterDto->setAllSeason(self::NAMESPACE_ALL_SEASON === $this->namespace);
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
@@ -0,0 +1,189 @@
<?php
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Config\SeasonCalendar;
use App\Email\Mailer;
use App\Entity\SeasonAssignmentTask;
use App\Repository\SeasonAssignmentTaskRepository;
use App\Repository\UserRepository;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Season\SeasonTaskDeadlineResolver;
use Psr\Log\LoggerInterface;
/**
* The two reminders around the season checklist: one to the office when a deadline is reached,
* one to the teamer while they still owe us something.
*
* Both are idempotent without recording anything, like UploadReminderService: the office summary
* picks out exactly one day per deadline, and the teamer digest picks out exactly one weekday.
*
* Only "done" stops either of them. An arriving file does not, and neither does the "uploaded"
* tick, which is a note for the office and gates nothing. Neither does doneTeamer on the
* assignment: that flag is informative only.
*/
class SeasonTaskReminderService
{
private const DIGEST_WEEKDAY = 1;
public function __construct(
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
private readonly SeasonAssignmentLabeler $labeler,
private readonly SeasonCalendar $seasonCalendar,
private readonly UserRepository $userRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
/**
* One mail to the office listing every entry whose deadline is reached today.
*
* Frist 2 defaults to Frist 1, so the two normally fall on the same day. A row is reported
* once in that case and reads as Frist 1: a second, identical line would be noise, and the
* office only pushes the second deadline out deliberately.
*/
public function sendDeadlineSummary(?\DateTimeImmutable $today = null): string
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
$violations = [];
foreach ($this->seasonAssignmentTaskRepository->findOpen() as $task) {
if (null === $level = $this->levelDueOn($task, $today)) {
continue;
}
$teamer = (string) $task->getSeasonAssignment()?->getTeamer();
$violations[$teamer][] = [
'task' => $task,
'label' => $task->getTask()?->label() ?? $task->getTaskKey(),
'level' => $level,
];
}
if (0 === $count = count($violations)) {
$message = 'No season task deadlines reached today';
$this->logger->info($message);
return $message;
}
// the two roles that carry ROLE_ADMINISTRATIVE: the repository matches roles as they are
// stored on the user, and nobody is ever stored with the hierarchy-granted one
foreach ($this->userRepository->getUsersByRoles(['ROLE_TEAM_ADMIN', 'ROLE_MANAGER']) as $user) {
if (true === $user->isMuteNotifications()) {
continue;
}
$this->mailer->createAndSendEmail([
'violations' => $violations,
], [
'to' => $user->getEmail(),
'subject' => 'Fristen für Saisonunterlagen erreicht',
'template' => 'email/season_deadlines.html.twig',
]);
}
$message = 'Sent the season task deadline summary for '.$count.' teamers';
$this->logger->info($message);
return $message;
}
/**
* One mail per teamer, every Monday, listing what they still have to send us. It repeats
* until an administrative user ticks the entry off; it is not tied to a deadline at all.
*/
public function sendTeamerDigest(?\DateTimeImmutable $today = null): string
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
$message = 'Season task digests are only sent on mondays';
$this->logger->info($message);
return $message;
}
// Scoped to the season being prepared, so a teamer is never chased about a season that
// is long over.
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor(
$this->seasonCalendar->currentOrUpcomingSeasonYear($today)
);
$open = $this->seasonAssignmentTaskRepository->findOpen(
$windowStart->toDateTimeImmutable(),
$windowEnd->toDateTimeImmutable()
);
$perTeamer = [];
foreach ($open as $task) {
$seasonAssignment = $task->getSeasonAssignment();
if (null === $seasonAssignment) {
continue;
}
// office-internal entries never reach the teamer
if (true !== $task->getTask()?->requiresReturn()) {
continue;
}
$perTeamer[$seasonAssignment->getId()]['assignment'] = $seasonAssignment;
$perTeamer[$seasonAssignment->getId()]['labels'][] = $task->getTask()->label();
}
$count = 0;
foreach ($perTeamer as $entry) {
$teamer = $entry['assignment']->getTeamer();
$email = $teamer?->getCommunication()?->getEmail();
if (null === $email) {
continue;
}
$this->mailer->createAndSendText(EmailTextKey::REMINDER_SEASON_TASKS, [
// the placeholder keeps its name: admins can have edited this text in the
// database, and renaming it would blank it out in those stored versions
'destination' => $this->labeler->label($entry['assignment']),
'tasks' => implode(', ', $entry['labels']),
], [
'to' => $email,
]);
++$count;
}
$message = 'Sent '.$count.' season task digests to teamers';
$this->logger->info($message);
return $message;
}
/**
* Which deadline of this entry falls on $day, or null for none. Frist 1 wins when both do.
*/
private function levelDueOn(SeasonAssignmentTask $task, \DateTimeImmutable $day): ?int
{
if ($this->isSameDay($this->deadlineResolver->resolveFirst($task), $day)) {
return 1;
}
if ($this->isSameDay($this->deadlineResolver->resolveSecond($task), $day)) {
return 2;
}
return null;
}
private function isSameDay(?\DateTimeImmutable $deadline, \DateTimeImmutable $day): bool
{
return null !== $deadline && $deadline->format('Y-m-d') === $day->format('Y-m-d');
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Service\Season;
use App\Config\HouseCatalog;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
/**
* How a season assignment names itself - "Saison 2026/27 - Rotbach".
*
* Not SeasonAssignment::__toString(): the name needs the configured season window and the
* configured houses map, neither of which an entity can reach. Everything that shows or logs a
* placement goes through here, so the list, the delete dialog, the audit log and the teamer mail
* cannot drift apart.
*/
class SeasonAssignmentLabeler
{
public function __construct(
private readonly SeasonCalendar $seasonCalendar,
private readonly HouseCatalog $houseCatalog,
) {
}
public function label(SeasonAssignment $seasonAssignment): string
{
return sprintf(
'%s - %s',
$this->seasonCalendar->labelFor($seasonAssignment->getDateFrom()),
$this->houseLabel($seasonAssignment->getHotelCode())
);
}
/**
* A code that has since been dropped from the houses map still has to name itself, so it
* falls back to the raw code rather than rendering an empty cell.
*/
public function houseLabel(?string $hotelCode): string
{
if (null === $hotelCode) {
return '?';
}
return $this->houseCatalog->getName($hotelCode) ?? $hotelCode;
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Service\Season;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Repository\SeasonAssignmentRepository;
use Psr\Log\LoggerInterface;
/**
* The season assignment a teamer's own page is about.
*
* Resolved by season rather than by date range, using SeasonCalendar - the same vocabulary the
* statistics screens use - and falling forward to the next season when today is off-season, so
* the page stays reachable while the documents for the coming season are already being chased.
*
* App\Validator\Constraints\SeasonAssignment allows only one per teamer and season, so at most
* one is expected here; the earliest is taken and a warning logged for rows that predate the
* rule.
*/
class SeasonAssignmentResolver
{
public function __construct(
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
private readonly SeasonCalendar $seasonCalendar,
private readonly LoggerInterface $logger,
) {
}
public function resolveFor(Teamer $teamer): ?SeasonAssignment
{
if (false === $teamer->isAllSeason()) {
return null;
}
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor(
$this->seasonCalendar->currentOrUpcomingSeasonYear()
);
$seasonAssignments = $this->seasonAssignmentRepository->findForTeamerInSeason(
$teamer,
$windowStart->toDateTimeImmutable(),
$windowEnd->toDateTimeImmutable()
);
if (0 === count($seasonAssignments)) {
return null;
}
if (1 < count($seasonAssignments)) {
$this->logger->warning('Multiple season assignments resolved for one teamer', [
'teamer_id' => $teamer->getId(),
'count' => count($seasonAssignments),
]);
}
return $seasonAssignments[0];
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Service\Season;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Entity\SeasonTaskDocument;
use App\Repository\SeasonTaskDocumentRepository;
/**
* Puts one checklist row together: the stored state, the entry it belongs to, both resolved
* deadlines and - for an entry handing out a standard document - the library file it points at.
*
* Shared by the detail page and the per-row htmx toggles, which re-render a single row and have
* to arrive at exactly the same one.
*/
class SeasonChecklistBuilder
{
public function __construct(
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
) {
}
/**
* Rows in enum order, which is the order the office works through them. Rows whose key no
* longer resolves are left out: the entry they belong to was removed from the enum.
*
* @return array<int, array<string, mixed>>
*/
public function build(SeasonAssignment $seasonAssignment): array
{
$tasks = [];
foreach ($seasonAssignment->getTasks() as $task) {
if (null !== $definition = $task->getTask()) {
$tasks[$definition->value] = $task;
}
}
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
$rows = [];
foreach (SeasonTask::cases() as $case) {
if (false === isset($tasks[$case->value])) {
continue;
}
$rows[] = $this->buildRow($tasks[$case->value], $documents[$case->value] ?? null);
}
return $rows;
}
/**
* @return array<string, mixed>
*/
public function buildRow(SeasonAssignmentTask $task, ?SeasonTaskDocument $seasonTaskDocument = null): array
{
$definition = $task->getTask();
if (null === $definition) {
throw new \InvalidArgumentException(sprintf('Task key "%s" is not defined.', $task->getTaskKey()));
}
if (null === $seasonTaskDocument && SeasonTaskProvision::STANDARD === $definition->provides()) {
$seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($definition);
}
return [
'task' => $task,
'definition' => $definition,
'deadline1' => $this->deadlineResolver->resolveFirst($task),
'deadline2' => $this->deadlineResolver->resolveSecond($task),
'document' => $seasonTaskDocument?->getDocument(),
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Service\Season;
use App\Entity\SeasonAssignmentTask;
/**
* The two deadlines of a checklist entry.
*
* Neither is stored: both are computed from the assignment's first day, so moving the placement's
* dates moves the deadlines with them instead of leaving them stale. A task row only carries the
* overrides, which is what "null" means there.
*
* Frist 2 defaults to Frist 1 and therefore follows an overridden Frist 1 as well - the office
* pushes the second deadline out deliberately or not at all.
*
* Reminders resolve through this class in PHP rather than reproducing the offset in DQL:
* "-1 month" is not invertible day by day (31.03. minus a month is 03.03., and that plus a
* month is 03.04.), so a query built by inverting the offset would silently miss tasks.
*/
class SeasonTaskDeadlineResolver
{
public function __construct(private readonly string $seasonTaskDeadlineOffset)
{
}
public function resolveFirst(SeasonAssignmentTask $task): ?\DateTimeImmutable
{
if (null !== $override = $task->getDeadlineOverride1()) {
return $override;
}
return $this->defaultDeadline($task);
}
public function resolveSecond(SeasonAssignmentTask $task): ?\DateTimeImmutable
{
if (null !== $override = $task->getDeadlineOverride2()) {
return $override;
}
return $this->resolveFirst($task);
}
/**
* Null only when the assignment has no start date yet, which the create form does not
* allow but a half-built object in memory can still be in.
*/
private function defaultDeadline(SeasonAssignmentTask $task): ?\DateTimeImmutable
{
$dateFrom = $task->getSeasonAssignment()?->getDateFrom();
return $dateFrom?->modify($this->seasonTaskDeadlineOffset);
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Service\Season;
use App\Config\SeasonTask;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use Doctrine\ORM\EntityManagerInterface;
/**
* Brings a season assignment's checklist rows in line with the entries defined in
* App\Config\SeasonTask.
*
* Called on write paths only - after an assignment is created, and at the top of the
* administrative detail controller before it renders - never lazily during an arbitrary GET, so
* no read path ends up flushing.
*
* It only ever creates. A case removed from the enum leaves its row behind: every read resolves
* the stored key through SeasonTask::tryFrom() and skips what no longer exists, which keeps a
* removed entry from erroring out and keeps its history recoverable.
*/
class SeasonTaskSynchronizer
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function sync(SeasonAssignment $seasonAssignment): void
{
$existing = [];
foreach ($seasonAssignment->getTasks() as $task) {
$existing[$task->getTaskKey()] = true;
}
$created = false;
foreach (SeasonTask::cases() as $case) {
if (true === isset($existing[$case->value])) {
continue;
}
$task = (new SeasonAssignmentTask())->setTaskKey($case->value);
$seasonAssignment->addTask($task);
$this->entityManager->persist($task);
$created = true;
}
if (true === $created) {
$this->entityManager->flush();
}
}
}