From 5bec2c38cfcaf4a72921be6da5e1a2b8bd3d21c9 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 09:03:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(heures)=20:=20n'enregistrer=20que=20les=20l?= =?UTF-8?q?ignes=20modifi=C3=A9es=20(anti-=C3=A9crasement=20concurrent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 1 + doc/hours-save-dirty-tracking.md | 54 ++++++++++++++++ frontend/composables/useDriverHoursPage.ts | 71 +++++++++++++-------- frontend/composables/useHoursPage.ts | 73 ++++++++++++++-------- 4 files changed, 146 insertions(+), 53 deletions(-) create mode 100644 doc/hours-save-dirty-tracking.md diff --git a/CLAUDE.md b/CLAUDE.md index 97db153..3cbd374 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ - `isSiteValid` (site manager): locks for non-admin, admin can still edit - Any real modification resets both `isSiteValid=false` and `isValid=false` - No-op saves preserve existing validations +- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : 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. Doc : `doc/hours-save-dirty-tracking.md`. ## Overtime Rules - Contracts <= 35h: +25% from 35h to 43h, +50% beyond diff --git a/doc/hours-save-dirty-tracking.md b/doc/hours-save-dirty-tracking.md new file mode 100644 index 0000000..4405b03 --- /dev/null +++ b/doc/hours-save-dirty-tracking.md @@ -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é). diff --git a/frontend/composables/useDriverHoursPage.ts b/frontend/composables/useDriverHoursPage.ts index 6faae56..08d5ccf 100644 --- a/frontend/composables/useDriverHoursPage.ts +++ b/frontend/composables/useDriverHoursPage.ts @@ -48,6 +48,11 @@ export const useDriverHoursPage = () => { const selectedSiteIds = ref([]) const sitesInitialized = ref(false) const rows = ref>({}) + // Instantané des lignes telles que chargées depuis le serveur (clé = employeeId). + // Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées, afin de ne jamais + // écraser/supprimer une ligne saisie entre-temps par un autre utilisateur (enregistrement + // « à l'aveugle » d'une grille périmée). + const loadedRows = ref>({}) const dayContext = ref(null) const weeklySummary = ref(null) const absenceTypes = ref([]) @@ -458,6 +463,10 @@ export const useDriverHoursPage = () => { } rows.value = nextRows + // Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci. + loadedRows.value = Object.fromEntries( + Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }]) + ) } const loadAbsenceTypes = async () => { @@ -924,6 +933,32 @@ export const useDriverHoursPage = () => { await refreshByDate() }) + // Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé). + const buildEntry = (employeeId: number, row: DriverHourRow) => { + const dayMin = toMinutes(row.dayHours) + const nightMin = toMinutes(row.nightHours) + const workshopMin = toMinutes(row.workshopHours) + + return { + employeeId, + morningFrom: null, + morningTo: null, + afternoonFrom: null, + afternoonTo: null, + eveningFrom: null, + eveningTo: null, + isPresentMorning: false, + isPresentAfternoon: false, + dayHoursMinutes: dayMin || null, + nightHoursMinutes: nightMin || null, + workshopHoursMinutes: workshopMin || null, + hasBreakfast: row.hasBreakfast, + hasLunch: row.hasLunch, + hasDinner: row.hasDinner, + hasOvernight: row.hasOvernight + } + } + const handleSave = async () => { if (isSubmitting.value || employees.value.length === 0) return @@ -933,32 +968,16 @@ export const useDriverHoursPage = () => { (e) => e.isDriver === true && hasContractAtSelectedDate(e.id) ) - const entries = driverEmployees.map((employee) => { - const employeeId = employee.id - const row = rows.value[employeeId] ?? emptyRow() - const dayMin = toMinutes(row.dayHours) - const nightMin = toMinutes(row.nightHours) - const workshopMin = toMinutes(row.workshopHours) - - return { - employeeId, - morningFrom: null, - morningTo: null, - afternoonFrom: null, - afternoonTo: null, - eveningFrom: null, - eveningTo: null, - isPresentMorning: false, - isPresentAfternoon: false, - dayHoursMinutes: dayMin || null, - nightHoursMinutes: nightMin || null, - workshopHoursMinutes: workshopMin || null, - hasBreakfast: row.hasBreakfast, - hasLunch: row.hasLunch, - hasDinner: row.hasDinner, - hasOvernight: row.hasOvernight - } - }) + const entries = driverEmployees + .map((employee) => { + const current = buildEntry(employee.id, rows.value[employee.id] ?? emptyRow()) + const original = buildEntry(employee.id, loadedRows.value[employee.id] ?? emptyRow()) + return { current, original } + }) + // N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais + // transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps. + .filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original)) + .map(({ current }) => current) if (entries.length === 0) return diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts index 76aaf1e..ac653e3 100644 --- a/frontend/composables/useHoursPage.ts +++ b/frontend/composables/useHoursPage.ts @@ -50,6 +50,11 @@ export const useHoursPage = () => { const selectedSiteIds = ref([]) const sitesInitialized = ref(false) const rows = ref>({}) + // Instantané des lignes telles que chargées depuis le serveur (clé = employeeId). + // Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées par l'utilisateur, + // afin de ne jamais écraser/supprimer une ligne saisie entre-temps par un autre utilisateur + // (perte de données par enregistrement « à l'aveugle » d'une grille périmée). + const loadedRows = ref>({}) const dayContext = ref(null) const weeklySummary = ref(null) const absenceTypes = ref([]) @@ -600,6 +605,10 @@ export const useHoursPage = () => { } rows.value = nextRows + // Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci. + loadedRows.value = Object.fromEntries( + Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }]) + ) } const loadAbsenceTypes = async () => { @@ -1136,6 +1145,36 @@ export const useHoursPage = () => { await refreshByDate() }) + // Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé). + const buildEntry = (employee: Employee, row: HourRow) => { + const employeeId = employee.id + if (isPresenceTracking(employee)) { + return { + employeeId, + morningFrom: null, + morningTo: null, + afternoonFrom: null, + afternoonTo: null, + eveningFrom: null, + eveningTo: null, + isPresentMorning: row.isPresentMorning, + isPresentAfternoon: row.isPresentAfternoon + } + } + + return { + employeeId, + morningFrom: normalizeTime(row.morningFrom), + morningTo: normalizeTime(row.morningTo), + afternoonFrom: normalizeTime(row.afternoonFrom), + afternoonTo: normalizeTime(row.afternoonTo), + eveningFrom: normalizeTime(row.eveningFrom), + eveningTo: normalizeTime(row.eveningTo), + isPresentMorning: false, + isPresentAfternoon: false + } + } + const handleSave = async () => { if (isSubmitting.value || employees.value.length === 0) return @@ -1144,34 +1183,14 @@ export const useHoursPage = () => { const entries = employees.value .filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id)) .map((employee) => { - const employeeId = employee.id - const row = rows.value[employeeId] ?? emptyRow() - if (isPresenceTracking(employee)) { - return { - employeeId, - morningFrom: null, - morningTo: null, - afternoonFrom: null, - afternoonTo: null, - eveningFrom: null, - eveningTo: null, - isPresentMorning: row.isPresentMorning, - isPresentAfternoon: row.isPresentAfternoon - } - } - - return { - employeeId, - morningFrom: normalizeTime(row.morningFrom), - morningTo: normalizeTime(row.morningTo), - afternoonFrom: normalizeTime(row.afternoonFrom), - afternoonTo: normalizeTime(row.afternoonTo), - eveningFrom: normalizeTime(row.eveningFrom), - eveningTo: normalizeTime(row.eveningTo), - isPresentMorning: false, - isPresentAfternoon: false - } + const current = buildEntry(employee, rows.value[employee.id] ?? emptyRow()) + const original = buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow()) + return { current, original } }) + // N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais + // transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps. + .filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original)) + .map(({ current }) => current) if (entries.length === 0) { return -- 2.39.5