From e454e3f2765624218ceccea06ac994d44354bbb2 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 4 Jun 2026 22:15:50 -0400 Subject: [PATCH] =?UTF-8?q?Garde:=20moteur=20fiable=20=E2=80=94=20s=C3=A9q?= =?UTF-8?q?uence=20d'=C3=A9tapes=20{tech,=20weeks}=20+=20semaine=20d'ancra?= =?UTF-8?q?ge=20(parcours=20d=C3=A9terministe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte du principe de rotation suite aux bugs (3 sem. au lieu de 2, édition non reflétée) : - Séquence = étapes {tech, nb de semaines} ; « 2 semaines consécutives » = weeks:2 (fini les doublons+periodWeeks qui se multipliaient). Tours inégaux = weeks différents par étape. - ANCRAGE explicite (semaine de départ, donnée de référence stockée) : on PARCOURT la séquence semaine par semaine depuis l'ancrage → déterministe, reflète les éditions, pas de dérive. - Vérifié: A×2,B,C ancré 8 juin → A,A,B,C,A,A,B,C (A toujours 2 consécutives) ; réordonner reflète. - Aperçu et génération utilisent le même parcours. Migration auto des anciennes règles (techs+period→steps). Rappel: après édition, re-cliquer « Générer la garde » (l'horizon est réécrit, wipe ciblé du shift). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 96 ++++++++++++------------ 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index bd3d1d0..4e5e951 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -29,7 +29,7 @@ Appliquer le modèle par défaut (consciente des absences) - Rotation de garde par département + Rotation de garde par département Notifier les techs par SMS à la publication @@ -270,7 +270,7 @@ {{ r.dept }} · {{ shiftName(r.shift) }} - {{ gardeDowLabel(r) }} · change tous les {{ r.periodWeeks }} {{ (r.unit || 'occ') === 'week' ? 'sem.' : 'jour(s) de garde' }} · rotation : {{ r.techs.map(id => (techs.find(t => t.id === id) || {}).name || id).join(' → ') }} + {{ gardeDowLabel(r) }} · dès {{ (r.anchor || '').slice(5) }} · {{ gardeSeqLabel(r) }} Modifier (ordre, techs, période) @@ -280,10 +280,9 @@
{{ editingGardeId ? 'Modifier la règle' : 'Nouvelle règle' }}
- + - - Occurrences consécutives avant de passer au tech suivant (1 = change à chaque {{ newGardeRule.unit === 'week' ? 'semaine' : 'jour de garde' }}) + Donnée de référence : la séquence commence à cette semaine (ancrage stable dans le temps).
Plages (hors bureau) : @@ -297,14 +296,15 @@
-
-
Séquence de rotation ({{ newGardeRule.techs.length }} positions — doublons permis pour des tours inégaux) :
-
+
+
Séquence ({{ newGardeRule.steps.length }} étapes) — « sem. » = nb de semaines consécutives du tech avant de passer au suivant :
+
{{ i + 1 }}. - + + Monter - Descendre - Retirer + Descendre + Retirer
@@ -315,7 +315,7 @@
- + @@ -395,7 +395,7 @@ const activeCell = ref(null) // dernière case cliquée {id, name, iso} — pour const anchor = ref(null) const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([]) const gardeRules = ref([]); const showGarde = ref(false) -const newGardeRule = reactive({ dept: '', shift: '', weekdays: [], periodWeeks: 1, unit: 'week', techs: [] }) +const newGardeRule = reactive({ dept: '', shift: '', weekdays: [], anchor: '', steps: [] }) const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }] const history = ref([]); const future = ref([]) const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40) @@ -634,57 +634,55 @@ const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine const gardeTemplateOptions = computed(() => templates.value.slice().sort((a, b) => (b.on_call ? 1 : 0) - (a.on_call ? 1 : 0)).map(t => ({ label: t.template_name + (t.on_call ? ' 🛡️' : ''), value: t.name }))) const groupNames = computed(() => [...new Set(techs.value.map(t => t.group).filter(Boolean))].sort()) const editingGardeId = ref(null); const gardePick = ref(null) -function addTechToSeq () { if (gardePick.value) { newGardeRule.techs.push(gardePick.value); gardePick.value = null } } // doublons permis (tours inégaux) -function moveTech (i, dir) { const a = newGardeRule.techs; const j = i + dir; if (j < 0 || j >= a.length) return; const x = a[i]; a.splice(i, 1); a.splice(j, 0, x) } -function editGardeRule (r) { Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, weekdays: [...r.weekdays], periodWeeks: r.periodWeeks || 1, unit: r.unit || 'week', techs: [...r.techs] }); editingGardeId.value = r.id } +function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) } +function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) } +function weekNo (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } // n° de semaine absolu (réf. lundi) +function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayISO(start.value); showGarde.value = true } +// Séquence = étapes {tech, weeks}. Ajouter à la suite (doublons OK), réordonner, retirer. +function addTechToSeq () { if (gardePick.value) { newGardeRule.steps.push({ tech: gardePick.value, weeks: 1 }); gardePick.value = null } } +function moveTech (i, dir) { const a = newGardeRule.steps; const j = i + dir; if (j < 0 || j >= a.length) return; const x = a[i]; a.splice(i, 1); a.splice(j, 0, x) } +function editGardeRule (r) { + const steps = (r.steps && r.steps.length) ? r.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) : (r.techs || []).map(t => ({ tech: t, weeks: r.periodWeeks || 1 })) + Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps }) + editingGardeId.value = r.id +} const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0] function isSetActive (set) { return set.length && set.every(v => newGardeRule.weekdays.includes(v)) } function toggleWeekdaysSet (set) { if (isSetActive(set)) newGardeRule.weekdays = newGardeRule.weekdays.filter(v => !set.includes(v)); else newGardeRule.weekdays = [...new Set([...newGardeRule.weekdays, ...set])] } -function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) } -function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) } -function weekIndex (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } -// Nb de jours de garde (matching weekdays) depuis l'époque, AVANT cette date → fait avancer la séquence jour par jour. -function occurrenceIndex (rule, iso) { - const days = Math.round((d2ms(iso) - d2ms(GARDE_EPOCH)) / 86400000); if (days <= 0) return 0 - const wd = rule.weekdays || []; let cnt = Math.floor(days / 7) * wd.length - const epochDow = dowOf(GARDE_EPOCH) - for (let i = 0; i < days % 7; i++) if (wd.includes((epochDow + i) % 7)) cnt++ - return cnt +function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) } +// ── Moteur de rotation : on PARCOURT la séquence semaine par semaine depuis l'ANCRAGE ── +function cycleWeeks (steps) { return (steps || []).reduce((s, x) => s + (Number(x.weeks) || 1), 0) } +function stepTechAt (steps, w) { for (const s of steps) { const n = Number(s.weeks) || 1; if (w < n) return s.tech; w -= n } return steps[0] && steps[0].tech } +function rotationTech (rule, iso) { + const steps = rule.steps || []; const cyc = cycleWeeks(steps); if (!cyc) return null + const anchor = rule.anchor || mondayISO(iso) + const w0 = (((weekNo(iso) - weekNo(anchor)) % cyc) + cyc) % cyc + for (let k = 0; k < cyc; k++) { const id = stepTechAt(steps, (w0 + k) % cyc); if (id && !isAbsent(id, iso)) return id } // saut d'absent + return stepTechAt(steps, w0) } -// Index de rotation : par OCCURRENCE de garde (défaut → la séquence continue chaque jour de garde) ou par SEMAINE (bloc). -function gardeIndex (rule, iso) { return Math.floor(((rule.unit || 'week') === 'week' ? weekIndex(iso) : occurrenceIndex(rule, iso)) / (rule.periodWeeks || 1)) } -// Aperçu : qui est de garde sur les prochaines semaines (depuis la semaine affichée) — reflète la file en cours d'édition +// Aperçu : qui est de garde, semaine par semaine, depuis l'ancrage — reflète la file en cours d'édition (ignore absences) const gardePreview = computed(() => { - const rule = newGardeRule; if (!rule.techs.length || !rule.weekdays.length) return [] - const out = []; const wk0 = mondayISO(start.value) - for (let i = 0; i < 8; i++) { - const ws = addDaysISO(wk0, i * 7); let iso = null - for (let k = 0; k < 7; k++) { const d = addDaysISO(ws, k); if (rule.weekdays.includes(dowOf(d))) { iso = d; break } } - if (!iso) continue - const base = gardeIndex(rule, iso); const id = rule.techs[((base % rule.techs.length) + rule.techs.length) % rule.techs.length] + const rule = newGardeRule; const cyc = cycleWeeks(rule.steps); if (!cyc || !rule.weekdays.length) return [] + const anchor = rule.anchor || mondayISO(start.value); const out = [] + for (let i = 0; i < Math.min(14, cyc + 4); i++) { + const ws = addDaysISO(mondayISO(anchor), i * 7); const w0 = (((weekNo(ws) - weekNo(anchor)) % cyc) + cyc) % cyc + const id = stepTechAt(rule.steps, w0) out.push({ week: ws, name: (techs.value.find(t => t.id === id) || {}).name || id }) } return out }) -// Tech de garde pour une date ; saute un tech absent au profit du suivant. -function rotationTech (rule, iso) { - if (!rule.techs || !rule.techs.length) return null - const base = gardeIndex(rule, iso) - for (let k = 0; k < rule.techs.length; k++) { const id = rule.techs[((base + k) % rule.techs.length + rule.techs.length) % rule.techs.length]; if (!isAbsent(id, iso)) return id } - return rule.techs[((base % rule.techs.length) + rule.techs.length) % rule.techs.length] -} -function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) } function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) } function addGardeRule () { - if (!newGardeRule.shift || !newGardeRule.techs.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et techs requis (département optionnel)' }); return } - const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, weekdays: [...newGardeRule.weekdays], periodWeeks: newGardeRule.periodWeeks || 1, unit: newGardeRule.unit || 'occ', techs: [...newGardeRule.techs] } + if (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return } + const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) } if (editingGardeId.value) gardeRules.value = gardeRules.value.map(r => r.id === editingGardeId.value ? rule : r) else gardeRules.value = [...gardeRules.value, rule] - saveGarde(); editingGardeId.value = null; newGardeRule.techs = []; newGardeRule.weekdays = [] - $q.notify({ type: 'positive', message: 'Règle de garde enregistrée' }) + saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = [] + $q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' }) } function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null } -function gardeDowLabel (r) { return r.weekdays.map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') } +function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') } +function gardeSeqLabel (r) { return (r.steps || []).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') } // Génère les gardes de la semaine affichée selon les règles (rotation par département) const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent) // Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine.