feat: collect assignment and disposition events for statistics

This commit is contained in:
fromme committed 2026-08-26 15:17:17 +02:00
1 parent e210021fe3
commit 2662d56ac1
26 files changed
+2557 -96

No files matched your search

@@ -0,0 +1,27 @@
<?php
namespace App\Service\Statistics;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\StatisticsEventName;
/**
* One statistic a collector found in a flush, before it is written.
*
* 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.
*/
final class CollectedStatisticsEvent
{
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public readonly StatisticsEventName $name,
public readonly Assignment|Disposition|Application $subject,
public readonly array $payload = [],
) {
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Service\Statistics\Collector;
use App\Entity\Application;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\StatisticsFlush;
/**
* How many applications come in, and for which hotel.
*
* Read from the insertions rather than from ApplicationCreatedEvent, so that any path which
* persists an application is counted - today there is only the teamer's own form, but an
* admin applying on somebody's behalf, an import or a fixture would otherwise go missing,
* silently and without anyone editing statistics code to cause it.
*
* Applications are hard deleted, so this is also the only durable record that one existed.
*/
class ApplicationCreatedCollector implements StatisticsCollectorInterface
{
public function collect(StatisticsFlush $flush): iterable
{
foreach ($flush->insertionsOf(Application::class) as $application) {
yield new CollectedStatisticsEvent(
StatisticsEventName::APPLICATION_CREATED,
$application,
);
}
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Service\Statistics\Collector;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\CallOffScope;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\StatisticsFlush;
/**
* Call-offs at both levels, deliberately in one collector.
*
* Cancelling a trip cascades onto every disposition on it in a single flush, so one office
* decision would otherwise read as N decisions. Whether a disposition was cascaded or ended
* on its own is only visible while both are still in the same unit of work - afterwards the
* two are indistinguishable - which is why these two metrics cannot be split apart.
*/
class CallOffCollector implements StatisticsCollectorInterface
{
public function collect(StatisticsFlush $flush): iterable
{
// Assignment and Disposition share the literal, so one constant serves both.
$calledOff = Assignment::STATUS_CALLED_OFF;
/** @var \SplObjectStorage<Assignment, int> $cancelledTrips */
$cancelledTrips = new \SplObjectStorage();
foreach ($flush->updatesOf(Assignment::class) as $assignment) {
if ($flush->transitionsTo($assignment, 'status', $calledOff)) {
$cancelledTrips[$assignment] = 0;
}
}
$dispositionEvents = [];
foreach ($flush->updatesOf(Disposition::class) as $disposition) {
if (!$flush->transitionsTo($disposition, 'status', $calledOff)) {
continue;
}
$assignment = $disposition->getAssignment();
$cascaded = null !== $assignment && $cancelledTrips->contains($assignment);
if ($cascaded) {
$cancelledTrips[$assignment] = $cancelledTrips[$assignment] + 1;
}
$dispositionEvents[] = new CollectedStatisticsEvent(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$disposition,
[
// Whose decision it was, as declared on the call-off form. The role of
// whoever clicked is recorded separately, in actor_role.
'called_off_by' => $disposition->getCalledOffBy(),
// How far it reached. The office can cancel one placement as well as a
// whole trip, so called_off_by does not imply this.
'scope' => ($cascaded ? CallOffScope::ASSIGNMENT : CallOffScope::DISPOSITION)->value,
'reason' => $disposition->getCalledOffReason(),
'previous_status' => $flush->previousValue($disposition, 'status'),
],
);
}
yield from $dispositionEvents;
// Emitted last, once the cascade has been counted: a cancelled trip is worth one row
// stating what it cost, rather than N rows that have to be counted back into a
// decision. Frequently zero - a trip cancelled before anyone was staffed is still a
// decision worth recording.
foreach ($cancelledTrips as $assignment) {
yield new CollectedStatisticsEvent(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
$assignment,
[
'dispositions_affected' => $cancelledTrips[$assignment],
'previous_status' => $flush->previousValue($assignment, 'status'),
],
);
}
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Service\Statistics\Collector;
use App\Entity\Assignment;
use App\Entity\JobProfile;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\StatisticsFlush;
/**
* How often admins change the job profile of an assignment.
*
* Read from the changeset rather than from the edit controller, because that is the only
* place the previous profile still exists - the column holds one value and every edit
* destroys the one before it - and because it catches every write path, not just the one
* route someone remembered to hook.
*/
class JobProfileChangeCollector implements StatisticsCollectorInterface
{
public function collect(StatisticsFlush $flush): iterable
{
foreach ($flush->updatesOf(Assignment::class) as $assignment) {
$changeSet = $flush->changeSet($assignment);
if (!isset($changeSet['jobProfile'])) {
continue;
}
[$old, $new] = $changeSet['jobProfile'];
if ($old === $new) {
continue;
}
yield new CollectedStatisticsEvent(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$assignment,
[
'from' => $this->describe($old),
'to' => $this->describe($new),
],
);
}
}
/**
* @return array{id: int|null, name: string|null}|null
*/
private function describe(?JobProfile $jobProfile): ?array
{
if (null === $jobProfile) {
return null;
}
return [
'id' => $jobProfile->getId(),
'name' => $jobProfile->getName(),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Service\Statistics\Collector;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\StatisticsFlush;
/**
* Finds the statistics of one concern in a flush.
*
* A new metric derived from a state change is a new implementation of this, not another
* branch in the listener. Implementations are autoconfigured, so a new collector needs no
* wiring and cannot be silently forgotten.
*
* Group by concern, not by metric: call-offs at both levels belong in one collector because
* they can only be told apart together, whereas a job profile change shares nothing with
* them. Order between collectors is irrelevant and nothing may depend on it.
*/
interface StatisticsCollectorInterface
{
/**
* @return iterable<CollectedStatisticsEvent>
*/
public function collect(StatisticsFlush $flush): iterable;
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Service\Statistics;
use Doctrine\ORM\UnitOfWork;
/**
* The entities changing in one flush, as collectors see them.
*
* Collectors get the whole flush rather than one entity at a time, because some statistics
* are only visible across entities: a disposition called off because its whole trip was
* cancelled is indistinguishable from a teamer dropping out, unless you can also see that
* the assignment is being called off in the same unit of work.
*
* Wrapping the UnitOfWork rather than passing it around keeps collectors readable and
* testable, and stops them reaching for the parts of it that write.
*/
final class StatisticsFlush
{
public function __construct(private readonly UnitOfWork $unitOfWork)
{
}
/**
* @return array<int, object>
*/
public function updates(): array
{
return $this->unitOfWork->getScheduledEntityUpdates();
}
/**
* @template T of object
*
* @param class-string<T> $class
*
* @return array<int, T>
*/
public function updatesOf(string $class): array
{
return array_values(array_filter(
$this->updates(),
static fn (object $entity): bool => $entity instanceof $class,
));
}
/**
* @return array<int, object>
*/
public function insertions(): array
{
return $this->unitOfWork->getScheduledEntityInsertions();
}
/**
* Newly created entities have no id yet at this point - Doctrine assigns it during the
* commit. Collectors may safely yield them anyway, because recording happens in
* postFlush, by which time the id is there.
*
* @template T of object
*
* @param class-string<T> $class
*
* @return array<int, T>
*/
public function insertionsOf(string $class): array
{
return array_values(array_filter(
$this->insertions(),
static fn (object $entity): bool => $entity instanceof $class,
));
}
/**
* @return array<string, mixed>
*/
public function changeSet(object $entity): array
{
return $this->unitOfWork->getEntityChangeSet($entity);
}
/**
* Whether a field is moving to a value it did not already hold.
*
* The second half matters: re-saving a record that is already in the target state is not
* a transition, and must not be counted as one.
*/
public function transitionsTo(object $entity, string $field, mixed $value): bool
{
$changeSet = $this->changeSet($entity);
if (!isset($changeSet[$field])) {
return false;
}
[$old, $new] = $changeSet[$field];
return $value === $new && $value !== $old;
}
public function previousValue(object $entity, string $field): mixed
{
return $this->changeSet($entity)[$field][0] ?? null;
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Service\Statistics;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\User;
use App\Enum\StatisticsActorRole;
use App\Enum\StatisticsEventName;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* The single write path into the statistics_event table.
*
* Writes go through DBAL rather than the ORM so that recording never touches the
* UnitOfWork: the Doctrine listeners that feed this service run during a flush, and
* persisting an entity from there would mean a nested flush and a recursion guard.
*
* Recording must never break the business action that triggered it, so failures are
* swallowed and reported to the audit log instead.
*/
class StatisticsRecorder
{
public function __construct(
private readonly Connection $connection,
private readonly Security $security,
private readonly LoggerInterface $logger,
) {
}
/**
* @param array<string, int|string|null> $dimensions
* @param array<string, mixed> $payload
*/
public function record(
StatisticsEventName $name,
array $dimensions = [],
array $payload = [],
?\DateTimeImmutable $occurredAt = null,
): void {
$data = array_merge([
'assignment_id' => null,
'disposition_id' => null,
'application_id' => null,
'teamer_id' => null,
'destination_id' => null,
'job_profile_id' => null,
'hotel_code' => null,
], $dimensions);
$data['name'] = $name->value;
$data['occurred_at'] = ($occurredAt ?? new \DateTimeImmutable())->format('Y-m-d H:i:s');
$user = $this->security->getUser();
$data['actor_id'] = $user instanceof User ? $user->getId() : null;
$data['actor_label'] = $user?->getUserIdentifier();
$data['actor_role'] = $this->resolveActorRole()->value;
try {
// Encoded inside the try: a payload that will not encode costs the event either
// way, and this is the difference between a log line naming the payload and one
// naming a column that rejected the literal false.
$data['payload'] = json_encode($payload, JSON_THROW_ON_ERROR);
$this->connection->insert('statistics_event', $data);
} catch (Exception|\JsonException $e) {
$this->logger->error('Could not record statistic event', [
'statistics_event' => $name->value,
'error' => $e->getMessage(),
]);
}
}
/**
* @param array<string, mixed> $payload
*/
public function recordForAssignment(
StatisticsEventName $name,
Assignment $assignment,
array $payload = [],
?\DateTimeImmutable $occurredAt = null,
): void {
$this->record($name, $this->dimensionsFromAssignment($assignment), $payload, $occurredAt);
}
/**
* @param array<string, mixed> $payload
*/
public function recordForDisposition(
StatisticsEventName $name,
Disposition $disposition,
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);
}
/**
* @param array<string, mixed> $payload
*/
public function recordForApplication(
StatisticsEventName $name,
Application $application,
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);
}
public function resolveActorRole(): StatisticsActorRole
{
if (null === $this->security->getUser()) {
return StatisticsActorRole::SYSTEM;
}
if ($this->security->isGranted('ROLE_ADMINISTRATIVE')) {
return StatisticsActorRole::ADMIN;
}
if ($this->security->isGranted('ROLE_TEAMER')) {
return StatisticsActorRole::TEAMER;
}
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(),
];
}
}