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