feat: record disposition deleted event for statistics

This commit is contained in:
2026-08-31 17:49:21 +02:00
parent 53465cc66a
commit 84441d9e69
10 changed files with 288 additions and 34 deletions
+26 -4
View File
@@ -26,12 +26,13 @@ Two corollaries that surprise people:
## Why the table exists
Three of the four metrics could not be answered from live data:
Most of the 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.
over time. Dispositions are hard deleted too, and leave even less behind — no `createdAt`,
no trace of the teamer they were on.
- Call-offs persisted, but not *when* they happened — `updatedAt` is overwritten by any
later edit — nor at what scope.
@@ -81,7 +82,7 @@ hotel code does not rewrite past periods. This is the same pattern `Feedback::fr
---
## The four metrics
## The five 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.
@@ -91,6 +92,7 @@ records it is the entire cost of a new metric.
| `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` |
| `disposition.deleted` | a disposition is hard deleted, on any delete path | `previous_status`, `remarks`, `called_off_by` |
| `application.created` | an application is persisted, by any path | — |
Which dimensions each one fills:
@@ -100,8 +102,14 @@ Which dimensions each one fills:
| `assignment.job_profile_changed` | ✓ | ✓ | ✓ | ✓ | — | — | — |
| `assignment.called_off` | ✓ | ✓ | ✓ | ✓ | — | — | — |
| `disposition.called_off` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| `disposition.deleted` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| `application.created` | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ |
`disposition.deleted` carries `remarks` because a hard delete destroys the entity outright:
the justification an admin types on the delete form (`DeleteController`) lives nowhere else
afterwards. `called_off_by` is set only when the placement had already been called off before
it was deleted.
### Call-offs: three different "who" and "how far"
This is the part most likely to be misread when building a chart.
@@ -174,6 +182,18 @@ 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`.
Collectors see **deletions** too, through `deletionsOf()`. `DispositionDeletedCollector`
reads them for the same reason: every delete path is counted, and a hard delete leaves
nothing to reconstruct afterwards. Deletions carry one wrinkle the other two do not.
Recording still happens in `postFlush`, but by then Doctrine has removed the entity from the
identity map and **nulled its generated id** (`UnitOfWork::executeDeletions()`), so
`StatisticsRecorder` can no longer resolve the dimension columns from it. A deletion
collector therefore snapshots its dimensions itself, during `onFlush`, via
`StatisticsDimensions::forDisposition()`, and passes them on the `CollectedStatisticsEvent`;
the listener writes that array verbatim instead of resolving from the subject. Anything a
deletion metric needs in its payload — status, the form `remarks` — has to be read the same
way, off the still-attached entity, before the flush commits.
> **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.
@@ -217,7 +237,7 @@ before the season it belongs to. Picking the wrong one does not fail — it quie
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.
no `assignment_id`. All five metrics set it.
### Crossing with live data
@@ -252,6 +272,7 @@ It writes only to `statistics_event`, so it is safe to run against production at
| `disposition.called_off` | yes | `updatedAt`**approximate** |
| `assignment.called_off` | yes | `updatedAt`**approximate** |
| `assignment.job_profile_changed` | **no** | history does not exist |
| `disposition.deleted` | **no** | the disposition is gone, nothing left to seed |
Every seeded row carries `"backfilled": true`; the approximate ones also carry
`"approximate_date": true`. So the choice is per chart, never table-wide:
@@ -322,6 +343,7 @@ and equally valid question — just not the same one.
| Repository | `src/Repository/StatisticsEventRepository.php` |
| Enums | `src/Enum/Statistics*.php` |
| Write path | `src/Service/Statistics/StatisticsRecorder.php` |
| Dimension columns resolved from an entity | `src/Service/Statistics/StatisticsDimensions.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` |