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
+2
View File
@@ -17,6 +17,7 @@ enum StatisticsEventName: string
case ASSIGNMENT_JOB_PROFILE_CHANGED = 'assignment.job_profile_changed';
case ASSIGNMENT_CALLED_OFF = 'assignment.called_off';
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
case DISPOSITION_DELETED = 'disposition.deleted';
case APPLICATION_CREATED = 'application.created';
public function label(): string
@@ -25,6 +26,7 @@ enum StatisticsEventName: string
self::ASSIGNMENT_JOB_PROFILE_CHANGED => 'Tätigkeitsprofil geändert',
self::ASSIGNMENT_CALLED_OFF => 'Reise abgesagt',
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
};
}
@@ -75,6 +75,14 @@ class StatisticsChangeSetListener implements ResetInterface
foreach ($pending as $event) {
$subject = $event->subject;
// Dimensions the collector froze at onFlush time - the subject is gone or
// detached by now, so there is nothing to resolve them from.
if (null !== $event->dimensions) {
$this->recorder->record($event->name, $event->dimensions, $event->payload);
continue;
}
if ($subject instanceof Assignment) {
$this->recorder->recordForAssignment($event->name, $subject, $event->payload);
@@ -12,16 +12,23 @@ use App\Enum\StatisticsEventName;
*
* Collectors run during onFlush, where the changeset still exists, but nothing may be
* written until the transaction has committed. This is what travels between the two.
*
* Normally the recorder resolves the dimension columns from $subject in postFlush. A
* collector that watches deletions cannot rely on that - the entity is detached and its id
* nulled by then - so it snapshots $dimensions now, and the listener writes them verbatim.
*/
final class CollectedStatisticsEvent
{
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $payload
* @param array<string, int|string|null>|null $dimensions pre-resolved at collect time; when
* set, written as-is instead of from $subject
*/
public function __construct(
public readonly StatisticsEventName $name,
public readonly Assignment|Disposition|Application $subject,
public readonly array $payload = [],
public readonly ?array $dimensions = null,
) {
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Service\Statistics\Collector;
use App\Entity\Disposition;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\StatisticsDimensions;
use App\Service\Statistics\StatisticsFlush;
/**
* A disposition being hard deleted by an admin.
*
* Its own concern: a deletion shares nothing with the call-off metrics, which have to be
* told apart together. Read from the flush rather than from the delete controller, so any
* path that removes a disposition is counted, and because a hard delete leaves nothing
* behind - this row is the only record the placement, its teamer, and the admin's
* justification ever existed.
*
* 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 DispositionDeletedCollector implements StatisticsCollectorInterface
{
public function collect(StatisticsFlush $flush): iterable
{
foreach ($flush->deletionsOf(Disposition::class) as $disposition) {
yield new CollectedStatisticsEvent(
StatisticsEventName::DISPOSITION_DELETED,
$disposition,
[
'previous_status' => $disposition->getStatus(),
// The reason typed on the delete form, bound onto the entity before the
// remove(). Nowhere else survives the deletion.
'remarks' => $disposition->getRemarks(),
// Non-null only if the placement had already been called off first.
'called_off_by' => $disposition->getCalledOffBy(),
],
StatisticsDimensions::forDisposition($disposition),
);
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Service\Statistics;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
/**
* The dimension columns of a statistics_event, resolved from a live entity.
*
* One place, because the SER-stripping hotel-code rule is already reimplemented in three
* spots (Destination, DispositionRepository, FeedbackRepository) and must not gain a fourth.
* StatisticsRecorder resolves dimensions here in postFlush for inserts and updates; a
* deletion collector calls the same methods during onFlush, while the entity is still whole.
*/
final class StatisticsDimensions
{
/**
* @return array<string, int|string|null>
*/
public static function forAssignment(?Assignment $assignment): array
{
$destination = $assignment?->getDestination();
return [
'assignment_id' => $assignment?->getId(),
'destination_id' => $destination?->getId(),
'job_profile_id' => $assignment?->getJobProfile()?->getId(),
'hotel_code' => $destination?->getHotelCodeNormalized(),
];
}
/**
* @return array<string, int|string|null>
*/
public static function forDisposition(Disposition $disposition): array
{
$dimensions = self::forAssignment($disposition->getAssignment());
$dimensions['disposition_id'] = $disposition->getId();
$dimensions['teamer_id'] = $disposition->getTeamer()?->getId();
return $dimensions;
}
/**
* @return array<string, int|string|null>
*/
public static function forApplication(Application $application): array
{
$dimensions = self::forAssignment($application->getAssignment());
$dimensions['application_id'] = $application->getId();
$dimensions['teamer_id'] = $application->getTeamer()?->getId();
return $dimensions;
}
}
@@ -71,6 +71,34 @@ final class StatisticsFlush
));
}
/**
* @return array<int, object>
*/
public function deletions(): array
{
return $this->unitOfWork->getScheduledEntityDeletions();
}
/**
* A scheduled-for-deletion entity still has its id and its initialised relations here,
* during onFlush. It does not in postFlush: Doctrine removes it from the identity map
* and nulls its generated id once the delete commits, so a collector that cares about a
* deletion has to read everything it needs off the entity now, not later.
*
* @template T of object
*
* @param class-string<T> $class
*
* @return array<int, T>
*/
public function deletionsOf(string $class): array
{
return array_values(array_filter(
$this->deletions(),
static fn (object $entity): bool => $entity instanceof $class,
));
}
/**
* @return array<string, mixed>
*/
+3 -26
View File
@@ -85,7 +85,7 @@ class StatisticsRecorder
array $payload = [],
?\DateTimeImmutable $occurredAt = null,
): void {
$this->record($name, $this->dimensionsFromAssignment($assignment), $payload, $occurredAt);
$this->record($name, StatisticsDimensions::forAssignment($assignment), $payload, $occurredAt);
}
/**
@@ -97,11 +97,7 @@ class StatisticsRecorder
array $payload = [],
?\DateTimeImmutable $occurredAt = null,
): void {
$dimensions = $this->dimensionsFromAssignment($disposition->getAssignment());
$dimensions['disposition_id'] = $disposition->getId();
$dimensions['teamer_id'] = $disposition->getTeamer()?->getId();
$this->record($name, $dimensions, $payload, $occurredAt);
$this->record($name, StatisticsDimensions::forDisposition($disposition), $payload, $occurredAt);
}
/**
@@ -113,11 +109,7 @@ class StatisticsRecorder
array $payload = [],
?\DateTimeImmutable $occurredAt = null,
): void {
$dimensions = $this->dimensionsFromAssignment($application->getAssignment());
$dimensions['application_id'] = $application->getId();
$dimensions['teamer_id'] = $application->getTeamer()?->getId();
$this->record($name, $dimensions, $payload, $occurredAt);
$this->record($name, StatisticsDimensions::forApplication($application), $payload, $occurredAt);
}
public function resolveActorRole(): StatisticsActorRole
@@ -136,19 +128,4 @@ class StatisticsRecorder
return StatisticsActorRole::SYSTEM;
}
/**
* @return array<string, int|string|null>
*/
private function dimensionsFromAssignment(?Assignment $assignment): array
{
$destination = $assignment?->getDestination();
return [
'assignment_id' => $assignment?->getId(),
'destination_id' => $destination?->getId(),
'job_profile_id' => $assignment?->getJobProfile()?->getId(),
'hotel_code' => $destination?->getHotelCodeNormalized(),
];
}
}