feat: collect assignment and disposition events for statistics
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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