Files
SIRH/doc/hours-save-dirty-tracking.md
T
tristan 8e59e9fd6a
Auto Tag Develop / tag (push) Successful in 10s
fix(heures) : n'enregistrer que les lignes modifiées (anti-écrasement concurrent) (#31)
## 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>
2026-06-24 07:16:42 +00:00

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 :

  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é :

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é).