fix(heures) : garde backend anti-suppression sur grille périmée (delete explicite)
## Problème Le correctif #31 (dirty-tracking front) ne protège que les sessions qui chargent le nouveau bundle. Un vieil onglet ouvert avant déploiement tourne encore sur l'ancien JS et envoie toute la grille périmée → reproduit en prod : un onglet ouvert le matin a supprimé ~10 lignes saisies dans la journée par d'autres utilisateurs (entrée vide = suppression côté backend, sans garde). ## Correctif (suppression sur intention explicite) `WorkHourBulkUpsertProcessor` ne supprime une ligne existante sur entrée vide QUE si l'entrée porte `delete: true`. Sinon → no-op (ligne préservée). Aucune grille périmée, quel que soit le client, ne peut plus détruire une saisie concurrente. La création de ligne technique de validation reste limitée à `null === $existing`. Le front (à jour) pose `delete: true` sur une ligne vidée volontairement (helper `isEntryEmpty`, après le filtre dirty-tracking) → suppression métier conservée. Flag optionnel ajouté au DTO front (`WorkHourEntryPayload`) et back (`WorkHourBulkUpsert`), défaut false. ## Testabilité Le processor dépend désormais des interfaces repo (`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface`, repos concrets `final` non mockables) → nouveau test unitaire `WorkHourBulkUpsertProcessorTest` (no-op sans flag / suppression avec flag / update normal). ## Limite résiduelle (par choix : suppression explicite, pas verrou optimiste) L'édition explicite d'une ligne sur données périmées peut encore écraser une saisie concurrente sur cette même ligne. Seule la suppression est blindée. ## Vérification - 267 tests PHPUnit OK (dont 3 nouveaux). Front : revue de code (pas de harnais). ## Doc - doc/hours-save-dirty-tracking.md, CLAUDE.md, doc in-app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,10 +45,51 @@ Conséquences :
|
||||
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
|
||||
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
|
||||
|
||||
## Limite connue (hors périmètre de ce correctif)
|
||||
## Garde backend : suppression sur intention explicite (`delete: true`)
|
||||
|
||||
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
|
||||
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
|
||||
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
|
||||
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
|
||||
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
|
||||
Le suivi front **ne protège que les sessions qui chargent le nouveau bundle**. Un **vieil
|
||||
onglet** ouvert avant le déploiement continue de tourner sur l'ancien JavaScript (sans
|
||||
dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (un onglet
|
||||
ouvert le matin a supprimé une dizaine de lignes saisies dans la journée par d'autres
|
||||
utilisateurs). La protection front est donc **insuffisante** : il faut une garde **côté
|
||||
backend**, indépendante de la version du client.
|
||||
|
||||
`WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si
|
||||
la suppression est explicitement demandée** par le flag `delete: true` sur l'entrée :
|
||||
|
||||
```php
|
||||
$deleteRequested = true === ($entry['delete'] ?? false);
|
||||
if ($existing && $deleteRequested) {
|
||||
// suppression (audit 'delete' + remove)
|
||||
} elseif (null === $existing && ($absence || $is4hContract)) {
|
||||
// création d'une ligne technique (validation d'une journée d'absence / contrat 4h)
|
||||
}
|
||||
// existing && !deleteRequested → NO-OP : la ligne existante est préservée
|
||||
```
|
||||
|
||||
Conséquences :
|
||||
|
||||
- Une entrée vide **sans flag** sur une ligne existante est un **no-op** → une grille périmée
|
||||
(n'importe quel client, vieil onglet inclus) **ne peut plus détruire** une saisie concurrente.
|
||||
- Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (entrée vide qui
|
||||
diffère de l'instantané chargé, donc transmise) → la suppression métier reste possible.
|
||||
Helper `isEntryEmpty(...)` dans les deux composables, appliqué après le filtre dirty-tracking.
|
||||
|
||||
Le `delete` est **optionnel** (`WorkHourEntryPayload` front, DTO `WorkHourBulkUpsert` back) et
|
||||
vaut `false` par défaut. Les appels de **création de ligne technique de validation** (toggles de
|
||||
validation quand aucune ligne n'existe) envoient une entrée vide sans flag → inchangés (branche
|
||||
`null === $existing`).
|
||||
|
||||
Tests : `tests/State/WorkHourBulkUpsertProcessorTest.php` (no-op sans flag / suppression avec
|
||||
flag / mise à jour d'une entrée non vide). Le processor dépend des interfaces
|
||||
`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface` (repos concrets `final`,
|
||||
non mockables) pour permettre ces tests unitaires.
|
||||
|
||||
## Limite connue résiduelle (hors périmètre)
|
||||
|
||||
Reste le cas où l'admin **édite explicitement** une ligne sur des données périmées (il voit la
|
||||
ligne vide, tape une valeur, écrasant une saisie concurrente sur cette même ligne). Ce cas
|
||||
relèverait d'un **verrou optimiste** (comparaison d'`updatedAt`/version côté backend), non
|
||||
implémenté ici — choix métier (option « suppression explicite » retenue plutôt que verrou
|
||||
optimiste). Le backend n'a aucune détection de conflit concurrent sur l'**édition** (seule la
|
||||
**suppression** est désormais protégée par l'intention explicite).
|
||||
|
||||
Reference in New Issue
Block a user