Files
SIRH/doc/hours-save-dirty-tracking.md
T
tristan 5bec2c38cf fix(heures) : n'enregistrer que les lignes modifiées (anti-écrasement concurrent)
L'écran Heures / Heures Conducteurs envoyait au bulk-upsert une entrée pour
tous les employés visibles non verrouillés, à partir de l'état en mémoire.
Le backend 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 (ex. ROLE_SELF non encore validé, donc non verrouillé).

handleSave capture désormais un instantané des lignes chargées (loadedRows,
dans hydrateRows) et ne transmet que les lignes dont l'état courant en diffère.
Une ligne intouchée n'est jamais envoyée → jamais supprimée.

Symétrique dans useHoursPage.ts et useDriverHoursPage.ts.
Doc : doc/hours-save-dirty-tracking.md + note CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:03:59 +02: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é).