feat: record application deleted event for statistics
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What became of an application that was hard deleted.
|
||||||
|
*
|
||||||
|
* Applications are removed on the way to a placement just as they are removed when they come
|
||||||
|
* to nothing, and the row is gone either way. Without this, staffing somebody would read as
|
||||||
|
* an application being thrown away, and "how many applications did we lose" would count every
|
||||||
|
* success along with every loss.
|
||||||
|
*/
|
||||||
|
enum ApplicationDeletionOutcome: string
|
||||||
|
{
|
||||||
|
/** It became a placement: a disposition was created from it in the same flush. */
|
||||||
|
case DISPOSED = 'disposed';
|
||||||
|
|
||||||
|
/** Nobody was staffed from it - withdrawn by the teamer, deleted by the office, or purged. */
|
||||||
|
case REMOVED = 'removed';
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ enum StatisticsEventName: string
|
|||||||
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
|
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
|
||||||
case DISPOSITION_DELETED = 'disposition.deleted';
|
case DISPOSITION_DELETED = 'disposition.deleted';
|
||||||
case APPLICATION_CREATED = 'application.created';
|
case APPLICATION_CREATED = 'application.created';
|
||||||
|
case APPLICATION_DELETED = 'application.deleted';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,7 @@ enum StatisticsEventName: string
|
|||||||
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
|
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
|
||||||
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
|
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
|
||||||
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
|
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
|
||||||
|
self::APPLICATION_DELETED => 'Bewerbung gelöscht',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Statistics\Collector;
|
||||||
|
|
||||||
|
use App\Entity\Application;
|
||||||
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Disposition;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Enum\ApplicationDeletionOutcome;
|
||||||
|
use App\Enum\StatisticsEventName;
|
||||||
|
use App\Service\Statistics\CollectedStatisticsEvent;
|
||||||
|
use App\Service\Statistics\StatisticsDimensions;
|
||||||
|
use App\Service\Statistics\StatisticsFlush;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An application leaving the table, and whether that was a loss.
|
||||||
|
*
|
||||||
|
* Applications are hard deleted with no cascade and no soft delete behind them, so until this
|
||||||
|
* existed an application.created row had no counterpart and nothing but the audit log said
|
||||||
|
* where the application went. Read from the flush rather than from the four controllers and
|
||||||
|
* listeners that remove one, so every path is counted.
|
||||||
|
*
|
||||||
|
* The outcome is the point. Staffing somebody deletes their application too - the dispose
|
||||||
|
* controller persists the placement and removes the application in the same flush - so
|
||||||
|
* counting deletions alone would read every success as a lost application.
|
||||||
|
*
|
||||||
|
* Dimensions are resolved here, during onFlush: by postFlush the entity is detached and its
|
||||||
|
* id nulled, so StatisticsRecorder could not resolve them from the subject.
|
||||||
|
*/
|
||||||
|
class ApplicationDeletedCollector implements StatisticsCollectorInterface
|
||||||
|
{
|
||||||
|
public function collect(StatisticsFlush $flush): iterable
|
||||||
|
{
|
||||||
|
$placements = $this->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<string, string>
|
||||||
|
*/
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,16 @@ namespace App\Tests\EventListener;
|
|||||||
|
|
||||||
use App\Entity\Application;
|
use App\Entity\Application;
|
||||||
use App\Entity\Assignment;
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Destination;
|
||||||
use App\Entity\Disposition;
|
use App\Entity\Disposition;
|
||||||
use App\Entity\JobProfile;
|
use App\Entity\JobProfile;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
|
use App\Enum\ApplicationDeletionOutcome;
|
||||||
use App\Enum\CallOffScope;
|
use App\Enum\CallOffScope;
|
||||||
use App\Enum\StatisticsEventName;
|
use App\Enum\StatisticsEventName;
|
||||||
use App\EventListener\StatisticsChangeSetListener;
|
use App\EventListener\StatisticsChangeSetListener;
|
||||||
use App\Service\Statistics\Collector\ApplicationCreatedCollector;
|
use App\Service\Statistics\Collector\ApplicationCreatedCollector;
|
||||||
|
use App\Service\Statistics\Collector\ApplicationDeletedCollector;
|
||||||
use App\Service\Statistics\Collector\CallOffCollector;
|
use App\Service\Statistics\Collector\CallOffCollector;
|
||||||
use App\Service\Statistics\Collector\DispositionDeletedCollector;
|
use App\Service\Statistics\Collector\DispositionDeletedCollector;
|
||||||
use App\Service\Statistics\Collector\JobProfileChangeCollector;
|
use App\Service\Statistics\Collector\JobProfileChangeCollector;
|
||||||
@@ -345,6 +348,111 @@ class StatisticsChangeSetListenerTest extends TestCase
|
|||||||
$this->flush([[$this->disposition(), ['remarks' => ['alt', 'neu']]]]);
|
$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.
|
* Editing an application later is not a second application.
|
||||||
*/
|
*/
|
||||||
@@ -423,6 +531,7 @@ class StatisticsChangeSetListenerTest extends TestCase
|
|||||||
new CallOffCollector(),
|
new CallOffCollector(),
|
||||||
new DispositionDeletedCollector(),
|
new DispositionDeletedCollector(),
|
||||||
new ApplicationCreatedCollector(),
|
new ApplicationCreatedCollector(),
|
||||||
|
new ApplicationDeletedCollector(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user