fix: called-off dispositions no longer block teamers

This commit is contained in:
Björn Fromme
2026-08-26 09:11:27 +02:00
committed by fromme
parent cc280907e5
commit f3eae5462d
8 changed files with 186 additions and 20 deletions
@@ -32,9 +32,12 @@ class CreateController extends AbstractController
$user = $this->getUser(); $user = $this->getUser();
$teamer = $user->getTeamer(); $teamer = $user->getTeamer();
// Only allow applications on empty slots // Only allow applications on empty slots. The teamer list already hides assignments without
$availableSlots = (int) $assignment->getAvailableDispositions(); // a free slot, so this only catches the way past it: a bookmark or a direct link. Called-off
if (0 > $availableSlots) { // 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'); $this->addFlash('error', 'Auf diesem Einsatz sind bereits alle Plätze belegt');
return $this->redirectToRoute('app_teamer_index'); return $this->redirectToRoute('app_teamer_index');
@@ -6,7 +6,6 @@ use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment; use App\Entity\Assignment;
use App\Entity\User; use App\Entity\User;
use App\Repository\ApplicationRepository; use App\Repository\ApplicationRepository;
use App\Repository\DispositionRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -19,7 +18,6 @@ class DetailController extends AbstractController
public function __construct( public function __construct(
private readonly ApplicationRepository $applicationRepository, private readonly ApplicationRepository $applicationRepository,
private readonly DispositionRepository $dispositionRepository,
) { ) {
} }
@@ -39,13 +37,7 @@ class DetailController extends AbstractController
]) ])
; ;
$disposition = $this $disposition = $assignment->getActiveDispositionByTeamer($teamer);
->dispositionRepository
->findOneBy([
'assignment' => $assignment,
'teamer' => $teamer,
])
;
$isBookmarked = $teamer->getBookmarks()->contains($assignment); $isBookmarked = $teamer->getBookmarks()->contains($assignment);
+18
View File
@@ -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 public function getActiveDispositions(): Collection
{ {
return $this->dispositions->filter(function (Disposition $disposition) { return $this->dispositions->filter(function (Disposition $disposition) {
+22 -5
View File
@@ -372,12 +372,31 @@ class AssignmentRepository extends ServiceEntityRepository
} }
private function getAvailableAssignmentsIds(): array 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'); $qb = $this->createQueryBuilder('assignment');
$availableAssignments = $qb return $qb
->select('assignment.id', 'assignment.availableDispositions') ->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') ->innerJoin('assignment.destination', 'destination')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->orX( $qb->expr()->orX(
@@ -393,12 +412,10 @@ class AssignmentRepository extends ServiceEntityRepository
->groupBy('assignment.id', 'assignment.availableDispositions') ->groupBy('assignment.id', 'assignment.availableDispositions')
->having($qb->expr()->gt('assignment.availableDispositions', $qb->expr()->count('disposition.id'))) ->having($qb->expr()->gt('assignment.availableDispositions', $qb->expr()->count('disposition.id')))
->setParameter('status', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF]) ->setParameter('status', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF])
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('now', new \DateTimeImmutable()) ->setParameter('now', new \DateTimeImmutable())
->getQuery() ->getQuery()
->getArrayResult()
; ;
return array_column($availableAssignments, 'id');
} }
public function getSelectableHotelCodes(): array public function getSelectableHotelCodes(): array
+1 -1
View File
@@ -58,7 +58,7 @@
</div> </div>
<div> <div>
{% set application = assignment.applications.first %} {% set application = assignment.applications.first %}
{% set disposition = assignment.dispositions.first%} {% set disposition = assignment.activeDispositionByTeamer(teamer) %}
{% if application %} {% if application %}
{% if application.status == constant('App\\Entity\\Application::STATUS_REJECTED') %} {% if application.status == constant('App\\Entity\\Application::STATUS_REJECTED') %}
<a href="{{ path('app_teamer_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}" <a href="{{ path('app_teamer_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
+3 -2
View File
@@ -35,15 +35,16 @@
{% endif %} {% endif %}
</div> </div>
<div> <div>
{% set disposition = assignment.activeDispositionByTeamer(teamer) %}
{% if assignment.applications|length %} {% if assignment.applications|length %}
{% set icon = 'hourglass' %} {% set icon = 'hourglass' %}
{% set class = 'bg-yellow-100 text-yellow-800 hover:bg-yellow-50' %} {% 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 url = path('app_teamer_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) %}
{% set label = 'in Bearbeitung' %} {% set label = 'in Bearbeitung' %}
{% elseif assignment.dispositions|length %} {% elseif disposition %}
{% set icon = 'check' %} {% set icon = 'check' %}
{% set class = 'bg-green-100 text-green-700 hover:bg-green-50' %} {% 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' %} {% set label = 'eingeteilt' %}
{% else %} {% else %}
{% set icon = 'hand' %} {% set icon = 'hand' %}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use PHPUnit\Framework\TestCase;
/**
* A called-off teamer is free again: the teamer views ask the assignment for the teamer's
* disposition to decide between "eingeteilt" and the application button, so a called-off
* disposition must not be handed out there.
*/
class AssignmentTest extends TestCase
{
public function testTheActiveDispositionOfATeamerIsReturned(): void
{
$assignment = new Assignment();
$teamer = new Teamer();
$disposition = $this->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;
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Repository\AssignmentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The slot counting behind every teamer facing assignment list lives in DQL, so the query is
* compiled here and the parts carrying the semantics are pinned.
*/
class AssignmentRepositoryTest extends KernelTestCase
{
public function testAvailableAssignmentsQueryIgnoresCalledOffDispositions(): void
{
$dql = $this->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();
}
}