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