fix(heures) : n'enregistrer que les lignes modifiées (anti-écrasement concurrent) (#31)
Auto Tag Develop / tag (push) Successful in 10s
Auto Tag Develop / tag (push) Successful in 10s
## Problème Sur l'écran **Heures** / **Heures Conducteurs**, l'enregistrement envoyait au bulk-upsert une entrée pour **tous** les employés visibles non verrouillés, à partir de l'état en mémoire de la grille. Le backend (`WorkHourBulkUpsertProcessor`) traitant une **entrée vide comme une suppression**, un admin avec une grille **périmée** pouvait supprimer une ligne saisie entre-temps par un autre utilisateur. ### Scénario reproduit 1. Un admin ouvre l'écran ; la ligne d'un salarié `ROLE_SELF` est vide. 2. Ce salarié saisit ses heures dans sa propre session → ligne créée, **non validée** (donc non verrouillée). 3. L'admin, sur sa grille périmée, enregistre d'autres employés. 4. Le payload contient une entrée **vide** pour le salarié → le backend supprime sa ligne. **Perte de données.** ## Correctif (suivi des lignes modifiées) `hydrateRows` capture un instantané `loadedRows` de l'état chargé depuis le serveur. `handleSave` ne transmet plus que les lignes **dont l'état courant diffère de l'instantané**. - Ligne **intouchée** → jamais envoyée → jamais supprimée ✅ - Ligne **vidée volontairement** → envoyée vide → supprimée (métier conservé) - Ligne **remplie/modifiée** → envoyée → créée/mise à jour Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. ## Limite connue Pas de verrou optimiste backend : l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (hors périmètre). ## Doc - `doc/hours-save-dirty-tracking.md` (nouveau) - Note `CLAUDE.md` (section *Validation Rules*) ## Vérification - Pre-commit hook : **236 tests PHPUnit OK**. - Pas de harnais de tests frontend (revue de code uniquement). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #31 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #31.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Enregistrement des heures — envoi des seules lignes modifiées
|
||||
|
||||
## Problème corrigé (perte de données par écrasement « à l'aveugle »)
|
||||
|
||||
L'écran **Heures** (et **Heures Conducteurs**) présente une grille d'une journée avec
|
||||
**tous** les employés du périmètre. L'enregistrement (`POST /work-hours/bulk-upsert`,
|
||||
`WorkHourBulkUpsertProcessor`) a une sémantique **upsert par (employé, date)** où une
|
||||
**entrée vide supprime** la ligne existante (« une ligne vide supprime l'enregistrement »).
|
||||
|
||||
Avant correctif, `handleSave` (front) envoyait une entrée pour **chaque** employé visible non
|
||||
verrouillé, à partir de l'état en mémoire de la grille. Conséquence en cas de **concurrence** :
|
||||
|
||||
1. Un admin ouvre l'écran ; la ligne d'un salarié (ex. utilisateur `ROLE_SELF`) est vide.
|
||||
2. Ce salarié saisit ses heures dans sa propre session → ligne créée en BDD, **non validée**
|
||||
(donc non verrouillée).
|
||||
3. L'admin, sur sa grille **périmée**, saisit les heures d'**autres** employés et enregistre.
|
||||
4. Le payload contient une entrée **vide** pour le salarié (état périmé). Le backend relit la
|
||||
BDD (ligne désormais remplie), constate « entrée vide ≠ existant » → **supprime** la ligne
|
||||
fraîchement saisie. Perte de données.
|
||||
|
||||
## Correctif (suivi des lignes modifiées côté front)
|
||||
|
||||
`hydrateRows` capture désormais un **instantané** des lignes telles que chargées depuis le
|
||||
serveur (`loadedRows`, clone indépendant de `rows`). À l'enregistrement, `handleSave` ne
|
||||
transmet **que les lignes dont l'état courant diffère de l'instantané chargé** :
|
||||
|
||||
```ts
|
||||
const entries = candidates
|
||||
.map((employee) => ({
|
||||
current: buildEntry(employee, rows.value[employee.id] ?? emptyRow()),
|
||||
original: buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow()),
|
||||
}))
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
```
|
||||
|
||||
Conséquences :
|
||||
|
||||
- Une ligne **intouchée** n'est jamais transmise → jamais supprimée, même si un autre
|
||||
utilisateur l'a saisie/modifiée entre-temps. **C'est le correctif du bug.**
|
||||
- Une ligne **vidée volontairement** par l'utilisateur diffère de l'instantané → transmise
|
||||
vide → supprimée (comportement métier conservé).
|
||||
- Une ligne **remplie** diffère → transmise → créée/mise à jour.
|
||||
|
||||
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)
|
||||
|
||||
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é).
|
||||
Reference in New Issue
Block a user