fix(heures) : n'enregistrer que les lignes modifiées (anti-écrasement concurrent) (#31)
Auto Tag Develop / tag (push) Successful in 10s
Auto Tag Develop / tag (push) Successful in 10s
## 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>
This commit was merged in pull request #31.
This commit is contained in:
@@ -48,6 +48,11 @@ export const useDriverHoursPage = () => {
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const sitesInitialized = ref(false)
|
||||
const rows = ref<Record<number, DriverHourRow>>({})
|
||||
// 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<Record<number, DriverHourRow>>({})
|
||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||
const absenceTypes = ref<AbsenceType[]>([])
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user