feat: automatically purge applications with date ranges overlapping an accepted application
closes #869aug4uz
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Upload;
|
||||
use App\Event\DocumentConfirmedEvent;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
|
||||
#[AsEventListener(event: DocumentConfirmedEvent::NAME, method: 'onDocumentConfirmed')]
|
||||
class InvalidateApplicationsListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onDocumentConfirmed(DocumentConfirmedEvent $event): void
|
||||
{
|
||||
$document = $event->getDocument();
|
||||
|
||||
if (Upload::TYPE_CONTRACT !== $document->getType()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disposition = $document->getDisposition();
|
||||
$assignment = $disposition->getAssignment();
|
||||
$teamer = $disposition->getTeamer();
|
||||
$period = $assignment->getEffectivePeriod();
|
||||
|
||||
// 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
|
||||
$qb = $this
|
||||
->entityManager
|
||||
->getRepository(Application::class)
|
||||
->createQueryBuilder('application');
|
||||
|
||||
/** @var array<Application> $applications */
|
||||
$applications = $qb
|
||||
->innerJoin('application.assignment', 'assignment')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('application.teamer', ':teamer'),
|
||||
$qb->expr()->lt('assignment.dateFrom', ':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()
|
||||
;
|
||||
|
||||
if (0 === count($applications)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($applications as $application) {
|
||||
$destination = $application->getAssignment()->getDestination();
|
||||
$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'),
|
||||
]);
|
||||
$this->entityManager->remove($application);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Destination;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
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;
|
||||
|
||||
class InvalidateApplicationsListenerTest extends TestCase
|
||||
{
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private InvalidateApplicationsListener $listener;
|
||||
private array $removedApplications = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$this->removedApplications = [];
|
||||
|
||||
$this->entityManager
|
||||
->method('remove')
|
||||
->willReturnCallback(function (Application $application): void {
|
||||
$this->removedApplications[] = $application;
|
||||
});
|
||||
|
||||
$this->listener = new InvalidateApplicationsListener(
|
||||
$this->entityManager,
|
||||
$logger
|
||||
);
|
||||
}
|
||||
|
||||
public function testDoesNotInvalidateApplicationsForNonContractDocuments(): void
|
||||
{
|
||||
$document = $this->createMockUpload(Upload::TYPE_INVOICE);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('getRepository');
|
||||
|
||||
$event = new DocumentConfirmedEvent($document);
|
||||
$this->listener->onDocumentConfirmed($event);
|
||||
|
||||
$this->assertCount(0, $this->removedApplications);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationCompletelyWithinConfirmedPeriod(): void
|
||||
{
|
||||
$teamer = $this->createMock(Teamer::class);
|
||||
$confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20');
|
||||
$overlappingApplication = $this->createMockApplication('2025-01-12', '2025-01-18');
|
||||
|
||||
$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]);
|
||||
}
|
||||
|
||||
public function testInvalidatesApplicationStartingBeforeAndOverlapping(): void
|
||||
{
|
||||
$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);
|
||||
|
||||
return $upload;
|
||||
}
|
||||
|
||||
private function createMockApplication(string $dateFrom, string $dateTo): 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);
|
||||
|
||||
$application = $this->createMock(Application::class);
|
||||
$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('eq')->willReturnSelf();
|
||||
$expr->method('lte')->willReturnSelf();
|
||||
$expr->method('gte')->willReturnSelf();
|
||||
$expr->method('neq')->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user