feat: additional functionality and layout adjustments for season teamers

This commit is contained in:
fromme committed 2026-09-26 12:13:16 +02:00
1 parent 80c6016da3
commit 4cab57d136
30 files changed
+916 -192

No files matched your search

@@ -18,9 +18,8 @@ use Psr\Log\LoggerInterface;
* 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.
* Only "done" stops either of them. An arriving file does not, and neither does the "office done"
* tick, which is a note for the office and gates nothing.
*/
class SeasonTaskReminderService
{
+46 -19
View File
@@ -2,26 +2,26 @@
namespace App\Service\Season;
use App\Config\SeasonChecklistStatus;
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.
* deadlines and the document the row was given when it was created.
*
* 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.
* The document comes off the row, never out of the library: the library is what the next
* assignments get, and replacing a file there must not change what an assignment that already
* exists hands out.
*
* Shared by the detail page and the htmx toggle, which re-renders the checklist after a tick and
* has to arrive at exactly the same rows.
*/
class SeasonChecklistBuilder
{
public function __construct(
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
) {
public function __construct(private readonly SeasonTaskDeadlineResolver $deadlineResolver)
{
}
/**
@@ -40,8 +40,6 @@ class SeasonChecklistBuilder
}
}
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
$rows = [];
foreach (SeasonTask::cases() as $case) {
@@ -49,16 +47,49 @@ class SeasonChecklistBuilder
continue;
}
$rows[] = $this->buildRow($tasks[$case->value], $documents[$case->value] ?? null);
$rows[] = $this->buildRow($tasks[$case->value]);
}
return $rows;
}
/**
* How far the checklist has been worked through. Rows whose key no longer resolves are
* skipped here just like in build(): the entry they belong to was removed from the enum, and
* counting it would leave the assignment permanently unfinished.
*/
public function status(SeasonAssignment $seasonAssignment): SeasonChecklistStatus
{
$open = 0;
$openWithReturn = 0;
foreach ($seasonAssignment->getTasks() as $task) {
if (null === $definition = $task->getTask()) {
continue;
}
if ($task->isDone()) {
continue;
}
++$open;
if ($definition->requiresReturn()) {
++$openWithReturn;
}
}
return match (true) {
0 === $open => SeasonChecklistStatus::COMPLETE,
0 === $openWithReturn => SeasonChecklistStatus::RETURNS_COMPLETE,
default => SeasonChecklistStatus::OPEN,
};
}
/**
* @return array<string, mixed>
*/
public function buildRow(SeasonAssignmentTask $task, ?SeasonTaskDocument $seasonTaskDocument = null): array
private function buildRow(SeasonAssignmentTask $task): array
{
$definition = $task->getTask();
@@ -66,16 +97,12 @@ class SeasonChecklistBuilder
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(),
'document' => $task->getDocument(),
];
}
}
+38 -10
View File
@@ -3,8 +3,10 @@
namespace App\Service\Season;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Repository\SeasonTaskDocumentRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
@@ -18,11 +20,18 @@ use Doctrine\ORM\EntityManagerInterface;
* 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.
*
* Creating is also the only point at which the library is read: the document is copied onto the
* row and the office tick set with it. Both are a starting state, not a rule, so neither is
* undone by a later sync - a deposited file reaches an assignment that already exists only
* through the propagate action.
*/
class SeasonTaskSynchronizer
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
) {
}
public function sync(SeasonAssignment $seasonAssignment): void
@@ -33,23 +42,42 @@ class SeasonTaskSynchronizer
$existing[$task->getTaskKey()] = true;
}
$created = false;
$missing = [];
foreach (SeasonTask::cases() as $case) {
if (true === isset($existing[$case->value])) {
continue;
if (false === isset($existing[$case->value])) {
$missing[] = $case;
}
}
if ([] === $missing) {
return;
}
// Queried once, and only when there is something to create: sync() runs on every render
// of the administrative detail page, where nothing is usually missing.
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
foreach ($missing as $case) {
$task = (new SeasonAssignmentTask())->setTaskKey($case->value);
// The library holds what the next assignments get and is replaced from season to
// season, so the file is copied onto the row here instead of being resolved on every
// read. What an assignment hands out is decided once, when it is laid out.
if (SeasonTaskProvision::STANDARD === $case->provides() && true === isset($documents[$case->value])) {
// The office step is covered the moment the row has its document: there is
// nothing left to put together for this teamer. The checkbox stays, so it can
// be unticked.
$task
->setDocument($documents[$case->value]->getDocument())
->setOfficeDone(true)
;
}
$seasonAssignment->addTask($task);
$this->entityManager->persist($task);
$created = true;
}
if (true === $created) {
$this->entityManager->flush();
}
$this->entityManager->flush();
}
}