feat: collect assignment and disposition events for statistics
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user