From f3eae5462da89e7ae3c670c36dfb81af5b08fab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 26 Aug 2026 09:06:46 +0200 Subject: [PATCH] fix: called-off dispositions no longer block teamers --- .../Teamer/Application/CreateController.php | 9 ++- .../Teamer/Assignment/DetailController.php | 10 +-- src/Entity/Assignment.php | 18 +++++ src/Repository/AssignmentRepository.php | 27 +++++-- templates/teamer/assignment/index.html.twig | 2 +- templates/teamer/bookmark/index.html.twig | 5 +- tests/Entity/AssignmentTest.php | 75 +++++++++++++++++++ tests/Repository/AssignmentRepositoryTest.php | 60 +++++++++++++++ 8 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 tests/Entity/AssignmentTest.php create mode 100644 tests/Repository/AssignmentRepositoryTest.php diff --git a/src/Controller/Teamer/Application/CreateController.php b/src/Controller/Teamer/Application/CreateController.php index 837712b..a00ca56 100644 --- a/src/Controller/Teamer/Application/CreateController.php +++ b/src/Controller/Teamer/Application/CreateController.php @@ -32,9 +32,12 @@ class CreateController extends AbstractController $user = $this->getUser(); $teamer = $user->getTeamer(); - // Only allow applications on empty slots - $availableSlots = (int) $assignment->getAvailableDispositions(); - if (0 > $availableSlots) { + // Only allow applications on empty slots. The teamer list already hides assignments without + // a free slot, so this only catches the way past it: a bookmark or a direct link. Called-off + // dispositions do not occupy a slot, and an assignment without a slot count is treated as + // full - both the same way App\Repository\AssignmentRepository decides what to list. + $availableSlots = (int) $assignment->getAvailableDispositions() - $assignment->getActiveDispositions()->count(); + if (0 >= $availableSlots) { $this->addFlash('error', 'Auf diesem Einsatz sind bereits alle Plätze belegt'); return $this->redirectToRoute('app_teamer_index'); diff --git a/src/Controller/Teamer/Assignment/DetailController.php b/src/Controller/Teamer/Assignment/DetailController.php index c89da23..d3f2fb5 100644 --- a/src/Controller/Teamer/Assignment/DetailController.php +++ b/src/Controller/Teamer/Assignment/DetailController.php @@ -6,7 +6,6 @@ use App\Controller\Traits\ReturnUrlTrait; use App\Entity\Assignment; use App\Entity\User; use App\Repository\ApplicationRepository; -use App\Repository\DispositionRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -19,7 +18,6 @@ class DetailController extends AbstractController public function __construct( private readonly ApplicationRepository $applicationRepository, - private readonly DispositionRepository $dispositionRepository, ) { } @@ -39,13 +37,7 @@ class DetailController extends AbstractController ]) ; - $disposition = $this - ->dispositionRepository - ->findOneBy([ - 'assignment' => $assignment, - 'teamer' => $teamer, - ]) - ; + $disposition = $assignment->getActiveDispositionByTeamer($teamer); $isBookmarked = $teamer->getBookmarks()->contains($assignment); diff --git a/src/Entity/Assignment.php b/src/Entity/Assignment.php index 8420aa8..3fa672d 100644 --- a/src/Entity/Assignment.php +++ b/src/Entity/Assignment.php @@ -497,6 +497,24 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa }); } + /** + * Deliberately skips called-off dispositions: once a teamer has been called off - by themselves + * or by the office - the assignment is open for them again, so the teamer views have to offer + * the application instead of reporting them as staffed. + */ + public function getActiveDispositionByTeamer(Teamer $teamer): ?Disposition + { + $dispositions = $this->getActiveDispositions()->filter(function (Disposition $disposition) use ($teamer) { + return $disposition->getTeamer() === $teamer; + }); + + if (0 === count($dispositions)) { + return null; + } + + return $dispositions->first(); + } + public function getActiveDispositions(): Collection { return $this->dispositions->filter(function (Disposition $disposition) { diff --git a/src/Repository/AssignmentRepository.php b/src/Repository/AssignmentRepository.php index 43b66a9..b65bb80 100644 --- a/src/Repository/AssignmentRepository.php +++ b/src/Repository/AssignmentRepository.php @@ -372,12 +372,31 @@ class AssignmentRepository extends ServiceEntityRepository } private function getAvailableAssignmentsIds(): array + { + $availableAssignments = $this + ->getAvailableAssignmentsIdsQuery() + ->getArrayResult() + ; + + return array_column($availableAssignments, 'id'); + } + + /** + * A called-off disposition does not occupy a slot any more: the assignment has to show up as + * available again - both for the teamer who was called off and for everybody else. + */ + public function getAvailableAssignmentsIdsQuery(): Query { $qb = $this->createQueryBuilder('assignment'); - $availableAssignments = $qb + return $qb ->select('assignment.id', 'assignment.availableDispositions') - ->leftJoin('assignment.dispositions', 'disposition') + ->leftJoin( + 'assignment.dispositions', + 'disposition', + Join::WITH, + $qb->expr()->neq('disposition.status', ':dispositionCalledOff') + ) ->innerJoin('assignment.destination', 'destination') ->where($qb->expr()->andX( $qb->expr()->orX( @@ -393,12 +412,10 @@ class AssignmentRepository extends ServiceEntityRepository ->groupBy('assignment.id', 'assignment.availableDispositions') ->having($qb->expr()->gt('assignment.availableDispositions', $qb->expr()->count('disposition.id'))) ->setParameter('status', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF]) + ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) ->setParameter('now', new \DateTimeImmutable()) ->getQuery() - ->getArrayResult() ; - - return array_column($availableAssignments, 'id'); } public function getSelectableHotelCodes(): array diff --git a/templates/teamer/assignment/index.html.twig b/templates/teamer/assignment/index.html.twig index 4f0c9cd..9d6140e 100644 --- a/templates/teamer/assignment/index.html.twig +++ b/templates/teamer/assignment/index.html.twig @@ -58,7 +58,7 @@
{% set application = assignment.applications.first %} - {% set disposition = assignment.dispositions.first%} + {% set disposition = assignment.activeDispositionByTeamer(teamer) %} {% if application %} {% if application.status == constant('App\\Entity\\Application::STATUS_REJECTED') %}
+ {% set disposition = assignment.activeDispositionByTeamer(teamer) %} {% if assignment.applications|length %} {% set icon = 'hourglass' %} {% set class = 'bg-yellow-100 text-yellow-800 hover:bg-yellow-50' %} {% set url = path('app_teamer_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) %} {% set label = 'in Bearbeitung' %} - {% elseif assignment.dispositions|length %} + {% elseif disposition %} {% set icon = 'check' %} {% set class = 'bg-green-100 text-green-700 hover:bg-green-50' %} - {% set url = path('app_teamer_disposition_detail', { 'uuid': assignment.dispositions[0].uuid }) %} + {% set url = path('app_teamer_disposition_detail', { 'uuid': disposition.uuid }) %} {% set label = 'eingeteilt' %} {% else %} {% set icon = 'hand' %} diff --git a/tests/Entity/AssignmentTest.php b/tests/Entity/AssignmentTest.php new file mode 100644 index 0000000..df83b49 --- /dev/null +++ b/tests/Entity/AssignmentTest.php @@ -0,0 +1,75 @@ +createDisposition($assignment, $teamer); + + $this->assertSame($disposition, $assignment->getActiveDispositionByTeamer($teamer)); + } + + public function testACalledOffDispositionIsNotReturned(): void + { + $assignment = new Assignment(); + $teamer = new Teamer(); + + $this + ->createDisposition($assignment, $teamer) + ->setStatus(Disposition::STATUS_CALLED_OFF) + ; + + $this->assertNull($assignment->getActiveDispositionByTeamer($teamer)); + } + + public function testTheDispositionOfAnotherTeamerIsNotReturned(): void + { + $assignment = new Assignment(); + $this->createDisposition($assignment, new Teamer()); + + $this->assertNull($assignment->getActiveDispositionByTeamer(new Teamer())); + } + + /** + * A teamer can be called off and disposed again on the same assignment, so both dispositions + * live side by side - the active one has to win. + */ + public function testTheActiveDispositionWinsOverACalledOffOne(): void + { + $assignment = new Assignment(); + $teamer = new Teamer(); + + $this + ->createDisposition($assignment, $teamer) + ->setStatus(Disposition::STATUS_CALLED_OFF) + ; + $disposition = $this->createDisposition($assignment, $teamer); + + $this->assertSame($disposition, $assignment->getActiveDispositionByTeamer($teamer)); + } + + private function createDisposition(Assignment $assignment, Teamer $teamer): Disposition + { + $disposition = new Disposition(new Application($assignment, $teamer)); + $assignment->addDisposition($disposition); + + return $disposition; + } +} diff --git a/tests/Repository/AssignmentRepositoryTest.php b/tests/Repository/AssignmentRepositoryTest.php new file mode 100644 index 0000000..f10c93c --- /dev/null +++ b/tests/Repository/AssignmentRepositoryTest.php @@ -0,0 +1,60 @@ +createAvailableAssignmentsIdsQuery()->getDQL(); + + // the exclusion has to live in the JOIN condition: moved into the WHERE clause it would + // drop assignments without any disposition at all + $this->assertStringContainsString( + 'LEFT JOIN assignment.dispositions disposition WITH disposition.status <> :dispositionCalledOff', + $dql + ); + $this->assertStringContainsString( + 'HAVING assignment.availableDispositions > COUNT(disposition.id)', + $dql + ); + } + + public function testAvailableAssignmentsQueryBindsTheCalledOffStatuses(): void + { + $query = $this->createAvailableAssignmentsIdsQuery(); + + $this->assertSame( + Disposition::STATUS_CALLED_OFF, + $query->getParameter('dispositionCalledOff')->getValue() + ); + $this->assertSame( + [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF], + $query->getParameter('status')->getValue() + ); + } + + private function createAvailableAssignmentsIdsQuery(): Query + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var AssignmentRepository $repository */ + $repository = $entityManager->getRepository(Assignment::class); + + return $repository->getAvailableAssignmentsIdsQuery(); + } +}