## 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>
3.0 KiB
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 :
- Un admin ouvre l'écran ; la ligne d'un salarié (ex. utilisateur
ROLE_SELF) est vide. - Ce salarié saisit ses heures dans sa propre session → ligne créée en BDD, non validée (donc non verrouillée).
- L'admin, sur sa grille périmée, saisit les heures d'autres employés et enregistre.
- 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é :
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é).