feat: collect assignment and disposition events for statistics
This commit is contained in:
@@ -105,6 +105,13 @@ services:
|
||||
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
|
||||
$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\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
@@ -246,6 +253,10 @@ services:
|
||||
$datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%'
|
||||
$datevEmailSender: '%env(DATEV_EMAIL_SENDER)%'
|
||||
|
||||
App\EventListener\StatisticsChangeSetListener:
|
||||
arguments:
|
||||
$collectors: !tagged_iterator app.statistics_collector
|
||||
|
||||
App\RequiredTeamerCheck\RequiredTeamerCheckRegistry:
|
||||
arguments:
|
||||
# Presentation order only. Each check must point at a page that clears
|
||||
|
||||
@@ -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`.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,19 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
@@ -29,14 +42,8 @@ class AssignmentType extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('status', ChoiceType::class, [
|
||||
'label' => 'Status',
|
||||
'choices' => [
|
||||
'Entwurf' => Assignment::STATUS_DRAFT,
|
||||
'veröffentlicht' => Assignment::STATUS_PUBLISHED,
|
||||
'abgesagt' => Assignment::STATUS_CALLED_OFF,
|
||||
],
|
||||
])
|
||||
// status is added in PRE_SET_DATA, where the assignment behind the form is
|
||||
// known - an already called off one has to keep that value in the list.
|
||||
->add('availableDispositions', IntegerType::class, [
|
||||
'label' => 'zu vergeben',
|
||||
'required' => false,
|
||||
@@ -138,6 +145,9 @@ class AssignmentType extends AbstractType
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
|
||||
$form = $event->getForm();
|
||||
$data = $event->getData();
|
||||
|
||||
$this->addStatusField($data, $form);
|
||||
|
||||
$destination = $data->getDestination();
|
||||
|
||||
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
|
||||
{
|
||||
$choices = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user