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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user