feat: collect assignment and disposition events for statistics

This commit is contained in:
2026-08-26 15:17:17 +02:00
parent e210021fe3
commit 2662d56ac1
26 changed files with 2557 additions and 96 deletions
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\StatisticsEventName;
use App\Repository\StatisticsEventRepository;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:statistics:backfill', description: 'Seeds statistics_event from data that already exists')]
class StatisticsBackfillCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly StatisticsEventRepository $statisticsEventRepository,
private readonly StatisticsRecorder $recorder,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing it');
}
/**
* Recovers the statistics that current data still contains, so charts do not start
* from an empty table on the day this ships.
*
* Runs on the CLI, where there is no security token, so every row it writes is
* attributed to the system actor rather than to whoever originally acted. Safe to run
* more than once: subjects already recorded are skipped.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
if ($dryRun) {
$io->note('Dry run - nothing is written.');
}
$applications = $this->backfillApplications($dryRun);
$io->success(sprintf('%d application(s) backfilled.', $applications));
$callOffs = $this->backfillCallOffs($dryRun);
$io->success(sprintf('%d called off disposition(s) backfilled.', $callOffs));
$assignmentCallOffs = $this->backfillAssignmentCallOffs($dryRun);
$io->success(sprintf('%d called off assignment(s) backfilled.', $assignmentCallOffs));
$io->warning(
'Job profile changes cannot be backfilled: only the current profile is stored, '
.'so the history does not exist. That metric starts from now.'
);
return Command::SUCCESS;
}
private function backfillApplications(bool $dryRun): int
{
$recorded = array_flip($this->statisticsEventRepository->findRecordedSubjectIds(
StatisticsEventName::APPLICATION_CREATED,
'applicationId',
));
$count = 0;
foreach ($this->iterateAll(Application::class) as $application) {
if (isset($recorded[$application->getId()])) {
continue;
}
++$count;
if ($dryRun) {
continue;
}
$this->recorder->recordForApplication(
StatisticsEventName::APPLICATION_CREATED,
$application,
['backfilled' => true],
$application->getCreatedAt(),
);
}
return $count;
}
private function backfillCallOffs(bool $dryRun): int
{
$recorded = array_flip($this->statisticsEventRepository->findRecordedSubjectIds(
StatisticsEventName::DISPOSITION_CALLED_OFF,
'dispositionId',
));
$count = 0;
foreach ($this->iterateAll(Disposition::class, ['status' => Disposition::STATUS_CALLED_OFF]) as $disposition) {
if (isset($recorded[$disposition->getId()])) {
continue;
}
++$count;
if ($dryRun) {
continue;
}
$this->recorder->recordForDisposition(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$disposition,
[
'called_off_by' => $disposition->getCalledOffBy(),
'reason' => $disposition->getCalledOffReason(),
'backfilled' => true,
// updatedAt is whenever the row was last touched for any reason, which
// is only the call-off date if nothing happened afterwards.
'approximate_date' => true,
],
$disposition->getUpdatedAt() ?? $disposition->getCreatedAt(),
);
}
return $count;
}
private function backfillAssignmentCallOffs(bool $dryRun): int
{
$recorded = array_flip($this->statisticsEventRepository->findRecordedSubjectIds(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
'assignmentId',
));
$count = 0;
foreach ($this->iterateAll(Assignment::class, ['status' => Assignment::STATUS_CALLED_OFF]) as $assignment) {
if (isset($recorded[$assignment->getId()])) {
continue;
}
++$count;
if ($dryRun) {
continue;
}
$affected = 0;
foreach ($assignment->getDispositions() as $disposition) {
if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
++$affected;
}
}
$this->recorder->recordForAssignment(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
$assignment,
[
// Counted as things stand now, not as they stood on the day. An
// assignment called off through the old status dropdown left its
// dispositions running, so this can legitimately be 0.
'dispositions_affected' => $affected,
'backfilled' => true,
'approximate_date' => true,
],
$assignment->getUpdatedAt() ?? $assignment->getCreatedAt(),
);
}
return $count;
}
/**
* @template T of object
*
* @param class-string<T> $entityClass
* @param array<string, mixed> $criteria
*
* @return iterable<T>
*/
private function iterateAll(string $entityClass, array $criteria = []): iterable
{
$repository = $this->entityManager->getRepository($entityClass);
$qb = $repository->createQueryBuilder('entity');
foreach ($criteria as $field => $value) {
$qb
->andWhere($qb->expr()->eq(sprintf('entity.%s', $field), ':'.$field))
->setParameter($field, $value)
;
}
// Kept out of the identity map: these tables are large and every row is read once.
foreach ($qb->getQuery()->toIterable() as $entity) {
yield $entity;
$this->entityManager->detach($entity);
}
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
namespace App\Entity;
use App\Enum\StatisticsActorRole;
use App\Enum\StatisticsEventName;
use App\Repository\StatisticsEventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* An append-only record of something that happened, kept so it can be counted later.
*
* Rows are never updated or deleted. The columns are the dimensions we slice by, so
* they are indexed and queryable; anything we only ever read back sits in $payload.
* That split is what keeps the table open for metrics nobody has asked for yet.
*
* The *Id columns are plain integers rather than relations on purpose: an
* Application is hard deleted, and its statistics must outlive it.
*/
#[ORM\Entity(repositoryClass: StatisticsEventRepository::class)]
#[ORM\Index(columns: ['name', 'occurred_at'])]
#[ORM\Index(columns: ['name', 'hotel_code'])]
#[ORM\Index(columns: ['assignment_id'])]
#[ORM\Index(columns: ['teamer_id'])]
class StatisticsEvent
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, enumType: StatisticsEventName::class)]
private StatisticsEventName $name;
#[ORM\Column]
private \DateTimeImmutable $occurredAt;
#[ORM\Column(nullable: true)]
private ?int $actorId = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $actorLabel = null;
#[ORM\Column(length: 32, nullable: true, enumType: StatisticsActorRole::class)]
private ?StatisticsActorRole $actorRole = null;
#[ORM\Column(nullable: true)]
private ?int $assignmentId = null;
#[ORM\Column(nullable: true)]
private ?int $dispositionId = null;
#[ORM\Column(nullable: true)]
private ?int $applicationId = null;
#[ORM\Column(nullable: true)]
private ?int $teamerId = null;
#[ORM\Column(nullable: true)]
private ?int $destinationId = null;
#[ORM\Column(nullable: true)]
private ?int $jobProfileId = null;
/**
* The normalized hotel code - SER-prefixed codes already stripped - frozen at write
* time so later corrections to the destination do not silently rewrite past periods.
*
* Careful: feedback.hotel_code is the opposite convention. It stores the raw code
* (SERALB) and normalizes at query time, so the two columns share a name and disagree
* on their values. Comparing or joining them directly returns nothing, silently.
*/
#[ORM\Column(length: 8, nullable: true)]
private ?string $hotelCode = null;
/** @var array<string, mixed> */
#[ORM\Column(type: Types::JSON)]
private array $payload = [];
public function __construct(StatisticsEventName $name, \DateTimeImmutable $occurredAt)
{
$this->name = $name;
$this->occurredAt = $occurredAt;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): StatisticsEventName
{
return $this->name;
}
public function getOccurredAt(): \DateTimeImmutable
{
return $this->occurredAt;
}
public function getActorId(): ?int
{
return $this->actorId;
}
public function setActorId(?int $actorId): static
{
$this->actorId = $actorId;
return $this;
}
public function getActorLabel(): ?string
{
return $this->actorLabel;
}
public function setActorLabel(?string $actorLabel): static
{
$this->actorLabel = $actorLabel;
return $this;
}
public function getActorRole(): ?StatisticsActorRole
{
return $this->actorRole;
}
public function setActorRole(?StatisticsActorRole $actorRole): static
{
$this->actorRole = $actorRole;
return $this;
}
public function getAssignmentId(): ?int
{
return $this->assignmentId;
}
public function setAssignmentId(?int $assignmentId): static
{
$this->assignmentId = $assignmentId;
return $this;
}
public function getDispositionId(): ?int
{
return $this->dispositionId;
}
public function setDispositionId(?int $dispositionId): static
{
$this->dispositionId = $dispositionId;
return $this;
}
public function getApplicationId(): ?int
{
return $this->applicationId;
}
public function setApplicationId(?int $applicationId): static
{
$this->applicationId = $applicationId;
return $this;
}
public function getTeamerId(): ?int
{
return $this->teamerId;
}
public function setTeamerId(?int $teamerId): static
{
$this->teamerId = $teamerId;
return $this;
}
public function getDestinationId(): ?int
{
return $this->destinationId;
}
public function setDestinationId(?int $destinationId): static
{
$this->destinationId = $destinationId;
return $this;
}
public function getJobProfileId(): ?int
{
return $this->jobProfileId;
}
public function setJobProfileId(?int $jobProfileId): static
{
$this->jobProfileId = $jobProfileId;
return $this;
}
public function getHotelCode(): ?string
{
return $this->hotelCode;
}
public function setHotelCode(?string $hotelCode): static
{
$this->hotelCode = $hotelCode;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getPayload(): array
{
return $this->payload;
}
/**
* @param array<string, mixed> $payload
*/
public function setPayload(array $payload): static
{
$this->payload = $payload;
return $this;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Enum;
/**
* How far a call-off reached.
*
* Distinct from Disposition::$calledOffBy, which records whose decision it was. The office
* can cancel a single placement just as a whole trip can fall away, so who decided says
* nothing about how many teamers it cost.
*/
enum CallOffScope: string
{
/** The whole trip was cancelled and every teamer on it lost their placement. */
case ASSIGNMENT = 'assignment';
/** This placement alone ended; the assignment and everyone else carried on. */
case DISPOSITION = 'disposition';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Enum;
/**
* The coarse role of whoever triggered a statistic event.
*
* This is who *acted*, derived from the security token. It is deliberately not the
* same as Disposition::$calledOffBy, which is what an admin declares on the call-off
* form about whose decision it was.
*/
enum StatisticsActorRole: string
{
case ADMIN = 'admin';
case TEAMER = 'teamer';
case SYSTEM = 'system';
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Enum;
/**
* Which date a statistics query means when it is given a period.
*
* The two answer different questions and routinely disagree: an application for the
* coming winter arrives months before the season it belongs to.
*/
enum StatisticsDateBasis: string
{
/**
* The season the assignment runs in - assignment dates, falling back to the
* destination's. This is what every other statistics screen in the app filters by,
* so it is the default here too.
*/
case SEASON = 'season';
/**
* When the event was actually recorded. Wanted for genuine time series, such as
* call-offs per month.
*/
case OCCURRENCE = 'occurrence';
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Enum;
/**
* The metrics collected into the statistics_event table.
*
* Adding a metric means adding a case here plus one StatisticsCollectorInterface that
* records it. The table itself does not change, and neither does
* StatisticsChangeSetListener.
*
* A case's value is what ends up in statistics_event.name, so renaming one orphans every
* row already recorded under the old value.
*/
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 APPLICATION_CREATED = 'application.created';
public function label(): string
{
return match ($this) {
self::ASSIGNMENT_JOB_PROFILE_CHANGED => 'Tätigkeitsprofil geändert',
self::ASSIGNMENT_CALLED_OFF => 'Reise abgesagt',
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
};
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\EventListener;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\Collector\StatisticsCollectorInterface;
use App\Service\Statistics\StatisticsFlush;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Contracts\Service\ResetInterface;
/**
* Runs the statistics collectors against every flush.
*
* This class holds no knowledge of any individual metric; that lives in the collectors, so
* adding one never means editing here. What it does own is the timing, which is the part
* that is easy to get wrong: changesets only exist during onFlush, but nothing may be
* written until the transaction has committed, or a rolled back flush would leave phantom
* statistics behind.
*
* The batch belongs to one flush and no more. A flush that throws never reaches postFlush,
* so whatever it collected has to be dropped rather than handed to the next flush that
* happens to succeed - in a worker that is a different message entirely.
*/
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
class StatisticsChangeSetListener implements ResetInterface
{
/**
* @var array<int, CollectedStatisticsEvent>
*/
private array $pending = [];
/**
* @param iterable<StatisticsCollectorInterface> $collectors
*/
public function __construct(
private readonly StatisticsRecorder $recorder,
private readonly iterable $collectors,
) {
}
public function onFlush(OnFlushEventArgs $args): void
{
// Each flush starts from empty: anything still here was collected by a flush that
// never committed, and writing it now would date it wrongly and attribute it to
// whoever happens to be acting instead.
$this->pending = [];
$flush = new StatisticsFlush($args->getObjectManager()->getUnitOfWork());
foreach ($this->collectors as $collector) {
foreach ($collector->collect($flush) as $event) {
$this->pending[] = $event;
}
}
}
public function postFlush(PostFlushEventArgs $args): void
{
if (0 === count($this->pending)) {
return;
}
// Taken and cleared up front: recording must not be repeated if anything
// downstream flushes again.
$pending = $this->pending;
$this->pending = [];
foreach ($pending as $event) {
$subject = $event->subject;
if ($subject instanceof Assignment) {
$this->recorder->recordForAssignment($event->name, $subject, $event->payload);
continue;
}
if ($subject instanceof Disposition) {
$this->recorder->recordForDisposition($event->name, $subject, $event->payload);
continue;
}
$this->recorder->recordForApplication($event->name, $subject, $event->payload);
}
}
/**
* Long-running processes reset their services between messages, which is where a failed
* flush's leftovers would otherwise sit waiting for an unrelated flush to write them.
*/
public function reset(): void
{
$this->pending = [];
}
}
+50 -8
View File
@@ -22,6 +22,19 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AssignmentType extends AbstractType
{
/**
* The statuses an admin may move an assignment between at will.
*
* Calling off is deliberately absent: it has to go through the dedicated action, which
* also cancels every disposition on the assignment. Choosing it here used to set the
* assignment to called_off and leave its teamers holding a live placement on a
* cancelled trip.
*/
private const STATUS_CHOICES = [
'Entwurf' => Assignment::STATUS_DRAFT,
'veröffentlicht' => Assignment::STATUS_PUBLISHED,
];
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
@@ -29,14 +42,8 @@ class AssignmentType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('status', ChoiceType::class, [
'label' => 'Status',
'choices' => [
'Entwurf' => Assignment::STATUS_DRAFT,
'veröffentlicht' => Assignment::STATUS_PUBLISHED,
'abgesagt' => Assignment::STATUS_CALLED_OFF,
],
])
// status is added in PRE_SET_DATA, where the assignment behind the form is
// known - an already called off one has to keep that value in the list.
->add('availableDispositions', IntegerType::class, [
'label' => 'zu vergeben',
'required' => false,
@@ -138,6 +145,9 @@ class AssignmentType extends AbstractType
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$form = $event->getForm();
$data = $event->getData();
$this->addStatusField($data, $form);
$destination = $data->getDestination();
if (null === $destination) {
@@ -170,6 +180,38 @@ class AssignmentType extends AbstractType
;
}
/**
* The statuses this form may set on a given assignment.
*
* An assignment that is already called off has to keep that value in the list, or the
* form could not render it and any save would silently reset the status. It stays
* switchable back to published, since there is no other way to undo a call-off made by
* mistake - what is blocked is entering the state, not leaving it.
*
* ChoiceType validates a submission against whatever this returns, so it holds against
* a hand-crafted POST too, not just the rendered select.
*
* @return array<string, string>
*/
public static function statusChoices(?Assignment $assignment): array
{
$choices = self::STATUS_CHOICES;
if (null !== $assignment && Assignment::STATUS_CALLED_OFF === $assignment->getStatus()) {
$choices['abgesagt'] = Assignment::STATUS_CALLED_OFF;
}
return $choices;
}
private function addStatusField(?Assignment $assignment, FormInterface $form): void
{
$form->add('status', ChoiceType::class, [
'label' => 'Status',
'choices' => self::statusChoices($assignment),
]);
}
private function addPickupField(Destination $destination, FormInterface $form): void
{
$choices = [];
-25
View File
@@ -417,29 +417,4 @@ class AssignmentRepository extends ServiceEntityRepository
->getQuery()
;
}
public function getSelectableHotelCodes(): array
{
$qb = $this->createQueryBuilder('assignment');
$codes = [];
$result = $qb
->select('destination.hotelCode')
->innerJoin('assignment.destination', 'destination')
->groupBy('destination.hotelCode')
->orderBy('destination.hotelCode', 'ASC')
->getQuery()
->getArrayResult();
foreach ($result as $row) {
if (str_starts_with($row['hotelCode'], 'SER')) {
$codes[] = substr($row['hotelCode'], 2, 3);
} else {
$codes[] = substr($row['hotelCode'], 0, 3);
}
}
return array_unique($codes);
}
}
+3 -63
View File
@@ -6,6 +6,7 @@ use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Repository\Filter\SeasonPeriodFilter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
@@ -343,38 +344,7 @@ class DispositionRepository extends ServiceEntityRepository
->orderBy('destination.hotel', 'ASC')
;
// Filter by effective date range (assignment date or fallback to destination date)
if (null !== $dateFrom) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateFrom'),
$qb->expr()->gte('assignment.dateFrom', ':dateFrom')
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateFrom'),
$qb->expr()->gte('destination.dateFrom', ':dateFrom')
)
))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->lte('assignment.dateTo', ':dateTo')
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->lte('destination.dateTo', ':dateTo')
)
))
->setParameter('dateTo', $dateTo)
;
}
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
$results = $qb->getQuery()->getResult();
@@ -435,37 +405,7 @@ class DispositionRepository extends ServiceEntityRepository
->orderBy('hotelCode', 'ASC')
;
if (null !== $dateFrom) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateFrom'),
$qb->expr()->gte('assignment.dateFrom', ':dateFrom')
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateFrom'),
$qb->expr()->gte('destination.dateFrom', ':dateFrom')
)
))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->lte('assignment.dateTo', ':dateTo')
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->lte('destination.dateTo', ':dateTo')
)
))
->setParameter('dateTo', $dateTo)
;
}
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
$results = $qb->getQuery()->getResult();
@@ -0,0 +1,59 @@
<?php
namespace App\Repository\Filter;
use Doctrine\ORM\QueryBuilder;
/**
* Narrows a query to the season an assignment runs in.
*
* The season is assignment.dateFrom/dateTo falling back to the destination's, mirroring
* Assignment::getEffectivePeriod(), which DQL cannot call. Every statistics screen means
* this by a date range, so it lives in one place: three repositories carrying the same
* expression by hand is three chances for "a season" to come to mean something slightly
* different on one screen than on the one beside it.
*
* Both aliases must already be joined by the caller - this only adds the conditions.
*/
final class SeasonPeriodFilter
{
public static function apply(
QueryBuilder $qb,
?\DateTimeImmutable $dateFrom,
?\DateTimeImmutable $dateTo,
string $assignmentAlias = 'assignment',
string $destinationAlias = 'destination',
): void {
if (null !== $dateFrom) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull($assignmentAlias.'.dateFrom'),
$qb->expr()->gte($assignmentAlias.'.dateFrom', ':dateFrom')
),
$qb->expr()->andX(
$qb->expr()->isNull($assignmentAlias.'.dateFrom'),
$qb->expr()->gte($destinationAlias.'.dateFrom', ':dateFrom')
)
))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull($assignmentAlias.'.dateTo'),
$qb->expr()->lte($assignmentAlias.'.dateTo', ':dateTo')
),
$qb->expr()->andX(
$qb->expr()->isNull($assignmentAlias.'.dateTo'),
$qb->expr()->lte($destinationAlias.'.dateTo', ':dateTo')
)
))
->setParameter('dateTo', $dateTo)
;
}
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Repository;
use App\Entity\Assignment;
use App\Entity\StatisticsEvent;
use App\Enum\StatisticsDateBasis;
use App\Enum\StatisticsEventName;
use App\Repository\Filter\SeasonPeriodFilter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StatisticsEvent>
*
* @method StatisticsEvent|null find($id, $lockMode = null, $lockVersion = null)
* @method StatisticsEvent|null findOneBy(array $criteria, array $orderBy = null)
* @method StatisticsEvent[] findAll()
* @method StatisticsEvent[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class StatisticsEventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StatisticsEvent::class);
}
/**
* Counts events of one metric, grouped by one of the dimension columns.
*
* The grouping column is whitelisted rather than interpolated freely, since it
* goes into the DQL string.
*
* The period defaults to the season the assignment runs in, because that is what
* every other statistics screen means by a date range - filtering these events by
* when they were recorded instead would silently answer a different question from
* the screen next to it. Pass OCCURRENCE deliberately for a real time series.
*
* SEASON reaches the assignment through an arbitrary join, since the dimension
* columns are plain integers rather than relations. Events without an assignmentId
* therefore drop out of a season-filtered count; all metrics recorded so far set it.
*
* @return array<int, array{value: string|int|null, total: int}>
*/
public function countGroupedBy(
StatisticsEventName $name,
string $dimension,
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON,
): array {
$rows = $this
->getCountGroupedByQuery($name, $dimension, $dateFrom, $dateTo, $dateBasis)
->getResult()
;
// Doctrine hands COUNT() back as a string. Cast it once here rather than leaving
// every caller to discover it through a === comparison that quietly never matches.
return array_map(
static fn (array $row): array => [
'value' => $row['value'],
'total' => (int) $row['total'],
],
$rows,
);
}
/**
* The query behind countGroupedBy(), exposed unexecuted so its semantics can be
* pinned without a database.
*/
public function getCountGroupedByQuery(
StatisticsEventName $name,
string $dimension,
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON,
): Query {
$allowed = ['actorRole', 'hotelCode', 'destinationId', 'jobProfileId', 'teamerId', 'assignmentId'];
if (!in_array($dimension, $allowed, true)) {
throw new \InvalidArgumentException(sprintf('Cannot group statistics by "%s".', $dimension));
}
$qb = $this->createQueryBuilder('event');
$qb
->select(sprintf('event.%s AS value', $dimension), 'COUNT(event.id) AS total')
->where($qb->expr()->eq('event.name', ':name'))
->setParameter('name', $name->value)
->groupBy('value')
->orderBy('total', 'DESC')
;
$filtered = null !== $dateFrom || null !== $dateTo;
if (StatisticsDateBasis::SEASON === $dateBasis) {
if ($filtered) {
$qb
->innerJoin(Assignment::class, 'assignment', Join::WITH, 'assignment.id = event.assignmentId')
->innerJoin('assignment.destination', 'destination')
;
}
// The same translation the disposition statistics use, so a period cannot come
// to mean one thing here and another on the screen beside it.
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
} else {
if (null !== $dateFrom) {
$qb
->andWhere($qb->expr()->gte('event.occurredAt', ':dateFrom'))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo) {
$qb
->andWhere($qb->expr()->lte('event.occurredAt', ':dateTo'))
->setParameter('dateTo', $dateTo)
;
}
}
return $qb->getQuery();
}
/**
* The subject ids already recorded for a metric, used by the backfill to stay
* idempotent.
*
* @return array<int, int>
*/
public function findRecordedSubjectIds(StatisticsEventName $name, string $dimension): array
{
$allowed = ['assignmentId', 'dispositionId', 'applicationId'];
if (!in_array($dimension, $allowed, true)) {
throw new \InvalidArgumentException(sprintf('Cannot look up statistics by "%s".', $dimension));
}
$qb = $this->createQueryBuilder('event');
$rows = $qb
->select(sprintf('event.%s AS subjectId', $dimension))
->where($qb->expr()->eq('event.name', ':name'))
->andWhere($qb->expr()->isNotNull(sprintf('event.%s', $dimension)))
->setParameter('name', $name->value)
->getQuery()
->getResult()
;
return array_map(static fn (array $row): int => (int) $row['subjectId'], $rows);
}
}
@@ -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(),
];
}
}