feat: record application deleted event for statistics

This commit is contained in:
2026-09-08 14:11:30 +02:00
parent 3d4d92b5c4
commit 66527515ef
4 changed files with 231 additions and 0 deletions
+20
View File
@@ -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';
}
+2
View File
@@ -19,6 +19,7 @@ enum StatisticsEventName: string
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
case DISPOSITION_DELETED = 'disposition.deleted';
case APPLICATION_CREATED = 'application.created';
case APPLICATION_DELETED = 'application.deleted';
public function label(): string
{
@@ -28,6 +29,7 @@ enum StatisticsEventName: string
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
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());
}
}