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
+11
View File
@@ -105,6 +105,13 @@ services:
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
$mailingBusTransport: '%mailing_bus_transport%' $mailingBusTransport: '%mailing_bus_transport%'
# Tagged by interface rather than listed by hand: order between collectors is
# irrelevant, so the only thing an explicit list could add here is the chance to
# forget one, which would silently stop a metric being recorded.
_instanceof:
App\Service\Statistics\Collector\StatisticsCollectorInterface:
tags: ['app.statistics_collector']
App\: App\:
resource: '../src/' resource: '../src/'
exclude: exclude:
@@ -246,6 +253,10 @@ services:
$datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%' $datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%'
$datevEmailSender: '%env(DATEV_EMAIL_SENDER)%' $datevEmailSender: '%env(DATEV_EMAIL_SENDER)%'
App\EventListener\StatisticsChangeSetListener:
arguments:
$collectors: !tagged_iterator app.statistics_collector
App\RequiredTeamerCheck\RequiredTeamerCheckRegistry: App\RequiredTeamerCheck\RequiredTeamerCheckRegistry:
arguments: arguments:
# Presentation order only. Each check must point at a page that clears # Presentation order only. Each check must point at a page that clears
+337
View File
@@ -0,0 +1,337 @@
# Statistics: event collection and evaluation
Reference for how statistics are collected, what is stored, and how to read or extend it.
Describes the behaviour as implemented — not a plan. Evaluation screens for these metrics do
not exist yet; this is what they will be built on.
The governing rule, from which the rest follows:
> **Columns are for what you `GROUP BY`. The payload is for what you read back.**
>
> A value that will end up in a `GROUP BY` needs its own indexed column. Everything else —
> old and new values, reasons, scopes, flags — lives in the JSON `payload`. That split is
> what lets a new metric ship without a migration, and it is the only rule that has to hold
> as the table grows.
Two corollaries that surprise people:
- **Rows are never updated or deleted.** A statistics event is a record of something that
happened, not a view of current state. If the answer can be recomputed from live data at
any time, it does not belong here — see *What does not belong* below.
- **Nothing here is a foreign key.** `assignment_id` and friends are plain integers, so
statistics outlive the rows they describe. Applications are hard deleted; their history is
not.
---
## Why the table exists
Three of the four metrics could not be answered from live data:
- `Assignment::$jobProfile` holds only the current value. Every edit destroyed the previous
one permanently.
- Applications are hard deleted (no soft delete), so any count over them silently shrank
over time.
- Call-offs persisted, but not *when* they happened — `updatedAt` is overwritten by any
later edit — nor at what scope.
The `log` table is not an alternative. It is a monolog dump (`App\Logging\DatabaseHandler`)
with free-form `context`/`extra`, excluded from migrations by
`schema_filter: ~^(?!log)~` in `config/packages/doctrine.yaml`. It is an audit trail for
humans to read, not a schema to aggregate over.
---
## The table
`statistics_event`, created by `migrations/Version20260826000000.php`, mapped by
`src/Entity/StatisticsEvent.php`.
| Column | Type | Purpose |
|--------|------|---------|
| `id` | int, PK | |
| `name` | varchar(64) | the metric, `enumType: StatisticsEventName` |
| `occurred_at` | datetime | when it happened |
| `actor_id` | int, null | `User::getId()` of whoever acted |
| `actor_label` | varchar(180), null | their `getUserIdentifier()` |
| `actor_role` | varchar(32), null | `enumType: StatisticsActorRole` |
| `assignment_id` | int, null | dimension |
| `disposition_id` | int, null | dimension |
| `application_id` | int, null | dimension |
| `teamer_id` | int, null | dimension |
| `destination_id` | int, null | dimension |
| `job_profile_id` | int, null | dimension |
| `hotel_code` | varchar(8), null | **normalized** code, frozen at write time |
| `payload` | json | everything metric-specific |
Indexed on `(name, occurred_at)`, `(name, hotel_code)`, `(assignment_id)`, `(teamer_id)`.
### Two traps in the columns
**`hotel_code` is normalized here and raw in `feedback`.** This table stores `ALB`;
`feedback.hotel_code` stores `SERALB` and normalizes at query time. Same column name,
opposite conventions — comparing or joining them directly returns nothing, silently. The
`SER`-stripping rule lives in `Destination::getHotelCodeNormalized()`
(`src/Entity/Destination.php`), and is reimplemented in DQL in `DispositionRepository` and
in raw SQL in `FeedbackRepository`. Reuse the entity method in PHP; do not add a fourth copy.
**Dimensions are frozen at write time, deliberately.** A later correction to a destination's
hotel code does not rewrite past periods. This is the same pattern `Feedback::fromAssignment()`
(`src/Entity/Feedback.php`) already uses.
---
## The four metrics
Defined in `src/Enum/StatisticsEventName.php`. Adding a case here plus one collector that
records it is the entire cost of a new metric.
| Name | Recorded when | Payload |
|------|---------------|---------|
| `assignment.job_profile_changed` | the job profile column changes on any write path | `from`, `to` (each `{id, name}` or null) |
| `assignment.called_off` | an assignment's status becomes `called_off` | `dispositions_affected`, `previous_status` |
| `disposition.called_off` | a disposition's status becomes `called_off` | `called_off_by`, `scope`, `reason`, `previous_status` |
| `application.created` | an application is persisted, by any path | — |
Which dimensions each one fills:
| Metric | assignment | destination | job_profile | hotel_code | teamer | disposition | application |
|--------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `assignment.job_profile_changed` | ✓ | ✓ | ✓ | ✓ | — | — | — |
| `assignment.called_off` | ✓ | ✓ | ✓ | ✓ | — | — | — |
| `disposition.called_off` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| `application.created` | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ |
### Call-offs: three different "who" and "how far"
This is the part most likely to be misread when building a chart.
- **`actor_role`** — who *clicked*, from the security token. Always `admin` for a call-off;
only administrative users can perform one.
- **`payload.called_off_by`** — whose *decision* it was, `teamer` or `office`, as declared by
an admin on the call-off form (`src/Form/DispositionCallOffType.php`). There is no
teamer-initiated call-off route; a teamer's decision is recorded on their behalf.
- **`payload.scope`** — how far it *reached*, `assignment` or `disposition`
(`src/Enum/CallOffScope.php`). The office can cancel one placement just as a
whole trip can fall away, so `called_off_by` does not imply this.
Cancelling a trip cascades onto every disposition on it in one flush, so **one office
decision produces one `assignment.called_off` row plus N `disposition.called_off` rows**.
Counting the disposition rows therefore counts *affected placements*, not decisions. To count
decisions:
```sql
-- decisions
SELECT COUNT(*) FROM statistics_event WHERE name = 'assignment.called_off';
SELECT COUNT(*) FROM statistics_event
WHERE name = 'disposition.called_off'
AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.scope')) = 'disposition';
-- affected placements
SELECT COUNT(*) FROM statistics_event WHERE name = 'disposition.called_off';
```
`assignment.called_off` carries `dispositions_affected` so a cancelled trip's cost reads off
a single row. It is frequently `0` — a trip cancelled before anyone was staffed is still a
decision worth counting.
---
## How events are recorded
One mechanism, writing through `App\Service\Statistics\StatisticsRecorder`.
**Doctrine changesets**`src/EventListener/StatisticsChangeSetListener.php`, on `onFlush`
and `postFlush`. Used for state changes. `onFlush` is the only place the *previous* value
still exists, and it catches every write path — edit form, duplication, imports, console —
rather than the one route someone remembered to hook. Recording happens in `postFlush`, after
commit, so a rolled-back flush leaves no phantom statistics: the batch belongs to one flush,
is cleared at the start of the next, and goes away with the service reset that a worker does
between messages.
The listener itself knows nothing about any metric. It owns only the timing, and delegates
to **collectors** — implementations of
`App\Service\Statistics\Collector\StatisticsCollectorInterface`, autoconfigured by interface
so a new one needs no wiring and cannot be silently forgotten.
A collector receives the whole flush (`StatisticsFlush`), not one entity at a time. That is
deliberate: `CallOffCollector` has to see which *assignments* are becoming `called_off`
before it can tell a cascaded disposition from a teamer dropping out, and it counts the
cascade to fill `dispositions_affected`. Neither is expressible from a single entity's
changeset, and that distinction exists only inside the flush.
**Group collectors by concern, not by metric.** Both call-off metrics live in one collector
because they can only be told apart together; a job profile change shares nothing with them
and lives in its own. Order between collectors is irrelevant and nothing may depend on it.
Collectors see **insertions** as well as updates, so a metric about something being created
is a collector too. `ApplicationCreatedCollector` reads `insertionsOf(Application::class)`
rather than subscribing to `ApplicationCreatedEvent`, which is what an earlier version did.
Reading the flush counts every path that persists an application; subscribing counted only
the paths that remember to dispatch, and would have silently missed an admin applying on
someone's behalf, an import, or a fixture.
A newly inserted entity has no id during `onFlush`. That is fine — Doctrine assigns it during
the commit, and recording happens afterwards in `postFlush`.
> **There is deliberately only one mechanism.** If a future metric is not a persisted state
> change at all — an email sent, a login, a document downloaded — call `StatisticsRecorder`
> directly from wherever that happens. Do not reintroduce a second listener layer for it.
**Writes go through raw DBAL**, not the ORM, so recording never touches the UnitOfWork — a
listener running inside a flush would otherwise trigger a nested flush. Failures are caught
and reported to the audit logger: statistics are a side effect and must never break the
business action that produced them.
---
## Reading the data
`src/Repository/StatisticsEventRepository.php`.
```php
public function countGroupedBy(
StatisticsEventName $name,
string $dimension, // whitelisted
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON,
): array
```
`getCountGroupedByQuery()` returns the same query unexecuted, which is how its semantics are
pinned in tests without a database.
### A period means the season, not when the row was written
`StatisticsDateBasis::SEASON` (the default) filters on the assignment's dates, falling back to
the destination's — the translation of `Assignment::getEffectivePeriod()` into DQL, which
lives in `src/Repository/Filter/SeasonPeriodFilter.php` and is shared with
`DispositionRepository`. This matches every other statistics screen and
`StatisticsFilterType`. Change what a season means there, once, not per screen.
`StatisticsDateBasis::OCCURRENCE` filters `occurred_at` instead. Reach for it deliberately,
for genuine time series such as call-offs per month.
The two routinely disagree, because an application for the coming winter arrives months
before the season it belongs to. Picking the wrong one does not fail — it quietly answers a
different question from the screen next to it.
`SEASON` reaches the assignment through an arbitrary join and therefore excludes events with
no `assignment_id`. All four metrics set it.
### Crossing with live data
The plain-integer dimension columns cost nothing in queryability — Doctrine's arbitrary join
works:
```
SELECT destination.hotel AS hotel, COUNT(event.id) AS applications
FROM App\Entity\StatisticsEvent event
JOIN App\Entity\Assignment assignment WITH assignment.id = event.assignmentId
JOIN assignment.destination destination
WHERE event.name = :name
GROUP BY hotel
```
Group by `hotel_code`, though, not by the hotel *name*: they are not one-to-one. One
normalized code can span several destinations with different names.
---
## Backfilling
`bin/console app:statistics:backfill` seeds what current data still contains. Idempotent —
it skips subjects already recorded — and supports `--dry-run`. It is never run automatically:
nothing in `CronCommand`, the scheduler, or any deploy script invokes it.
It writes only to `statistics_event`, so it is safe to run against production at any time.
| Metric | Backfillable | `occurred_at` from |
|--------|--------------|--------------------|
| `application.created` | yes | `createdAt` — exact |
| `disposition.called_off` | yes | `updatedAt`**approximate** |
| `assignment.called_off` | yes | `updatedAt`**approximate** |
| `assignment.job_profile_changed` | **no** | history does not exist |
Every seeded row carries `"backfilled": true`; the approximate ones also carry
`"approximate_date": true`. So the choice is per chart, never table-wide:
```sql
WHERE JSON_EXTRACT(payload,'$.backfilled') IS NULL -- organically recorded only
```
Three limits on seeded rows, all of which matter when charting:
- **Dates from `updatedAt` are a proxy**, since any later edit overwrites it. Exclude those
rows from time series; keep them for totals.
- **`actor_role` is `system`.** The command runs on the CLI with no security token, so the
original actor is not recoverable.
- **Frozen dimensions are frozen at *backfill* time**, not at the time of the event, and
`scope` is left unset because it cannot be established retroactively.
---
## Extending it
The cost depends entirely on whether the new metric needs a new *dimension* or just a new
*name*. Almost all need only a name.
**No migration — a new kind of event.** Add a case to `StatisticsEventName`, then one
`StatisticsCollectorInterface` implementation that records it. Metric-specific detail goes in
`payload`. `StatisticsChangeSetListener` is never edited. This covers most requests —
application rejections, contract upload delays, feedback submitted, any status transition,
anything created or deleted.
Reuse an existing collector when the new metric is part of a concern already covered —
especially when it needs to see the same entities in the same flush. Write a new one when it
is genuinely independent.
**One migration — a new dimension to slice by.** Only when you need to group by something no
column carries (a season, a country, a fee band). Add the column and an index, fill it in
`StatisticsRecorder`, and accept `NULL` on older rows. Resist when the dimension is already
reachable through a join.
**Migration plus backfill — history for something not yet recorded.** Extend
`StatisticsBackfillCommand` if current data still holds the answer. If it has been
overwritten, as with job profiles, the metric simply starts the day it ships.
---
## What does not belong here
**Anything recomputable from live data that cannot be lost.** Feedback statistics
(`src/Controller/Administrative/Statistics/`) are the worked example: `Feedback` already
freezes its own dimensions at creation, is never deleted, and its aggregations average a
per-question `ratings` array. It is already an append-only record; copying it into
`statistics_event` would duplicate every row and lose the structure the charts need. This
table counts occurrences — it has no shape for averaging structured arrays.
**Current state.** "How many applications are open right now" is a question for the
`application` table. This table answers "how many were ever received", which is a different
and equally valid question — just not the same one.
**An audit trail for humans.** That is the `log` table and `/admin/log`.
---
## Where things live
| Concern | Path |
|---------|------|
| Entity | `src/Entity/StatisticsEvent.php` |
| Repository | `src/Repository/StatisticsEventRepository.php` |
| Enums | `src/Enum/Statistics*.php` |
| Write path | `src/Service/Statistics/StatisticsRecorder.php` |
| Flush orchestration | `src/EventListener/StatisticsChangeSetListener.php` |
| Collectors (one per concern) | `src/Service/Statistics/Collector/` |
| Flush context passed to collectors | `src/Service/Statistics/StatisticsFlush.php` |
| What a period means, shared with the disposition statistics | `src/Repository/Filter/SeasonPeriodFilter.php` |
| Backfill | `src/Command/StatisticsBackfillCommand.php` |
| Migration | `migrations/Version20260826000000.php` |
| Tests | `tests/EventListener/`, `tests/Repository/`, `tests/Service/Statistics/` |
Existing statistics screens, for the filter/handler pattern a future evaluation UI should
reuse: `src/Controller/Administrative/Statistics/`,
`src/Service/Common/StatisticsFilterHandler.php`, `src/Model/StatisticsFilterDto.php`,
`src/Form/StatisticsFilterType.php`.
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The append-only store behind the assignment and disposition statistics.
*
* Written by hand rather than diffed: the schema carries unrelated drift (JSON column
* comments, a messenger index) that a generated diff would sweep in alongside this table.
*/
final class Version20260826000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create statistics_event, the append-only store for assignment and disposition metrics';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE statistics_event (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(64) NOT NULL,
occurred_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
actor_id INT DEFAULT NULL,
actor_label VARCHAR(180) DEFAULT NULL,
actor_role VARCHAR(32) DEFAULT NULL,
assignment_id INT DEFAULT NULL,
disposition_id INT DEFAULT NULL,
application_id INT DEFAULT NULL,
teamer_id INT DEFAULT NULL,
destination_id INT DEFAULT NULL,
job_profile_id INT DEFAULT NULL,
hotel_code VARCHAR(8) DEFAULT NULL,
payload JSON NOT NULL,
INDEX IDX_A343D13C5E237E0687C03D1B (name, occurred_at),
INDEX IDX_A343D13C5E237E06C0D0E610 (name, hotel_code),
INDEX IDX_A343D13CD19302F8 (assignment_id),
INDEX IDX_A343D13C4302FF75 (teamer_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE statistics_event');
}
}
+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);
}
}
}
+239
View File
@@ -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;
}
}
+19
View File
@@ -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';
}
+17
View File
@@ -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';
}
+25
View File
@@ -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';
}
+31
View File
@@ -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 = [];
}
}
+50 -8
View File
@@ -22,6 +22,19 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AssignmentType extends AbstractType 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) public function __construct(private readonly EntityManagerInterface $entityManager)
{ {
} }
@@ -29,14 +42,8 @@ class AssignmentType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('status', ChoiceType::class, [ // status is added in PRE_SET_DATA, where the assignment behind the form is
'label' => 'Status', // known - an already called off one has to keep that value in the list.
'choices' => [
'Entwurf' => Assignment::STATUS_DRAFT,
'veröffentlicht' => Assignment::STATUS_PUBLISHED,
'abgesagt' => Assignment::STATUS_CALLED_OFF,
],
])
->add('availableDispositions', IntegerType::class, [ ->add('availableDispositions', IntegerType::class, [
'label' => 'zu vergeben', 'label' => 'zu vergeben',
'required' => false, 'required' => false,
@@ -138,6 +145,9 @@ class AssignmentType extends AbstractType
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$form = $event->getForm(); $form = $event->getForm();
$data = $event->getData(); $data = $event->getData();
$this->addStatusField($data, $form);
$destination = $data->getDestination(); $destination = $data->getDestination();
if (null === $destination) { 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 private function addPickupField(Destination $destination, FormInterface $form): void
{ {
$choices = []; $choices = [];
-25
View File
@@ -417,29 +417,4 @@ class AssignmentRepository extends ServiceEntityRepository
->getQuery() ->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\Disposition;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Entity\Upload; use App\Entity\Upload;
use App\Repository\Filter\SeasonPeriodFilter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
@@ -343,38 +344,7 @@ class DispositionRepository extends ServiceEntityRepository
->orderBy('destination.hotel', 'ASC') ->orderBy('destination.hotel', 'ASC')
; ;
// Filter by effective date range (assignment date or fallback to destination date) SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
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)
;
}
$results = $qb->getQuery()->getResult(); $results = $qb->getQuery()->getResult();
@@ -435,37 +405,7 @@ class DispositionRepository extends ServiceEntityRepository
->orderBy('hotelCode', 'ASC') ->orderBy('hotelCode', 'ASC')
; ;
if (null !== $dateFrom) { SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
$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)
;
}
$results = $qb->getQuery()->getResult(); $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;
}
+105
View File
@@ -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(),
];
}
}
@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Enum\CallOffScope;
use App\Enum\StatisticsEventName;
use App\EventListener\StatisticsChangeSetListener;
use App\Service\Statistics\Collector\ApplicationCreatedCollector;
use App\Service\Statistics\Collector\CallOffCollector;
use App\Service\Statistics\Collector\JobProfileChangeCollector;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* These two metrics exist because the data behind them is otherwise unrecoverable - a job
* profile change overwrites its own history - so the thing worth pinning is *when* a row
* gets written and when it must not.
*/
class StatisticsChangeSetListenerTest extends TestCase
{
private StatisticsRecorder&MockObject $recorder;
protected function setUp(): void
{
$this->recorder = $this->createMock(StatisticsRecorder::class);
}
public function testRecordsBothSidesOfAJobProfileChange(): void
{
$assignment = new Assignment();
$old = (new JobProfile())->setName('Skilehrer');
$new = (new JobProfile())->setName('Hausleitung');
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$assignment,
$this->callback(static function (array $payload): bool {
return 'Skilehrer' === $payload['from']['name']
&& 'Hausleitung' === $payload['to']['name'];
})
)
;
$this->flush([[$assignment, ['jobProfile' => [$old, $new]]]]);
}
/**
* An assignment gets edited constantly. Only the job profile column may produce a row.
*/
public function testIgnoresAnAssignmentEditThatLeavesTheJobProfileAlone(): void
{
$this->recorder->expects($this->never())->method('recordForAssignment');
$this->flush([[new Assignment(), ['remarks' => ['alt', 'neu'], 'pickup' => [1, 2]]]]);
}
/**
* A newly created assignment gets its profile as an insert, not an update, and is not a
* change anybody made.
*/
public function testIgnoresAJobProfileThatDidNotActuallyChange(): void
{
$profile = (new JobProfile())->setName('Skilehrer');
$this->recorder->expects($this->never())->method('recordForAssignment');
$this->flush([[new Assignment(), ['jobProfile' => [$profile, $profile]]]]);
}
public function testRecordsACallOffWithWhoseDecisionItWas(): void
{
$disposition = $this->disposition();
$disposition
->setCalledOffBy(Disposition::CALLED_OFF_BY_TEAMER)
->setCalledOffReason('Krankheit')
;
$this->recorder
->expects($this->once())
->method('recordForDisposition')
->with(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$disposition,
$this->callback(static function (array $payload): bool {
return Disposition::CALLED_OFF_BY_TEAMER === $payload['called_off_by']
&& 'Krankheit' === $payload['reason']
&& Disposition::STATUS_CONFIRMED === $payload['previous_status'];
})
)
;
$this->flush([[$disposition, ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]]]);
}
public function testIgnoresAnyOtherDispositionStatusChange(): void
{
$this->recorder->expects($this->never())->method('recordForDisposition');
$this->flush([[$this->disposition(), [
'status' => [Disposition::STATUS_NEW, Disposition::STATUS_CONFIRMED],
]]]);
}
/**
* Calling off a whole assignment cascades the status onto every disposition in one
* flush, which is exactly why this listens on the changeset instead of on
* DispositionCalledOffEvent - that event is never dispatched for the cascade.
*/
public function testRecordsEveryDispositionOfACascadedAssignmentCallOff(): void
{
$assignment = new Assignment();
$this->recorder->expects($this->exactly(2))->method('recordForDisposition');
$this->flush([
[$assignment, ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
]);
}
/**
* One office decision that cancels a trip with two teamers must not read as two office
* decisions. The scope is what separates the decision from its cost.
*/
public function testACascadedDispositionIsScopedToTheAssignment(): void
{
$assignment = new Assignment();
$this->recorder
->expects($this->exactly(2))
->method('recordForDisposition')
->with(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$this->anything(),
$this->callback(static fn (array $payload): bool => CallOffScope::ASSIGNMENT->value === $payload['scope'])
)
;
$this->flush([
[$assignment, ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
]);
}
/**
* A teamer dropping out leaves the trip and everyone else on it running.
*/
public function testALoneCallOffIsScopedToTheDispositionOnly(): void
{
$this->recorder
->expects($this->once())
->method('recordForDisposition')
->with(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$this->anything(),
$this->callback(static fn (array $payload): bool => CallOffScope::DISPOSITION->value === $payload['scope'])
)
;
$this->recorder->expects($this->never())->method('recordForAssignment');
$this->flush([[$this->disposition(), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]]]);
}
/**
* The trip-level decision is its own row, carrying what the cancellation cost so that
* "how many trips did we cancel" does not have to be counted back out of the
* dispositions.
*/
public function testTheTripCancellationIsRecordedOnceWithItsCost(): void
{
$assignment = new Assignment();
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
$assignment,
$this->callback(static fn (array $payload): bool => 2 === $payload['dispositions_affected'])
)
;
$this->flush([
[$assignment, ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
]);
}
/**
* A trip cancelled before anyone was staffed still counts as a decision - 58 of the 88
* in the database are exactly that.
*/
public function testAnUnstaffedTripCancellationCostsNobody(): void
{
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
$this->anything(),
$this->callback(static fn (array $payload): bool => 0 === $payload['dispositions_affected'])
)
;
$this->flush([[new Assignment(), ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]]]);
}
/**
* A second flush in the same request must not replay what the first already recorded.
*/
public function testDoesNotRecordTheSameChangeTwice(): void
{
$assignment = new Assignment();
$old = (new JobProfile())->setName('Skilehrer');
$new = (new JobProfile())->setName('Hausleitung');
$this->recorder->expects($this->once())->method('recordForAssignment');
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs([[$assignment, ['jobProfile' => [$old, $new]]]]));
$listener->postFlush($this->postFlushArgs());
$listener->postFlush($this->postFlushArgs());
}
/**
* A flush that throws never reaches postFlush. What it collected has to die with it,
* rather than be written by whichever flush succeeds next - in a worker that is a
* different message, a different actor and a later date.
*/
public function testAFailedFlushDoesNotLeakIntoTheNextOne(): void
{
$survivor = new Assignment();
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED, $survivor, $this->anything())
;
$listener = $this->listener();
// Collected, and then the commit blows up: no postFlush for this one.
$listener->onFlush($this->onFlushArgs([[new Assignment(), [
'jobProfile' => [$this->jobProfile('Skilehrer'), $this->jobProfile('Hausleitung')],
]]]));
$listener->onFlush($this->onFlushArgs([[$survivor, [
'jobProfile' => [$this->jobProfile('Hausleitung'), $this->jobProfile('Skilehrer')],
]]]));
$listener->postFlush($this->postFlushArgs());
}
/**
* Workers reset their services between messages, which is the other way a failed
* flush's leftovers get dropped rather than written by an unrelated one.
*/
public function testResettingDropsWhatAFailedFlushCollected(): void
{
$this->recorder->expects($this->never())->method('recordForAssignment');
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs([[new Assignment(), [
'jobProfile' => [$this->jobProfile('Skilehrer'), $this->jobProfile('Hausleitung')],
]]]));
$listener->reset();
$listener->postFlush($this->postFlushArgs());
}
/**
* Folded in from a domain-event subscriber: reading the insertions catches every path
* that persists an application, not only the ones that remember to dispatch.
*/
public function testAnApplicationIsRecordedWhenItIsPersisted(): void
{
$application = new Application(new Assignment(), new Teamer());
$this->recorder
->expects($this->once())
->method('recordForApplication')
->with(StatisticsEventName::APPLICATION_CREATED, $application, [])
;
$this->flush([], [$application]);
}
/**
* Editing an application later is not a second application.
*/
public function testAnUpdatedApplicationIsNotRecordedAgain(): void
{
$this->recorder->expects($this->never())->method('recordForApplication');
$this->flush([[new Application(new Assignment(), new Teamer()), ['remarks' => ['a', 'b']]]]);
}
/**
* Other entities are inserted constantly; only applications count here.
*/
public function testOtherInsertionsAreIgnored(): void
{
$this->recorder->expects($this->never())->method('recordForApplication');
$this->flush([], [new Assignment(), new Teamer()]);
}
/**
* @param array<int, array{0: object, 1: array<string, mixed>}> $changeSets
* @param array<int, object> $insertions
*/
private function flush(array $changeSets, array $insertions = []): void
{
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs($changeSets, $insertions));
$listener->postFlush($this->postFlushArgs());
}
/**
* @param array<int, array{0: object, 1: array<string, mixed>}> $changeSets
* @param array<int, object> $insertions
*/
private function onFlushArgs(array $changeSets, array $insertions = []): OnFlushEventArgs
{
$entities = [];
$byEntity = new \SplObjectStorage();
foreach ($changeSets as [$entity, $changeSet]) {
$entities[] = $entity;
$byEntity[$entity] = $changeSet;
}
$unitOfWork = $this->createMock(UnitOfWork::class);
$unitOfWork->method('getScheduledEntityUpdates')->willReturn($entities);
$unitOfWork->method('getScheduledEntityInsertions')->willReturn($insertions);
$unitOfWork
->method('getEntityChangeSet')
->willReturnCallback(static fn (object $entity): array => $byEntity[$entity] ?? [])
;
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->method('getUnitOfWork')->willReturn($unitOfWork);
return new OnFlushEventArgs($entityManager);
}
private function postFlushArgs(): PostFlushEventArgs
{
return new PostFlushEventArgs($this->createMock(EntityManagerInterface::class));
}
/**
* Built with the real collectors, so these tests cover them as well as the timing.
*/
private function listener(): StatisticsChangeSetListener
{
return new StatisticsChangeSetListener($this->recorder, [
new JobProfileChangeCollector(),
new CallOffCollector(),
new ApplicationCreatedCollector(),
]);
}
private function jobProfile(string $name): JobProfile
{
return (new JobProfile())->setName($name);
}
private function disposition(?Assignment $assignment = null): Disposition
{
return new Disposition(new Application($assignment ?? new Assignment(), new Teamer()));
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\Assignment;
use App\Form\AssignmentType;
use PHPUnit\Framework\TestCase;
/**
* Calling off an assignment has to go through the dedicated action, because that is the
* only path that also cancels the dispositions on it. Offering "abgesagt" in this form left
* teamers holding a live placement on a trip that had been cancelled - 23 assignments in the
* database are in exactly that state.
*
* The choice list is asserted directly rather than through a built form: the form also
* carries EntityType fields that query the database, and this suite does not have one.
* ChoiceType validates submissions against exactly this list, so it is the enforcement
* point, not merely what gets rendered.
*/
class AssignmentTypeTest extends TestCase
{
public function testADraftCannotBeCalledOffThroughTheStatusField(): void
{
$this->assertSame(
[Assignment::STATUS_DRAFT, Assignment::STATUS_PUBLISHED],
$this->statusChoices(Assignment::STATUS_DRAFT)
);
}
/**
* The published case is the one that mattered: a staffed trip cancelled from the edit
* form kept every disposition on it alive.
*/
public function testAPublishedAssignmentCannotBeCalledOffEither(): void
{
$this->assertNotContains(Assignment::STATUS_CALLED_OFF, $this->statusChoices(Assignment::STATUS_PUBLISHED));
}
/**
* Otherwise opening its edit form would render an empty status, and saving anything
* else on the page would silently resurrect the assignment.
*/
public function testAnAlreadyCalledOffAssignmentKeepsTheValue(): void
{
$this->assertContains(Assignment::STATUS_CALLED_OFF, $this->statusChoices(Assignment::STATUS_CALLED_OFF));
}
/**
* There is no dedicated undo for a call-off, so this form stays the way back.
*/
public function testACalledOffAssignmentCanStillBeRestored(): void
{
$this->assertContains(Assignment::STATUS_PUBLISHED, $this->statusChoices(Assignment::STATUS_CALLED_OFF));
}
/**
* A brand new assignment has no entity behind the form yet.
*/
public function testTheChoicesHoldWithoutAnAssignment(): void
{
$this->assertNotContains(Assignment::STATUS_CALLED_OFF, array_values(AssignmentType::statusChoices(null)));
}
/**
* @return array<int, string>
*/
private function statusChoices(string $status): array
{
return array_values(AssignmentType::statusChoices((new Assignment())->setStatus($status)));
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\StatisticsEvent;
use App\Enum\StatisticsDateBasis;
use App\Enum\StatisticsEventName;
use App\Repository\StatisticsEventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Everywhere else in this app a date range means the season an assignment runs in, and
* these events are recorded months before it - an application for the coming winter
* arrives in summer. Picking the wrong basis does not fail, it quietly answers a
* different question from the screen next to it, so the compiled DQL is pinned here.
*/
class StatisticsEventRepositoryTest extends KernelTestCase
{
public function testAPeriodMeansTheSeasonUnlessToldOtherwise(): void
{
$dql = $this->query(dateFrom: new \DateTimeImmutable('2026-07-01'))->getDQL();
$this->assertStringContainsString('assignment.dateFrom >= :dateFrom', $dql);
$this->assertStringNotContainsString('event.occurredAt', $dql);
}
/**
* The season start is assignment.dateFrom falling back to the destination's, per
* Assignment::getEffectivePeriod(). Losing the fallback would silently drop every
* assignment that inherits its dates.
*/
public function testTheSeasonFallsBackToTheDestinationDates(): void
{
$dql = $this->query(
dateFrom: new \DateTimeImmutable('2026-07-01'),
dateTo: new \DateTimeImmutable('2027-06-30'),
)->getDQL();
$this->assertStringContainsString('assignment.dateFrom IS NULL', $dql);
$this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql);
$this->assertStringContainsString('assignment.dateTo IS NULL', $dql);
$this->assertStringContainsString('destination.dateTo <= :dateTo', $dql);
}
/**
* The dimension columns are plain integers, not relations, so the assignment is only
* reachable through an arbitrary join.
*/
public function testTheSeasonReachesTheAssignmentThroughAnArbitraryJoin(): void
{
$dql = $this->query(dateFrom: new \DateTimeImmutable('2026-07-01'))->getDQL();
$this->assertStringContainsString('assignment.id = event.assignmentId', $dql);
}
public function testOccurrenceAsksWhenTheEventWasRecordedAndJoinsNothing(): void
{
$dql = $this->query(
dateFrom: new \DateTimeImmutable('2026-07-01'),
dateBasis: StatisticsDateBasis::OCCURRENCE,
)->getDQL();
$this->assertStringContainsString('event.occurredAt >= :dateFrom', $dql);
$this->assertStringNotContainsString('JOIN', $dql);
}
/**
* An unfiltered count spans every season there is, so there is nothing to join for.
*/
public function testNoPeriodMeansNoJoinEitherWay(): void
{
$this->assertStringNotContainsString('JOIN', $this->query()->getDQL());
}
public function testTheGroupingColumnStaysWhitelisted(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->query(dimension: 'payload');
}
private function query(
string $dimension = 'hotelCode',
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON,
): Query {
return $this->repository()->getCountGroupedByQuery(
StatisticsEventName::APPLICATION_CREATED,
$dimension,
$dateFrom,
$dateTo,
$dateBasis,
);
}
private function repository(): StatisticsEventRepository
{
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
/** @var StatisticsEventRepository $repository */
$repository = $entityManager->getRepository(StatisticsEvent::class);
return $repository;
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Statistics;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\User;
use App\Enum\StatisticsActorRole;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception as DbalException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
class StatisticsRecorderTest extends TestCase
{
private Connection&MockObject $connection;
private Security&MockObject $security;
private LoggerInterface&MockObject $logger;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->security = $this->createMock(Security::class);
$this->logger = $this->createMock(LoggerInterface::class);
}
public function testWritesTheEnumValueAndLeavesUnusedDimensionsNull(): void
{
$written = $this->capture();
$this->recorder()->record(StatisticsEventName::APPLICATION_CREATED, ['application_id' => 7]);
$this->assertSame('application.created', $written['data']['name']);
$this->assertSame('statistics_event', $written['table']);
$this->assertSame(7, $written['data']['application_id']);
$this->assertNull($written['data']['disposition_id']);
$this->assertNull($written['data']['hotel_code']);
}
/**
* The hotel metric is asked for by hotel, not by destination, and several destinations
* share one hotel under a SER prefix.
*/
public function testFreezesTheNormalizedHotelCodeOfTheAssignment(): void
{
$written = $this->capture();
$this->recorder()->recordForAssignment(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$this->assignmentInHotel('SERZIL'),
);
$this->assertSame('ZIL', $written['data']['hotel_code']);
}
public function testKeepsAnUnprefixedHotelCodeAsItIs(): void
{
$written = $this->capture();
$this->recorder()->recordForAssignment(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$this->assignmentInHotel('SBW'),
);
$this->assertSame('SBW', $written['data']['hotel_code']);
}
public function testAttributesAnAdministrativeUser(): void
{
$this->givenUser(['ROLE_ADMINISTRATIVE']);
$this->assertSame(StatisticsActorRole::ADMIN, $this->recorder()->resolveActorRole());
}
public function testAttributesATeamer(): void
{
$this->givenUser(['ROLE_TEAMER']);
$this->assertSame(StatisticsActorRole::TEAMER, $this->recorder()->resolveActorRole());
}
/**
* The backfill and the cron run without a token.
*/
public function testAttributesAnythingWithoutATokenToTheSystem(): void
{
$this->security->method('getUser')->willReturn(null);
$this->assertSame(StatisticsActorRole::SYSTEM, $this->recorder()->resolveActorRole());
}
/**
* Statistics are a side effect. Losing one must never cost the teamer their
* application or the admin their edit.
*/
public function testSwallowsAWriteFailureInsteadOfBreakingTheAction(): void
{
$this->connection
->method('insert')
->willThrowException(new DbalException('no such table'))
;
$this->logger->expects($this->once())->method('error');
$this->recorder()->record(StatisticsEventName::APPLICATION_CREATED);
}
/**
* A payload that will not encode is a lost event either way. What must not happen is
* losing it to a DBAL error about the column, with nothing in the log pointing at the
* payload that caused it.
*/
public function testReportsAPayloadThatCannotBeEncodedInsteadOfWritingGarbage(): void
{
$this->connection->expects($this->never())->method('insert');
$this->logger->expects($this->once())->method('error');
$this->recorder()->record(
StatisticsEventName::DISPOSITION_CALLED_OFF,
[],
['reason' => "\xB1\x31"],
);
}
/**
* Hands back the row the recorder writes. An ArrayObject rather than an array, so the
* callback can fill it in after this method has already returned.
*
* @return \ArrayObject<string, mixed>
*/
private function capture(): \ArrayObject
{
$written = new \ArrayObject(['table' => '', 'data' => []]);
$this->connection
->method('insert')
->willReturnCallback(static function (string $table, array $data) use ($written): int {
$written['table'] = $table;
$written['data'] = $data;
return 1;
})
;
return $written;
}
private function recorder(): StatisticsRecorder
{
return new StatisticsRecorder($this->connection, $this->security, $this->logger);
}
/**
* @param array<int, string> $grantedRoles
*/
private function givenUser(array $grantedRoles): void
{
$this->security->method('getUser')->willReturn(new User());
$this->security
->method('isGranted')
->willReturnCallback(static fn (mixed $role): bool => in_array($role, $grantedRoles, true))
;
}
private function assignmentInHotel(string $hotelCode): Assignment
{
$destination = (new Destination())->setHotelCode($hotelCode);
return (new Assignment())->setDestination($destination);
}
}