diff --git a/src/EventListener/InvalidateApplicationsListener.php b/src/EventListener/InvalidateApplicationsListener.php new file mode 100644 index 0000000..b7aac3d --- /dev/null +++ b/src/EventListener/InvalidateApplicationsListener.php @@ -0,0 +1,80 @@ +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 $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(); + } +} diff --git a/tests/EventListener/InvalidateApplicationsListenerTest.php b/tests/EventListener/InvalidateApplicationsListenerTest.php new file mode 100644 index 0000000..80941e4 --- /dev/null +++ b/tests/EventListener/InvalidateApplicationsListenerTest.php @@ -0,0 +1,332 @@ +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); + } +}