Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
804dab335f | ||
|
|
bdd06f0187 | ||
|
|
644bfa225d |
No files matched your search
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\Document;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\Upload;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class CommentController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/document/comment/{uuid}', name: 'app_administrative_document_comment')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
#[IsGranted('COMMENT', subject: 'document')]
|
||||
public function index(Upload $document, Request $request): Response
|
||||
{
|
||||
$form = $this
|
||||
->createFormBuilder($document, ['hx_post' => $request->getUri()])
|
||||
->add('comment', TextareaType::class, [
|
||||
'label' => 'Kommentar',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
])
|
||||
->getForm()
|
||||
;
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Der Kommentar wurde aktualisiert');
|
||||
$this->logger->info('Update document comment', [
|
||||
'document_id' => $document->getId(),
|
||||
'document_filename' => $document->getOriginalFilename(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_document_index'));
|
||||
}
|
||||
|
||||
return $this->render('administrative/document/modal_comment.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'document' => $document,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+32
-10
@@ -21,12 +21,41 @@ class JobProfile implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
use SoftDeletableEntity;
|
||||
|
||||
public const CATEGORY_GUIDE = 'guide';
|
||||
public const CATEGORY_COURSE = 'course';
|
||||
public const CATEGORY_COURSE_SKI = 'course_ski';
|
||||
public const CATEGORY_COURSE_SKI_BEGINNER = 'course_ski_beginner';
|
||||
public const CATEGORY_COURSE_SKI_ADVANCED = 'course_ski_advanced';
|
||||
public const CATEGORY_COURSE_BOARD = 'course_board';
|
||||
public const CATEGORY_COURSE_BOARD_BEGINNER = 'course_board_beginner';
|
||||
public const CATEGORY_COURSE_BOARD_ADVANCED = 'course_board_advanced';
|
||||
public const CATEGORY_SERVICE = 'service';
|
||||
public const CATEGORY_EVENT_TEAM = 'event_team';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getCategories(): array
|
||||
{
|
||||
return [
|
||||
self::CATEGORY_GUIDE => 'Reiseleitung',
|
||||
self::CATEGORY_COURSE_SKI => 'Kursleitung Ski',
|
||||
self::CATEGORY_COURSE_SKI_BEGINNER => 'Kursleitung Ski Anfänger:innen',
|
||||
self::CATEGORY_COURSE_SKI_ADVANCED => 'Kursleitung Ski fortgeschritten',
|
||||
self::CATEGORY_COURSE_BOARD => 'Kursleitung Board',
|
||||
self::CATEGORY_COURSE_BOARD_BEGINNER => 'Kursleitung Board Anfänger:innen',
|
||||
self::CATEGORY_COURSE_BOARD_ADVANCED => 'Kursleitung Board fortgeschritten',
|
||||
self::CATEGORY_SERVICE => 'Serviceteamer:in',
|
||||
self::CATEGORY_EVENT_TEAM => 'Eventteamer:in',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function getCategoryValues(): array
|
||||
{
|
||||
return array_keys(self::getCategories());
|
||||
}
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -41,6 +70,7 @@ class JobProfile implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\NotBlank(message: 'Bitte wähle die Kategorie')]
|
||||
#[Assert\Choice(callback: [self::class, 'getCategoryValues'], message: 'Die Kategorie ist ungültig')]
|
||||
private ?string $category = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
@@ -99,15 +129,7 @@ class JobProfile implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
|
||||
public function getCategoryLabel(): ?string
|
||||
{
|
||||
return match ($this->category) {
|
||||
static::CATEGORY_GUIDE => 'Reiseleitung',
|
||||
static::CATEGORY_COURSE => 'Kursleitung',
|
||||
static::CATEGORY_COURSE_SKI => 'Kursleitung Ski',
|
||||
static::CATEGORY_COURSE_BOARD => 'Kursleitung Board',
|
||||
static::CATEGORY_SERVICE => 'Serviceteam',
|
||||
static::CATEGORY_EVENT_TEAM => 'Eventteam',
|
||||
default => null,
|
||||
};
|
||||
return self::getCategories()[$this->category] ?? null;
|
||||
}
|
||||
|
||||
public function getRequiredLicenses(): array
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Upload;
|
||||
use App\Event\ApplicationDeletedEvent;
|
||||
use App\Event\DocumentConfirmedEvent;
|
||||
use App\Repository\ApplicationRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
#[AsEventListener(event: DocumentConfirmedEvent::NAME, method: 'onDocumentConfirmed')]
|
||||
class InvalidateApplicationsListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApplicationRepository $applicationRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -33,73 +37,39 @@ class InvalidateApplicationsListener
|
||||
return;
|
||||
}
|
||||
|
||||
$assignment = $disposition->getAssignment();
|
||||
$period = $assignment->getEffectivePeriod();
|
||||
if (null === $period = $disposition->getAssignment()->getEffectivePeriod()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find other applications by this teamer with overlapping date ranges
|
||||
// Two periods overlap when: (start1 < end2) AND (end1 > start2)
|
||||
// This catches all overlap scenarios while excluding boundary-only touching:
|
||||
// - Partial overlaps from either direction
|
||||
// - Complete containment in either direction
|
||||
// - Exact date matches
|
||||
// Note: Periods that only touch at boundaries (e.g., Jan 1-7 and Jan 7-12) are NOT considered overlapping
|
||||
// The effective dates are determined by checking assignment dates first, falling back to destination dates if null
|
||||
$qb = $this
|
||||
->entityManager
|
||||
->getRepository(Application::class)
|
||||
->createQueryBuilder('application');
|
||||
|
||||
/** @var array<Application> $applications */
|
||||
$applications = $qb
|
||||
->innerJoin('application.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('application.teamer', ':teamer'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNull('assignment.dateFrom'),
|
||||
$qb->expr()->lt('destination.dateFrom', ':dateTo')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.dateFrom'),
|
||||
$qb->expr()->lt('assignment.dateFrom', ':dateTo')
|
||||
)
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNull('assignment.dateTo'),
|
||||
$qb->expr()->gt('destination.dateTo', ':dateFrom')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.dateTo'),
|
||||
$qb->expr()->gt('assignment.dateTo', ':dateFrom')
|
||||
)
|
||||
),
|
||||
$qb->expr()->neq('application.status', ':status')
|
||||
))
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('dateFrom', $period->start->toDateTimeImmutable())
|
||||
->setParameter('dateTo', $period->end->toDateTimeImmutable())
|
||||
->setParameter('status', Application::STATUS_REJECTED)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
$applications = $this->applicationRepository->findOverlappingForTeamer($teamer, $period);
|
||||
|
||||
if (0 === count($applications)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($applications as $application) {
|
||||
$destination = $application->getAssignment()->getDestination();
|
||||
$assignment = $application->getAssignment();
|
||||
$this->logger->info('Deleted overlapping application', [
|
||||
'teamer' => $teamer,
|
||||
'destination' => $destination->getHotelCode(),
|
||||
'date_from' => $destination->getDateFrom()->format('Y-m-d'),
|
||||
'date_to' => $destination->getDateTo()->format('Y-m-d'),
|
||||
'application' => $application->getUuid(),
|
||||
'assignment' => $assignment->getUuid(),
|
||||
'teamer' => $teamer->getUuid(),
|
||||
'teamer_name' => (string) $teamer,
|
||||
'destination' => $assignment->getDestination()->getHotelCode(),
|
||||
'date_from' => $assignment->getEffectiveDateFrom()->format('Y-m-d'),
|
||||
'date_to' => $assignment->getEffectiveDateTo()->format('Y-m-d'),
|
||||
]);
|
||||
$this->entityManager->remove($application);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// the staffing status of the affected assignments and the audit log are both updated by
|
||||
// listeners of this event, so it has to be dispatched after the applications are gone
|
||||
foreach ($applications as $application) {
|
||||
$this->eventDispatcher->dispatch(
|
||||
new ApplicationDeletedEvent($application),
|
||||
ApplicationDeletedEvent::NAME
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,7 @@ class JobProfileType extends AbstractType
|
||||
->add('category', ChoiceType::class, [
|
||||
'label' => 'Kategorie',
|
||||
'placeholder' => 'bitte zuordnen...',
|
||||
'choices' => [
|
||||
'Reiseleitung' => JobProfile::CATEGORY_GUIDE,
|
||||
'Kursleitung Ski' => JobProfile::CATEGORY_COURSE_SKI,
|
||||
'Kursleitung Board' => JobProfile::CATEGORY_COURSE_BOARD,
|
||||
'Serviceteam' => JobProfile::CATEGORY_SERVICE,
|
||||
'Eventteam' => JobProfile::CATEGORY_EVENT_TEAM,
|
||||
],
|
||||
'choices' => array_flip(JobProfile::getCategories()),
|
||||
])
|
||||
->add('requiredTrainings', EntityType::class, [
|
||||
'label' => 'vorausgesetzte Fortbildung',
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Entity\Teamer;
|
||||
use App\Model\ApplicationFilterDto;
|
||||
use App\Model\AssignmentFilterDto;
|
||||
use App\Repository\Traits\QueryHelperTrait;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -161,6 +162,67 @@ class ApplicationRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all applications of a teamer whose effective assignment period overlaps the given period.
|
||||
*
|
||||
* The effective period of an assignment is its own date range, falling back to the date range of
|
||||
* its destination whenever the assignment does not override it - the query mirrors
|
||||
* {@see Assignment::getEffectivePeriod()}. Boundaries count as an overlap: a teamer cannot end one
|
||||
* assignment on the same day another one begins.
|
||||
*
|
||||
* @return array<Application>
|
||||
*/
|
||||
public function findOverlappingForTeamer(Teamer $teamer, CarbonPeriod $period): array
|
||||
{
|
||||
return $this
|
||||
->getOverlappingForTeamerQuery($teamer, $period)
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function getOverlappingForTeamerQuery(Teamer $teamer, CarbonPeriod $period): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('application');
|
||||
|
||||
return $qb
|
||||
->innerJoin('application.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('application.teamer', ':teamer'),
|
||||
$qb->expr()->neq('application.status', ':application_rejected'),
|
||||
$qb->expr()->neq('assignment.status', ':assignment_called_off'),
|
||||
$qb->expr()->isNull('assignment.deletedAt'),
|
||||
$qb->expr()->isNull('destination.deletedAt'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNull('assignment.dateFrom'),
|
||||
$qb->expr()->lte('destination.dateFrom', ':dateTo')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.dateFrom'),
|
||||
$qb->expr()->lte('assignment.dateFrom', ':dateTo')
|
||||
)
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNull('assignment.dateTo'),
|
||||
$qb->expr()->gte('destination.dateTo', ':dateFrom')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.dateTo'),
|
||||
$qb->expr()->gte('assignment.dateTo', ':dateFrom')
|
||||
)
|
||||
)
|
||||
))
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('application_rejected', Application::STATUS_REJECTED)
|
||||
->setParameter('assignment_called_off', Assignment::STATUS_CALLED_OFF)
|
||||
->setParameter('dateFrom', $period->start->toDateTimeImmutable())
|
||||
->setParameter('dateTo', $period->end->toDateTimeImmutable())
|
||||
->getQuery()
|
||||
;
|
||||
}
|
||||
|
||||
public function getCurrentByAssignment(Assignment $assignment): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('application');
|
||||
|
||||
@@ -14,6 +14,7 @@ class UploadVoter extends Voter
|
||||
public const DELETE = 'DELETE';
|
||||
public const DOWNLOAD = 'DOWNLOAD';
|
||||
public const CHECK = 'CHECK';
|
||||
public const COMMENT = 'COMMENT';
|
||||
|
||||
public function __construct(private readonly Security $security)
|
||||
{
|
||||
@@ -25,7 +26,7 @@ class UploadVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($attribute, [static::VIEW, static::DELETE, static::DOWNLOAD, static::CHECK]);
|
||||
return in_array($attribute, [static::VIEW, static::DELETE, static::DOWNLOAD, static::CHECK, static::COMMENT]);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
@@ -35,7 +36,7 @@ class UploadVoter extends Voter
|
||||
|
||||
// Administrative users have full access
|
||||
if (true === $this->security->isGranted('ROLE_ADMINISTRATIVE')) {
|
||||
if (true === in_array($attribute, [static::VIEW, static::DELETE, static::DOWNLOAD])) {
|
||||
if (true === in_array($attribute, [static::VIEW, static::DELETE, static::DOWNLOAD, static::COMMENT])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,17 +37,21 @@ class ApplicationValidator extends ConstraintValidator
|
||||
return;
|
||||
}
|
||||
|
||||
$assignment = $application->getAssignment();
|
||||
$destination = $assignment->getDestination();
|
||||
if (null === $period = $application->getAssignment()->getEffectivePeriod()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicationDateFrom = $destination->getDateFrom();
|
||||
$applicationDateTo = $destination->getDateTo();
|
||||
$applicationDateFrom = $period->start->toDateTimeImmutable();
|
||||
$applicationDateTo = $period->end->toDateTimeImmutable();
|
||||
|
||||
$qb = $this
|
||||
->dispositionRepository
|
||||
->createQueryBuilder('disposition')
|
||||
;
|
||||
|
||||
// the same overlap test as App\Repository\ApplicationRepository::findOverlappingForTeamer(),
|
||||
// so an application that passes this validation is not purged later on by
|
||||
// App\EventListener\InvalidateApplicationsListener
|
||||
$conflictingCount = $qb
|
||||
->select($qb->expr()->count('disposition'))
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
@@ -60,12 +64,22 @@ class ApplicationValidator extends ConstraintValidator
|
||||
$qb->expr()->isNull('destination.deletedAt'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->gt('destination.dateTo', ':application_date_from'),
|
||||
$qb->expr()->lt('destination.dateTo', ':application_date_to'),
|
||||
$qb->expr()->isNull('assignment.dateFrom'),
|
||||
$qb->expr()->lte('destination.dateFrom', ':application_date_to')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->lt('destination.dateFrom', ':application_date_to'),
|
||||
$qb->expr()->gt('destination.dateFrom', ':application_date_from'),
|
||||
$qb->expr()->isNotNull('assignment.dateFrom'),
|
||||
$qb->expr()->lte('assignment.dateFrom', ':application_date_to')
|
||||
)
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNull('assignment.dateTo'),
|
||||
$qb->expr()->gte('destination.dateTo', ':application_date_from')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.dateTo'),
|
||||
$qb->expr()->gte('assignment.dateTo', ':application_date_from')
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
@@ -90,6 +90,16 @@
|
||||
{{ icon('delete') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if is_granted('COMMENT', upload) %}
|
||||
<button type="button"
|
||||
class="{{ html_classes({ 'text-gray-300': not upload.comment }) }}"
|
||||
title="{{ upload.comment ? 'Kommentar bearbeiten' : 'Kommentar hinzufügen' }}"
|
||||
hx-get="{{ path('app_administrative_document_comment', { 'uuid': upload.uuid, 'r': return_url() }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('feedback') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_common_download', { 'uuid': upload.uuid, 'inline': false }) }}"
|
||||
target="_blank"
|
||||
title="Download und Markierung">
|
||||
|
||||
@@ -99,6 +99,16 @@
|
||||
{{ icon('delete') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if is_granted('COMMENT', upload) %}
|
||||
<button type="button"
|
||||
class="{{ html_classes({ 'text-gray-300': not upload.comment }) }}"
|
||||
title="{{ upload.comment ? 'Kommentar bearbeiten' : 'Kommentar hinzufügen' }}"
|
||||
hx-get="{{ path('app_administrative_document_comment', { 'uuid': upload.uuid, 'r': return_url() }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('feedback') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if is_granted('CHECK', upload) %}
|
||||
<button type="button"
|
||||
hx-get="{{ path('app_administrative_document_check', { 'uuid': upload.uuid, 'r': return_url() }) }}"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Kommentar{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="{{ path('app_common_download', { 'uuid': document.uuid, 'inline': true }) }}"
|
||||
target="_blank"
|
||||
class="flex justify-between items-center pb-4"
|
||||
title="Dokument öffnen">
|
||||
<h2 class="font-bold text-lg">
|
||||
{{ ('label.document.type.' ~ document.type)|trans }} {{ document.disposition.teamer }} vom {{ document.createdAt|date('d.m.Y') }}
|
||||
</h2>
|
||||
{{ icon('eye') }}
|
||||
</a>
|
||||
{{ form_start(form) }}
|
||||
<div class="pb-4">
|
||||
{{ form_row(form.comment) }}
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
Aktualisieren
|
||||
</button>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -10,326 +10,218 @@ use App\Entity\Destination;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
use App\Event\ApplicationDeletedEvent;
|
||||
use App\Event\DocumentConfirmedEvent;
|
||||
use App\EventListener\InvalidateApplicationsListener;
|
||||
use App\Repository\ApplicationRepository;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class InvalidateApplicationsListenerTest extends TestCase
|
||||
{
|
||||
private ApplicationRepository&MockObject $applicationRepository;
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private InvalidateApplicationsListener $listener;
|
||||
|
||||
/** @var array<Application> */
|
||||
private array $removedApplications = [];
|
||||
|
||||
/** @var array<Event> */
|
||||
private array $dispatchedEvents = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->applicationRepository = $this->createMock(ApplicationRepository::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$this->removedApplications = [];
|
||||
$this->dispatchedEvents = [];
|
||||
|
||||
$this->entityManager
|
||||
->method('remove')
|
||||
->willReturnCallback(function (Application $application): void {
|
||||
$this->removedApplications[] = $application;
|
||||
});
|
||||
})
|
||||
;
|
||||
|
||||
$eventDispatcher
|
||||
->method('dispatch')
|
||||
->willReturnCallback(function (Event $event): Event {
|
||||
$this->dispatchedEvents[] = $event;
|
||||
|
||||
return $event;
|
||||
})
|
||||
;
|
||||
|
||||
$this->listener = new InvalidateApplicationsListener(
|
||||
$this->applicationRepository,
|
||||
$this->entityManager,
|
||||
$logger
|
||||
$eventDispatcher,
|
||||
$this->createMock(LoggerInterface::class)
|
||||
);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationsForNonContractDocuments(): void
|
||||
public function testIgnoresNonContractDocuments(): void
|
||||
{
|
||||
$document = $this->createMockUpload(Upload::TYPE_INVOICE);
|
||||
$document = $this->createMock(Upload::class);
|
||||
$document->method('getType')->willReturn(Upload::TYPE_INVOICE);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('getRepository');
|
||||
$this->applicationRepository->expects($this->never())->method('findOverlappingForTeamer');
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
$this->assertSame([], $this->removedApplications);
|
||||
$this->assertSame([], $this->dispatchedEvents);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationCompletelyWithinConfirmedPeriod(): void
|
||||
public function testIgnoresTeamersAllowedToHaveOverlappingApplications(): void
|
||||
{
|
||||
$document = $this->createContract(
|
||||
new CarbonPeriod('2025-01-10', '2025-01-20'),
|
||||
$this->createTeamer(allowOverlapping: true)
|
||||
);
|
||||
|
||||
$this->applicationRepository->expects($this->never())->method('findOverlappingForTeamer');
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
|
||||
$this->assertSame([], $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testIgnoresAssignmentsWithoutEffectivePeriod(): void
|
||||
{
|
||||
$document = $this->createContract(null, $this->createTeamer());
|
||||
|
||||
$this->applicationRepository->expects($this->never())->method('findOverlappingForTeamer');
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
|
||||
$this->assertSame([], $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotFlushWhenNothingOverlaps(): void
|
||||
{
|
||||
$teamer = $this->createTeamer();
|
||||
$document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer);
|
||||
|
||||
$this->applicationRepository
|
||||
->method('findOverlappingForTeamer')
|
||||
->willReturn([])
|
||||
;
|
||||
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
|
||||
$this->assertSame([], $this->removedApplications);
|
||||
$this->assertSame([], $this->dispatchedEvents);
|
||||
}
|
||||
|
||||
public function testQueriesOverlappingApplicationsForTheTeamerAndTheEffectivePeriod(): void
|
||||
{
|
||||
$teamer = $this->createTeamer();
|
||||
$period = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$document = $this->createContract($period, $teamer);
|
||||
|
||||
$this->applicationRepository
|
||||
->expects($this->once())
|
||||
->method('findOverlappingForTeamer')
|
||||
->with($teamer, $period)
|
||||
->willReturn([])
|
||||
;
|
||||
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
}
|
||||
|
||||
public function testRemovesEveryOverlappingApplicationAndFlushesOnce(): void
|
||||
{
|
||||
$teamer = $this->createTeamer();
|
||||
$document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer);
|
||||
|
||||
$applications = [$this->createApplication(), $this->createApplication(), $this->createApplication()];
|
||||
|
||||
$this->applicationRepository
|
||||
->method('findOverlappingForTeamer')
|
||||
->willReturn($applications)
|
||||
;
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
|
||||
$this->assertSame($applications, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testAnnouncesEveryRemovalWithAnApplicationDeletedEvent(): void
|
||||
{
|
||||
$teamer = $this->createTeamer();
|
||||
$document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer);
|
||||
|
||||
$applications = [$this->createApplication(), $this->createApplication()];
|
||||
|
||||
$this->applicationRepository
|
||||
->method('findOverlappingForTeamer')
|
||||
->willReturn($applications)
|
||||
;
|
||||
|
||||
$this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document));
|
||||
|
||||
$this->assertCount(2, $this->dispatchedEvents);
|
||||
|
||||
foreach ($this->dispatchedEvents as $index => $event) {
|
||||
$this->assertInstanceOf(ApplicationDeletedEvent::class, $event);
|
||||
$this->assertSame($applications[$index], $event->getApplication());
|
||||
}
|
||||
}
|
||||
|
||||
private function createTeamer(bool $allowOverlapping = false): Teamer&MockObject
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$overlappingApplication = $this->createMockApplication('2025-01-12', '2025-01-18');
|
||||
$teamer->method('isAllowOverlappingApplications')->willReturn($allowOverlapping);
|
||||
$teamer->method('getUuid')->willReturn('teamer-uuid');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(1, $this->removedApplications);
|
||||
$this->assertSame($overlappingApplication, $this->removedApplications[0]);
|
||||
return $teamer;
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationStartingBeforeAndOverlapping(): void
|
||||
private function createContract(?CarbonPeriod $period, Teamer $teamer): Upload&MockObject
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$overlappingApplication = $this->createMockApplication('2025-01-05', '2025-01-15');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(1, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationEndingAfterAndOverlapping(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$overlappingApplication = $this->createMockApplication('2025-01-15', '2025-01-25');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(1, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationSpanningEntireConfirmedPeriod(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$spanningApplication = $this->createMockApplication('2025-01-05', '2025-01-25');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$spanningApplication]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(1, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationWithExactSameDates(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$sameApplication = $this->createMockApplication('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$sameApplication]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(1, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationEndingBeforeConfirmedPeriod(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, []);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationStartingAfterConfirmedPeriod(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, []);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testInvalidatesMultipleOverlappingApplications(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$application1 = $this->createMockApplication('2025-01-12', '2025-01-14');
|
||||
$application2 = $this->createMockApplication('2025-01-15', '2025-01-18');
|
||||
$application3 = $this->createMockApplication('2025-01-08', '2025-01-25');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$application1, $application2, $application3]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(3, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationTouchingOnBoundary(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, []);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateRejectedApplications(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, []);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationEndingOnSameDayAsConfirmedStart(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, []);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationStartingOnSameDayAsConfirmedEnd(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, []);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationWithOneDayActualOverlap(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$overlappingApplication = $this->createMockApplication('2025-01-05', '2025-01-11');
|
||||
|
||||
$document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer);
|
||||
$this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]);
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(1, $this->removedApplications);
|
||||
}
|
||||
|
||||
private function createMockUpload(string $type): Upload&MockObject
|
||||
{
|
||||
$upload = $this->createMock(Upload::class);
|
||||
$upload->method('getType')->willReturn($type);
|
||||
|
||||
return $upload;
|
||||
}
|
||||
|
||||
private function createMockContractWithPeriod(CarbonPeriod $period, Teamer $teamer): Upload&MockObject
|
||||
{
|
||||
$destination = $this->createMock(Destination::class);
|
||||
$destination->method('getHotelCode')->willReturn('TST');
|
||||
$destination->method('getDateFrom')->willReturn($period->start->toDateTimeImmutable());
|
||||
$destination->method('getDateTo')->willReturn($period->end->toDateTimeImmutable());
|
||||
|
||||
$assignment = $this->createMock(Assignment::class);
|
||||
$assignment->method('getEffectivePeriod')->willReturn($period);
|
||||
$assignment->method('getDestination')->willReturn($destination);
|
||||
|
||||
$disposition = $this->createMock(Disposition::class);
|
||||
$disposition->method('getAssignment')->willReturn($assignment);
|
||||
$disposition->method('getTeamer')->willReturn($teamer);
|
||||
|
||||
$upload = $this->createMock(Upload::class);
|
||||
$upload->method('getType')->willReturn(Upload::TYPE_CONTRACT);
|
||||
$upload->method('getDisposition')->willReturn($disposition);
|
||||
$document = $this->createMock(Upload::class);
|
||||
$document->method('getType')->willReturn(Upload::TYPE_CONTRACT);
|
||||
$document->method('getDisposition')->willReturn($disposition);
|
||||
|
||||
return $upload;
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function createMockApplication(string $dateFrom, string $dateTo): Application&MockObject
|
||||
private function createApplication(): Application&MockObject
|
||||
{
|
||||
$destination = $this->createMock(Destination::class);
|
||||
$destination->method('getHotelCode')->willReturn('TST');
|
||||
$destination->method('getDateFrom')->willReturn(new \DateTimeImmutable($dateFrom));
|
||||
$destination->method('getDateTo')->willReturn(new \DateTimeImmutable($dateTo));
|
||||
|
||||
$assignment = $this->createMock(Assignment::class);
|
||||
$assignment->method('getDestination')->willReturn($destination);
|
||||
$assignment->method('getUuid')->willReturn('assignment-uuid');
|
||||
$assignment->method('getEffectiveDateFrom')->willReturn(new \DateTimeImmutable('2025-01-12'));
|
||||
$assignment->method('getEffectiveDateTo')->willReturn(new \DateTimeImmutable('2025-01-18'));
|
||||
|
||||
$application = $this->createMock(Application::class);
|
||||
$application->method('getUuid')->willReturn('application-uuid');
|
||||
$application->method('getAssignment')->willReturn($assignment);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
private function setupQueryMock(Teamer $teamer, CarbonPeriod $period, array $applications): void
|
||||
{
|
||||
$query = $this->createMock(AbstractQuery::class);
|
||||
$query->method('getResult')->willReturn($applications);
|
||||
|
||||
$expr = $this->createMock(Expr::class);
|
||||
$expr->method('andX')->willReturnSelf();
|
||||
$expr->method('orX')->willReturnSelf();
|
||||
$expr->method('eq')->willReturnSelf();
|
||||
$expr->method('lt')->willReturnSelf();
|
||||
$expr->method('gt')->willReturnSelf();
|
||||
$expr->method('neq')->willReturnSelf();
|
||||
$expr->method('isNull')->willReturnSelf();
|
||||
$expr->method('isNotNull')->willReturnSelf();
|
||||
|
||||
$queryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$queryBuilder->method('expr')->willReturn($expr);
|
||||
$queryBuilder->method('innerJoin')->willReturnSelf();
|
||||
$queryBuilder->method('where')->willReturnSelf();
|
||||
$queryBuilder->method('setParameter')->willReturnSelf();
|
||||
$queryBuilder->method('getQuery')->willReturn($query);
|
||||
|
||||
$repository = $this->createMock(ApplicationRepository::class);
|
||||
$repository
|
||||
->method('createQueryBuilder')
|
||||
->with('application')
|
||||
->willReturn($queryBuilder);
|
||||
|
||||
$this->entityManager
|
||||
->method('getRepository')
|
||||
->with(Application::class)
|
||||
->willReturn($repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Teamer;
|
||||
use App\Repository\ApplicationRepository;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Query;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The overlap query behind the automatic purge of applications is expressed in DQL, so its semantics
|
||||
* cannot be covered by mocking the query builder - doing so is what let a wrong comparison ship
|
||||
* unnoticed. These tests compile the query instead and pin the parts that carry the semantics.
|
||||
*/
|
||||
class ApplicationRepositoryTest extends KernelTestCase
|
||||
{
|
||||
public function testOverlappingQueryUsesInclusiveBoundariesAndEffectiveDates(): void
|
||||
{
|
||||
$dql = $this->createOverlappingQuery()->getDQL();
|
||||
|
||||
// an assignment overrides the date range of its destination, so both have to be considered
|
||||
$this->assertStringContainsString(
|
||||
'(assignment.dateFrom IS NULL AND destination.dateFrom <= :dateTo)'
|
||||
.' OR (assignment.dateFrom IS NOT NULL AND assignment.dateFrom <= :dateTo)',
|
||||
$dql
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'(assignment.dateTo IS NULL AND destination.dateTo >= :dateFrom)'
|
||||
.' OR (assignment.dateTo IS NOT NULL AND assignment.dateTo >= :dateFrom)',
|
||||
$dql
|
||||
);
|
||||
|
||||
// strict comparisons would let assignments that touch on a boundary pass and could never
|
||||
// match a single day assignment at all
|
||||
$this->assertDoesNotMatchRegularExpression('/date(From|To) [<>] :date(From|To)/', $dql);
|
||||
}
|
||||
|
||||
public function testOverlappingQueryExcludesRejectedApplicationsAndDeadAssignments(): void
|
||||
{
|
||||
$dql = $this->createOverlappingQuery()->getDQL();
|
||||
|
||||
$this->assertStringContainsString('application.teamer = :teamer', $dql);
|
||||
$this->assertStringContainsString('application.status <> :application_rejected', $dql);
|
||||
$this->assertStringContainsString('assignment.status <> :assignment_called_off', $dql);
|
||||
$this->assertStringContainsString('assignment.deletedAt IS NULL', $dql);
|
||||
$this->assertStringContainsString('destination.deletedAt IS NULL', $dql);
|
||||
}
|
||||
|
||||
public function testOverlappingQueryBindsThePeriodBoundaries(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$period = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
|
||||
$query = $this->createOverlappingQuery($teamer, $period);
|
||||
|
||||
$this->assertSame($teamer, $query->getParameter('teamer')->getValue());
|
||||
$this->assertSame(Application::STATUS_REJECTED, $query->getParameter('application_rejected')->getValue());
|
||||
$this->assertSame(Assignment::STATUS_CALLED_OFF, $query->getParameter('assignment_called_off')->getValue());
|
||||
$this->assertSame('2025-01-10', $query->getParameter('dateFrom')->getValue()->format('Y-m-d'));
|
||||
$this->assertSame('2025-01-20', $query->getParameter('dateTo')->getValue()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
private function createOverlappingQuery(?Teamer $teamer = null, ?CarbonPeriod $period = null): Query
|
||||
{
|
||||
/** @var EntityManagerInterface $entityManager */
|
||||
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
/** @var ApplicationRepository $repository */
|
||||
$repository = $entityManager->getRepository(Application::class);
|
||||
|
||||
return $repository->getOverlappingForTeamerQuery(
|
||||
$teamer ?? new Teamer(),
|
||||
$period ?? new CarbonPeriod('2025-01-10', '2025-01-20')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user