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

+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Config;
/**
* How far the office has worked through one season assignment's checklist.
*
* Derived from the rows by App\Service\Season\SeasonChecklistBuilder, never stored: the two
* completed stages follow from the entries themselves, so a column would only be a second,
* hand-maintained copy of what the rows already say.
*/
enum SeasonChecklistStatus: string
{
case OPEN = 'open';
/**
* Everything the teamer has to send back has arrived and been ticked off, so the weekly
* digest has nothing left to chase. Office-internal entries can still be open.
*/
case RETURNS_COMPLETE = 'returns_complete';
case COMPLETE = 'complete';
}
@@ -33,6 +33,7 @@ class DetailController extends AbstractController
return $this->render('administrative/season_assignment/detail.html.twig', [
'seasonAssignment' => $seasonAssignment,
'checklist' => $this->checklistBuilder->build($seasonAssignment),
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_season_assignment_index'),
]);
}
@@ -1,50 +0,0 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Toggles the office's note that it considers this teamer done.
*
* Informative only: it silences nothing. The weekly teamer digest and the office deadline alerts
* both keep running while a return-required checklist entry is open. Administrative users only -
* there is no teamer-side write path.
*/
class DoneTeamerController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/{uuid}/done-teamer',
name: 'app_administrative_season_assignment_done_teamer',
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment): Response
{
$seasonAssignment->setDoneTeamer(false === $seasonAssignment->isDoneTeamer());
$this->entityManager->flush();
$this->logger->info('Toggle season assignment done teamer', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'value' => $seasonAssignment->isDoneTeamer(),
]);
return $this->render('administrative/season_assignment/_done_teamer.html.twig', [
'seasonAssignment' => $seasonAssignment,
]);
}
}
@@ -12,16 +12,19 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Ticks one checklist entry, re-rendering that single row.
* Ticks one checklist entry and re-renders the checklist.
*
* Deliberately one request per row rather than one form for the whole checklist: the office
* Deliberately one request per tick rather than one form for the whole checklist: the office
* works through the list one line at a time while reading a document, and a whole-form submit
* would let two people working the same assignment overwrite each other's ticks.
* would let two people working the same assignment overwrite each other's ticks. The answer
* still carries the whole checklist, because the status box below the table is derived from
* every row at once - and a tick re-rendered from the database also picks up what a colleague
* has ticked in the meantime.
*/
class TaskToggleController extends AbstractController
{
private const FIELD_DONE = 'done';
private const FIELD_UPLOADED = 'uploaded';
private const FIELD_OFFICE_DONE = 'office_done';
public function __construct(
private readonly EntityManagerInterface $entityManager,
@@ -35,7 +38,7 @@ class TaskToggleController extends AbstractController
name: 'app_administrative_season_assignment_task_toggle',
// pinned in the route as well as checked below, so this can never shadow the sibling
// .../task/{uuid}/deadline route whichever order the two are loaded in
requirements: ['field' => 'done|uploaded'],
requirements: ['field' => 'done|office_done'],
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
@@ -43,7 +46,7 @@ class TaskToggleController extends AbstractController
{
// matched against the two names rather than turned into a property path: the field comes
// out of the URL
if (false === in_array($field, [self::FIELD_DONE, self::FIELD_UPLOADED], true)) {
if (false === in_array($field, [self::FIELD_DONE, self::FIELD_OFFICE_DONE], true)) {
throw $this->createNotFoundException(sprintf('Unknown checklist field "%s".', $field));
}
@@ -58,7 +61,7 @@ class TaskToggleController extends AbstractController
->setDoneBy(true === $done ? $this->getUser()?->getUserIdentifier() : null)
;
} else {
$task->setUploaded(false === $task->isUploaded());
$task->setOfficeDone(false === $task->isOfficeDone());
}
$this->entityManager->flush();
@@ -68,11 +71,18 @@ class TaskToggleController extends AbstractController
'teamer' => $task->getSeasonAssignment()?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'field' => $field,
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isUploaded(),
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isOfficeDone(),
]);
return $this->render('administrative/season_assignment/_task_row.html.twig', [
'row' => $this->checklistBuilder->buildRow($task),
$seasonAssignment = $task->getSeasonAssignment();
if (null === $seasonAssignment) {
throw $this->createNotFoundException('The checklist entry has no season assignment.');
}
return $this->render('administrative/season_assignment/_checklist.html.twig', [
'checklist' => $this->checklistBuilder->build($seasonAssignment),
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
]);
}
}
@@ -5,12 +5,18 @@ namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonTaskDocument;
use App\Entity\Upload;
use App\Entity\User;
use App\Form\SeasonTaskDocumentType;
use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonTaskDocumentRepository;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -19,13 +25,23 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Sets the library document a checklist entry hands out. Addressed by the entry rather than by a
* record: the mapping may not exist yet, and there is at most one per entry.
*
* The file can be picked from the library or uploaded here, in which case it joins the library as
* an ordinary Upload::TYPE_DOCUMENT - so next season it can simply be picked. An uploaded file
* wins over the pick: the picker already shows the mapping's current document, so a new file is
* always the more recent statement of intent.
*
* Changing the mapping changes what the *next* assignments get. It never reaches an assignment
* that already exists - that is what the propagate action is for.
*/
class EditController extends AbstractController
{
public function __construct(
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
@@ -48,6 +64,23 @@ class EditController extends AbstractController
$form = $this->createForm(SeasonTaskDocumentType::class, $seasonTaskDocument);
$form->handleRequest($request);
$uploaded = null;
if ($form->isSubmitted() && $form->isValid()) {
$dropped = $this->uploadHandler->getUploadSession()->getUploadsByType(Upload::TYPE_DOCUMENT);
// A dropped file wins over the pick. Editing an existing mapping shows its current
// document in the picker, so having to clear that first to replace it would be a
// detour through a field the user is not talking about.
if (false === $dropped->isEmpty()) {
$uploaded = $this->storeUploadedDocument($form);
$seasonTaskDocument->setDocument($uploaded);
} elseif (null === $seasonTaskDocument->getDocument()) {
$form->addError(new FormError('Bitte wähle ein Dokument aus oder lade eine Datei hoch'));
}
}
if ($form->isSubmitted() && $form->isValid()) {
if (null === $seasonTaskDocument->getId()) {
$this->entityManager->persist($seasonTaskDocument);
@@ -58,6 +91,7 @@ class EditController extends AbstractController
$this->logger->info('Change season task document', [
'task' => $task->value,
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
'uploaded' => null !== $uploaded,
]);
$this->addFlash('success', 'Das Dokument wurde hinterlegt');
@@ -72,4 +106,40 @@ class EditController extends AbstractController
'task' => $task,
]);
}
/**
* The dropped file, turned into a library document. It carries Upload::TYPE_DOCUMENT like
* everything under Dokumente, so next season it is simply picked from the autocomplete.
*
* The dropzone takes one file, and a second one would be a user error rather than a case to
* model, so only the first is kept and the rest of the session entry is dropped with it.
*/
private function storeUploadedDocument(FormInterface $form): Upload
{
/** @var User $user */
$user = $this->getUser();
$uploadSession = $this->uploadHandler->getUploadSession();
$upload = Upload::fromUploadDto(
$uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(),
$user,
Upload::TYPE_DOCUMENT
);
if (null !== $displayName = $form->get('displayName')->getData()) {
$upload->setDisplayName($displayName);
}
$this->entityManager->persist($upload);
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
$this->uploadHandler->ensurePdf($upload);
}
return $upload;
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Entity\SeasonTaskDocument;
use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonAssignmentTaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Copies the mapped document onto the checklist rows of assignments that already exist.
*
* A checklist row carries the document it was given when it was created, so depositing a new file
* changes nothing for a season that was already laid out. That is deliberate for finished work
* and unhelpful for a season still being prepared - this action closes exactly that gap, by hand
* and with the count shown before it runs.
*
* Scoped to entries that are not ticked off, on assignments that have not ended: what an entry
* already handed out is history and is not rewritten.
*
* officeDone is left alone. It is the office's own note, an untick is deliberate, and a document
* changing underneath is not an office action.
*/
class PropagateController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/propagate/{uuid}',
name: 'app_administrative_system_season_task_document_propagate'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
{
$tasks = $this->seasonAssignmentTaskRepository->findOpenForTask(
$seasonTaskDocument->getTaskKey(),
new \DateTimeImmutable('today')
);
if (true === $request->isMethod('POST')) {
foreach ($tasks as $task) {
$task->setDocument($seasonTaskDocument->getDocument());
}
$this->entityManager->flush();
$this->logger->info('Propagate season task document', [
'task' => $seasonTaskDocument->getTaskKey(),
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
'count' => count($tasks),
]);
$this->addFlash(
'success',
1 === count($tasks)
? 'Das Dokument wurde auf einen offenen Einsatz übertragen'
: sprintf('Das Dokument wurde auf %d offene Einsätze übertragen', count($tasks))
);
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_propagate.html.twig', [
'seasonTaskDocument' => $seasonTaskDocument,
'tasks' => $tasks,
]);
}
}
-20
View File
@@ -77,14 +77,6 @@ class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityI
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $remarks = null;
/**
* The office's note that it considers this teamer done. Informative only: it changes no mail
* and no query - the teamer keeps being reminded while a return-required checklist entry is
* open. Administrative users only; there is no teamer-side write path.
*/
#[ORM\Column]
private bool $doneTeamer = false;
/**
* @var Collection<int, SeasonAssignmentTask>
*/
@@ -186,18 +178,6 @@ class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityI
return $this;
}
public function isDoneTeamer(): bool
{
return $this->doneTeamer;
}
public function setDoneTeamer(bool $doneTeamer): static
{
$this->doneTeamer = $doneTeamer;
return $this;
}
/**
* @return Collection<int, SeasonAssignmentTask>
*/
+39 -6
View File
@@ -61,10 +61,31 @@ class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEnt
private ?string $doneBy = null;
/**
* Purely informational: it records that a usable file arrived and gates nothing at all.
* The office's own step on this entry, ticked by an administrative user. Purely
* informational: it gates nothing at all, and only $done closes the entry.
*
* Named for who ticks it rather than for what is ticked, because the step differs per entry:
* filing a returned contract is not the same act as registering someone with the
* municipality, and a name taken from one of those reads as a second $done on the others.
*/
#[ORM\Column]
private bool $uploaded = false;
private bool $officeDone = false;
/**
* The document this entry hands out, copied from the library when the row was created.
*
* A snapshot rather than a live lookup: the library holds what the *next* assignments get,
* and it is replaced from season to season, so resolving it on read would rewrite what a
* finished assignment handed out years ago. An already laid out season that should still get
* a newly deposited file is served by the propagate action instead, which is deliberate and
* leaves completed work alone.
*
* SET NULL rather than a guard on the library: deleting the file leaves the row readable and
* showing "kein Dokument hinterlegt", which is a state the views already handle.
*/
#[ORM\ManyToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Upload $document = null;
/**
* Null means "not overridden" - the deadlines are computed on read by
@@ -166,14 +187,26 @@ class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEnt
return $this;
}
public function isUploaded(): bool
public function isOfficeDone(): bool
{
return $this->uploaded;
return $this->officeDone;
}
public function setUploaded(bool $uploaded): static
public function setOfficeDone(bool $officeDone): static
{
$this->uploaded = $uploaded;
$this->officeDone = $officeDone;
return $this;
}
public function getDocument(): ?Upload
{
return $this->document;
}
public function setDocument(?Upload $document): static
{
$this->document = $document;
return $this;
}
+18 -1
View File
@@ -5,21 +5,38 @@ namespace App\Form;
use App\Entity\SeasonTaskDocument;
use App\Entity\Upload;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Either an existing library document or a newly uploaded one.
*
* The upload itself does not travel in the form - it goes through the dropzone into the upload
* session, like everywhere else in this app - so the controller decides which of the two the
* mapping ends up with: a dropped file wins over the pick, and a submit carrying neither is
* refused.
*/
class SeasonTaskDocumentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('document', AutocompleteEntityType::class, [
'label' => 'Dokument',
'label' => 'Vorhandenes Dokument',
'required' => false,
'class' => Upload::class,
'label_property' => 'originalFilename',
'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(),
'endpoint_route' => 'app_admin_autocomplete_document',
])
// Unmapped: it names the uploaded file, which is an Upload and not part of the
// mapping. Renaming a picked document would rename it for the whole library.
->add('displayName', TextType::class, [
'label' => 'angezeigter Name der neuen Datei',
'required' => false,
'mapped' => false,
])
;
}
@@ -44,6 +44,47 @@ class SeasonAssignmentTaskRepository extends ServiceEntityRepository
;
}
/**
* The rows a newly deposited library document may still be copied onto: the entry is not
* ticked off yet and its assignment has not ended.
*
* A finished entry keeps what it handed out - that is the whole point of the row carrying its
* own document - and so does an assignment that is over, whether or not every entry on it was
* ticked.
*
* @return SeasonAssignmentTask[]
*/
public function findOpenForTask(string $taskKey, \DateTimeImmutable $today): array
{
return $this
->createOpenForTaskQueryBuilder($taskKey, $today)
->getQuery()
->getResult()
;
}
/**
* Extracted like createOpenQueryBuilder() below, so a test can pin the conditions by
* compiling the DQL rather than by persisting rows.
*/
public function createOpenForTaskQueryBuilder(string $taskKey, \DateTimeImmutable $today): QueryBuilder
{
$qb = $this->createQueryBuilder('task');
return $qb
->innerJoin('task.seasonAssignment', 'season_assignment')
->where($qb->expr()->andX(
$qb->expr()->eq('task.taskKey', ':taskKey'),
$qb->expr()->eq('task.done', ':done'),
$qb->expr()->gte('season_assignment.dateTo', ':today'),
$qb->expr()->isNull('season_assignment.deletedAt')
))
->setParameter('taskKey', $taskKey)
->setParameter('done', false)
->setParameter('today', $today)
;
}
/**
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
* fixtures, so what is asserted is what the query says, not what it returns.
@@ -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();
}
}