feat: record disposition deleted event for statistics
This commit is contained in:
+26
-4
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user