feat: all-season assignments
This commit is contained in:
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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user