feat: collect assignment and disposition events for statistics
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user