From 86bd8080d2b34b4c0db53560054af2fde717d089 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 15:35:25 -0400 Subject: [PATCH] fix(dispatch): optimizer respects existing load; drop pseudo-tech placeholders; edit skills from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overload fix: each vehicle's window is reduced by the tech's already- assigned load that day (shift_start += existing) — no more 20.8/16h (verified: William 20.8→5.3/8h). Capacity is now real. - No more "Tournée N / non casé" pseudo-techs: the optimizer uses ONLY the selected techs; everything it can't place goes to ONE plain "⚠️ Non assignés" bucket (weekend + capacity/skill overflow). Assign them via the proximity+load ⇄ menu, or add techs. - Not forced to fill every tech: unneeded techs are simply left empty (day off) — the VRP no longer fake-distributes. - Edit/reorder skills from the optimizer: ✏️ on each tech header opens the same skill editor (drag-reorder chips + ★ + rank list). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 28 ++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 135476a..fbaf0b6 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1052,6 +1052,8 @@ {{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16 + + Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité {{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }} @@ -1811,7 +1813,7 @@ const dayList = computed(() => { function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() } const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés) // File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__||` (tournées sans quart, tout jour). -const isHoldId = id => id === '__wknd__' || (typeof id === 'string' && id.startsWith('__hold__')) +const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || (typeof id === 'string' && id.startsWith('__hold__')) // ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer // les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ───────────── const dayMode = computed(() => dayList.value.length === 1) @@ -2177,6 +2179,7 @@ const skillDialog = ref(null) // tech dont on édite les compétences const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée) const skillMenuShown = ref(false) function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true } +function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur // TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON). // Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale). function skillLevelOf (t, sk) { return (t.skill_levels && t.skill_levels[sk]) || 0 } // 0 = non défini (étoiles vides) ; pas de défaut trompeur @@ -4125,11 +4128,9 @@ async function optimizeSuggestion () { buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné → à créer au moment du Publier const plan = suggestDlg.plan - const byDay = {}; const kept = [] + const byDay = {}; const unassigned = [] // PAS de faux « tech placeholder » : on n'utilise QUE les techs sélectionnés ; le reste = 1 seule liste « non assignés » for (const e of plan) { - // Les placeholders de SEMAINE sont replongés dans leur jour : on suppose un quart pour chaque tech sélectionné → le solveur les case. - // Les placeholders de WEEK-END restent (jamais de quart auto le week-end). - if (e.placeholder && isWeekendIso(e.iso)) { kept.push(e); continue } + if (e.placeholder && isWeekendIso(e.iso)) { unassigned.push(e); continue } // week-end : pas de quart auto → non assigné ;(byDay[e.iso] = byDay[e.iso] || []).push(e) } const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) @@ -4137,7 +4138,7 @@ async function optimizeSuggestion () { const poolPr = Object.fromEntries((assignPanel.jobs || []).map(j => [j.name, String(j.priority || '').toLowerCase()])) const prBoost = p => ({ urgent: 400000, high: 400000, 'élevée': 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0) const prEarly = p => ({ urgent: 10, high: 4, 'élevée': 4 }[p] || 0) // priorité → poids « servir tôt » - const out = [...kept] + const out = [] for (const iso of Object.keys(byDay)) { const entries = byDay[iso] const byName = Object.fromEntries(entries.map(e => [e.jobName, e])) @@ -4154,20 +4155,25 @@ async function optimizeSuggestion () { // Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule. const w = shiftWindowMin(t.id, iso) || (isWeekendIso(iso) ? null : { start: 480, end: 960 }) if (!w) continue + // RÉDUIT la capacité par la charge DÉJÀ assignée ce jour (sinon surcharge, ex. « déjà 8.8h » + 12h → 20/16h). + const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0 + const startMin = Math.min(w.end - 15, w.start + usedMin) const h = techHomes.value[t.id] - vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: (h && isFinite(+h.lat)) ? +h.lat : null, home_lon: (h && isFinite(+h.lon)) ? +h.lon : null, shift_start_min: w.start, shift_end_min: w.end }) + vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: (h && isFinite(+h.lat)) ? +h.lat : null, home_lon: (h && isFinite(+h.lon)) ? +h.lon : null, shift_start_min: startMin, shift_end_min: w.end }) } - if (!vehicles.length) { out.push(...entries); continue } // aucun tech éligible (ex. week-end sans quart) → garder l'assignation greedy + if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés let res - try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh }) } catch (e) { out.push(...entries); continue } // réglages ajustables (curseur distance↔spécialité, vitesse, budget calcul) - if (!res || res.status !== 'OK') { out.push(...entries); continue } // solveur absent/erreur/infaisable → on GARDE l'assignation greedy (ne jamais perdre de jobs) + try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh }) } catch (e) { unassigned.push(...entries); continue } + if (!res || res.status !== 'OK') { unassigned.push(...entries); continue } // solveur absent/erreur → non assignés (ne jamais perdre de jobs) const placed = new Set() for (const rt of (res.routes || [])) { const t = selTechs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name } for (const st of (rt.stops || [])) { const e0 = byName[st.job_id]; if (!e0) continue; placed.add(st.job_id); out.push({ ...e0, techId: t.id, techName: t.name, placeholder: false, noShift: !hasShiftDay(t.id, iso), capable: true }) } // noShift → pastille « Créer quart » } - for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (!e0 || placed.has(jid)) continue; out.push({ ...e0, techId: `__hold__|${iso}|opt`, techName: `⏳ ${FR_DOW[dowOf(iso)] || ''} ${iso.slice(8)} — non casé (à assigner)`, placeholder: true, noShift: false }) } + for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (e0 && !placed.has(jid)) unassigned.push(e0) } } + // UN SEUL groupe « Non assignés » (aucun faux tech) : capacité/compétence insuffisante avec les techs sélectionnés → en ajouter, ou assigner à la main via le menu ⇄ (classé proximité + charge). + for (const e of unassigned) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false }) suggestDlg.plan = out } // D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).