feat: record disposition deleted event for statistics

This commit is contained in:
2026-08-31 17:49:21 +02:00
parent 53465cc66a
commit 84441d9e69
10 changed files with 288 additions and 34 deletions
@@ -14,6 +14,7 @@ use App\Enum\StatisticsEventName;
use App\EventListener\StatisticsChangeSetListener;
use App\Service\Statistics\Collector\ApplicationCreatedCollector;
use App\Service\Statistics\Collector\CallOffCollector;
use App\Service\Statistics\Collector\DispositionDeletedCollector;
use App\Service\Statistics\Collector\JobProfileChangeCollector;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\ORM\EntityManagerInterface;
@@ -304,6 +305,46 @@ class StatisticsChangeSetListenerTest extends TestCase
$this->flush([], [$application]);
}
/**
* A hard delete leaves nothing behind, so this row is the only trace the placement, its
* teamer and the admin's justification ever existed. The dimensions have to be read
* while the entity is still whole - by postFlush Doctrine has nulled its id.
*/
public function testRecordsADispositionDeletion(): void
{
$disposition = $this->disposition();
$disposition
->setStatus(Disposition::STATUS_CONFIRMED)
->setRemarks('Doppelt eingeteilt')
;
$this->recorder
->expects($this->once())
->method('record')
->with(
StatisticsEventName::DISPOSITION_DELETED,
$this->anything(),
$this->callback(static function (array $payload): bool {
return Disposition::STATUS_CONFIRMED === $payload['previous_status']
&& 'Doppelt eingeteilt' === $payload['remarks']
&& null === $payload['called_off_by'];
})
)
;
$this->flush([], [], [$disposition]);
}
/**
* A disposition being edited is not a disposition being deleted.
*/
public function testADispositionUpdateIsNotADeletion(): void
{
$this->recorder->expects($this->never())->method('record');
$this->flush([[$this->disposition(), ['remarks' => ['alt', 'neu']]]]);
}
/**
* Editing an application later is not a second application.
*/
@@ -327,20 +368,22 @@ class StatisticsChangeSetListenerTest extends TestCase
/**
* @param array<int, array{0: object, 1: array<string, mixed>}> $changeSets
* @param array<int, object> $insertions
* @param array<int, object> $deletions
*/
private function flush(array $changeSets, array $insertions = []): void
private function flush(array $changeSets, array $insertions = [], array $deletions = []): void
{
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs($changeSets, $insertions));
$listener->onFlush($this->onFlushArgs($changeSets, $insertions, $deletions));
$listener->postFlush($this->postFlushArgs());
}
/**
* @param array<int, array{0: object, 1: array<string, mixed>}> $changeSets
* @param array<int, object> $insertions
* @param array<int, object> $deletions
*/
private function onFlushArgs(array $changeSets, array $insertions = []): OnFlushEventArgs
private function onFlushArgs(array $changeSets, array $insertions = [], array $deletions = []): OnFlushEventArgs
{
$entities = [];
$byEntity = new \SplObjectStorage();
@@ -353,6 +396,7 @@ class StatisticsChangeSetListenerTest extends TestCase
$unitOfWork = $this->createMock(UnitOfWork::class);
$unitOfWork->method('getScheduledEntityUpdates')->willReturn($entities);
$unitOfWork->method('getScheduledEntityInsertions')->willReturn($insertions);
$unitOfWork->method('getScheduledEntityDeletions')->willReturn($deletions);
$unitOfWork
->method('getEntityChangeSet')
->willReturnCallback(static fn (object $entity): array => $byEntity[$entity] ?? [])
@@ -377,6 +421,7 @@ class StatisticsChangeSetListenerTest extends TestCase
return new StatisticsChangeSetListener($this->recorder, [
new JobProfileChangeCollector(),
new CallOffCollector(),
new DispositionDeletedCollector(),
new ApplicationCreatedCollector(),
]);
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Statistics;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Service\Statistics\StatisticsDimensions;
use PHPUnit\Framework\TestCase;
/**
* The dimension columns are what every statistics screen groups by, so the SER-stripping
* hotel-code rule has to be applied here exactly as the recorder used to apply it inline.
*/
class StatisticsDimensionsTest extends TestCase
{
public function testFreezesTheNormalizedHotelCodeOfTheAssignment(): void
{
$assignment = (new Assignment())->setDestination((new Destination())->setHotelCode('SERZIL'));
$this->assertSame('ZIL', StatisticsDimensions::forAssignment($assignment)['hotel_code']);
}
public function testKeepsAnUnprefixedHotelCodeAsItIs(): void
{
$assignment = (new Assignment())->setDestination((new Destination())->setHotelCode('SBW'));
$this->assertSame('SBW', StatisticsDimensions::forAssignment($assignment)['hotel_code']);
}
public function testADispositionCarriesItsTeamerAndTheAssignmentDimensions(): void
{
$teamer = new Teamer();
$assignment = (new Assignment())->setDestination((new Destination())->setHotelCode('SERZIL'));
$disposition = new Disposition(new Application($assignment, $teamer));
$dimensions = StatisticsDimensions::forDisposition($disposition);
$this->assertArrayHasKey('disposition_id', $dimensions);
$this->assertSame($teamer->getId(), $dimensions['teamer_id']);
$this->assertSame('ZIL', $dimensions['hotel_code']);
}
/**
* A disposition can lose its assignment or teamer; the columns just go null, they do not
* blow up the flush that is trying to record the event.
*/
public function testDegradesToNullWithoutAnAssignmentOrTeamer(): void
{
$disposition = new Disposition(new Application(new Assignment(), new Teamer()));
$disposition->setAssignment(null);
$disposition->setTeamer(null);
$dimensions = StatisticsDimensions::forDisposition($disposition);
$this->assertNull($dimensions['assignment_id']);
$this->assertNull($dimensions['destination_id']);
$this->assertNull($dimensions['hotel_code']);
$this->assertNull($dimensions['teamer_id']);
}
}