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