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);
}
}
}