From 66527515ef26164c908da8448cec5a5f54b1d9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 8 Sep 2026 14:11:30 +0200 Subject: [PATCH] feat: record application deleted event for statistics --- src/Enum/ApplicationDeletionOutcome.php | 20 ++++ src/Enum/StatisticsEventName.php | 2 + .../Collector/ApplicationDeletedCollector.php | 100 ++++++++++++++++ .../StatisticsChangeSetListenerTest.php | 109 ++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/Enum/ApplicationDeletionOutcome.php create mode 100644 src/Service/Statistics/Collector/ApplicationDeletedCollector.php diff --git a/src/Enum/ApplicationDeletionOutcome.php b/src/Enum/ApplicationDeletionOutcome.php new file mode 100644 index 0000000..0098582 --- /dev/null +++ b/src/Enum/ApplicationDeletionOutcome.php @@ -0,0 +1,20 @@ + 'Einsatz abgesagt', self::DISPOSITION_DELETED => 'Einteilung gelöscht', self::APPLICATION_CREATED => 'Bewerbung eingegangen', + self::APPLICATION_DELETED => 'Bewerbung gelöscht', }; } } diff --git a/src/Service/Statistics/Collector/ApplicationDeletedCollector.php b/src/Service/Statistics/Collector/ApplicationDeletedCollector.php new file mode 100644 index 0000000..23ee02a --- /dev/null +++ b/src/Service/Statistics/Collector/ApplicationDeletedCollector.php @@ -0,0 +1,100 @@ +placementsCreatedIn($flush); + + foreach ($flush->deletionsOf(Application::class) as $application) { + $key = $this->key($application->getAssignment(), $application->getTeamer()); + $dispositionUuid = null !== $key ? ($placements[$key] ?? null) : null; + + yield new CollectedStatisticsEvent( + StatisticsEventName::APPLICATION_DELETED, + $application, + [ + 'outcome' => (null !== $dispositionUuid + ? ApplicationDeletionOutcome::DISPOSED + : ApplicationDeletionOutcome::REMOVED)->value, + 'previous_status' => $application->getStatus(), + // The only handle that outlives the row - application_id points at a + // deleted record - and the same value LoggingSubscriber writes to the + // audit log, which is what makes the two joinable. + 'application_uuid' => $application->getUuid(), + 'disposition_uuid' => $dispositionUuid, + ], + StatisticsDimensions::forApplication($application), + ); + } + } + + /** + * The placements created in this flush, by the teamer and assignment they are for. + * + * Keyed on uuids rather than ids or object identity: a disposition being inserted has no + * id yet at this point, and the application it came from loses its own the moment the + * delete commits. Disposition::__construct() copies the assignment and the teamer off the + * application without keeping a reference back to it, so that pair is all there is to + * match on - and a teamer holds at most one application per overlapping period, which is + * what ApplicationValidator enforces. + * + * @return array + */ + private function placementsCreatedIn(StatisticsFlush $flush): array + { + $placements = []; + + foreach ($flush->insertionsOf(Disposition::class) as $disposition) { + $key = $this->key($disposition->getAssignment(), $disposition->getTeamer()); + + if (null === $key) { + continue; + } + + $placements[$key] = $disposition->getUuid(); + } + + return $placements; + } + + /** + * Null when either side is missing: two half-anonymous records must not match each other + * on the strength of what they both lack. + */ + private function key(?Assignment $assignment, ?Teamer $teamer): ?string + { + if (null === $assignment || null === $teamer) { + return null; + } + + return sprintf('%s:%s', $assignment->getUuid(), $teamer->getUuid()); + } +} diff --git a/tests/EventListener/StatisticsChangeSetListenerTest.php b/tests/EventListener/StatisticsChangeSetListenerTest.php index 4a4c9e0..a17b8ef 100644 --- a/tests/EventListener/StatisticsChangeSetListenerTest.php +++ b/tests/EventListener/StatisticsChangeSetListenerTest.php @@ -6,13 +6,16 @@ namespace App\Tests\EventListener; use App\Entity\Application; use App\Entity\Assignment; +use App\Entity\Destination; use App\Entity\Disposition; use App\Entity\JobProfile; use App\Entity\Teamer; +use App\Enum\ApplicationDeletionOutcome; use App\Enum\CallOffScope; use App\Enum\StatisticsEventName; use App\EventListener\StatisticsChangeSetListener; use App\Service\Statistics\Collector\ApplicationCreatedCollector; +use App\Service\Statistics\Collector\ApplicationDeletedCollector; use App\Service\Statistics\Collector\CallOffCollector; use App\Service\Statistics\Collector\DispositionDeletedCollector; use App\Service\Statistics\Collector\JobProfileChangeCollector; @@ -345,6 +348,111 @@ class StatisticsChangeSetListenerTest extends TestCase $this->flush([[$this->disposition(), ['remarks' => ['alt', 'neu']]]]); } + /** + * A teamer withdrawing leaves nothing behind - the application row is hard deleted - so + * this is the only trace that they applied and then thought better of it. + */ + public function testRecordsAWithdrawnApplicationAsALoss(): void + { + $application = new Application(new Assignment(), new Teamer()); + $application->setStatus(Application::STATUS_PENDING); + + $this->recorder + ->expects($this->once()) + ->method('record') + ->with( + StatisticsEventName::APPLICATION_DELETED, + $this->anything(), + $this->callback(static function (array $payload) use ($application): bool { + return ApplicationDeletionOutcome::REMOVED->value === $payload['outcome'] + && Application::STATUS_PENDING === $payload['previous_status'] + && $application->getUuid() === $payload['application_uuid'] + && null === $payload['disposition_uuid']; + }) + ) + ; + + $this->flush([], [], [$application]); + } + + /** + * Staffing somebody deletes their application too: the dispose controller persists the + * placement and removes the application in one flush. Counting that as a lost application + * would turn every success into a failure. + */ + public function testAnApplicationTurnedIntoAPlacementIsNotALoss(): void + { + $assignment = new Assignment(); + $teamer = new Teamer(); + $application = new Application($assignment, $teamer); + $disposition = new Disposition($application); + + $this->recorder + ->expects($this->once()) + ->method('record') + ->with( + StatisticsEventName::APPLICATION_DELETED, + $this->anything(), + $this->callback(static function (array $payload) use ($disposition): bool { + return ApplicationDeletionOutcome::DISPOSED->value === $payload['outcome'] + && $disposition->getUuid() === $payload['disposition_uuid']; + }) + ) + ; + + $this->flush([], [$disposition], [$application]); + } + + /** + * Two unrelated things happening in one flush do not make one the cause of the other. The + * application still counts as lost. + */ + public function testAPlacementForSomebodyElseDoesNotExcuseTheDeletion(): void + { + $assignment = new Assignment(); + $application = new Application($assignment, new Teamer()); + $disposition = new Disposition(new Application($assignment, new Teamer())); + + $this->recorder + ->expects($this->once()) + ->method('record') + ->with( + StatisticsEventName::APPLICATION_DELETED, + $this->anything(), + $this->callback(static function (array $payload): bool { + return ApplicationDeletionOutcome::REMOVED->value === $payload['outcome'] + && null === $payload['disposition_uuid']; + }) + ) + ; + + $this->flush([], [$disposition], [$application]); + } + + /** + * The dimensions have to be frozen while the entity is still whole. Going through + * recordForApplication() instead would resolve them in postFlush, by which time Doctrine + * has detached the application and nulled its id, and the row would name nothing. + */ + public function testADeletedApplicationCarriesDimensionsFrozenDuringTheFlush(): void + { + $assignment = (new Assignment())->setDestination((new Destination())->setHotelCode('SERZIL')); + + $this->recorder->expects($this->never())->method('recordForApplication'); + + $this->recorder + ->expects($this->once()) + ->method('record') + ->with( + StatisticsEventName::APPLICATION_DELETED, + $this->callback(static fn (array $dimensions): bool => 'ZIL' === $dimensions['hotel_code']), + $this->anything() + ) + ; + + $this->flush([], [], [new Application($assignment, new Teamer())]); + } + /** * Editing an application later is not a second application. */ @@ -423,6 +531,7 @@ class StatisticsChangeSetListenerTest extends TestCase new CallOffCollector(), new DispositionDeletedCollector(), new ApplicationCreatedCollector(), + new ApplicationDeletedCollector(), ]); }