From 3a0b65942cda403a36de007fe317b8df28fe996d Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 7 Jul 2026 11:14:07 -0400 Subject: [PATCH] =?UTF-8?q?planif=20Sugg=C3=A9rer=20:=20ordre=20AM?= =?UTF-8?q?=E2=86=92PM=20des=20arr=C3=AAts=20+=20picker=20=C2=AB=20d=C3=A9?= =?UTF-8?q?placer=20vers=20=C2=BB=20filtr=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nnOrder respecte la fenêtre horaire : bloc AM (8-12) → sans-fenêtre → PM (12-16), plus-proche-voisin DANS chaque bloc. Avant : NN pur → une job PM pouvait finir 1re, une « premier en AM » 2e (bug signalé). Utilise le toggle AM/PM explicite OU detectAmpm (texte : « **premier en AM** », « PM Fin de journée »). - Picker « déplacer vers un autre tech » : n'affiche que les techs SÉLECTIONNÉS + compétents par défaut, + « Afficher tous les techniciens… » pour lever le filtre (repli sur tous si aucun sélectionné-compétent). Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/pages/PlanificationPage.vue | 45 ++++++++++++++++++------ 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index fa779af..b027bc0 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1184,6 +1184,8 @@ {{ t.name }} · fusionner ({{ planCountByTech[t.id] }}) Sans la compétence requise + + Afficher tous les techniciens… @@ -1253,6 +1255,8 @@ {{ r.name }}
{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h · sans quart
Sans la compétence + + Afficher tous les techniciens… {{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }} @@ -4811,6 +4815,7 @@ function techQuality (t) { const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b))) async function runSuggestion () { if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return } + showAllMoveTechs.value = false // picker « déplacer vers » : repart filtré aux techs sélectionnés+compétents // « Ré-optimiser l'existant » : le solveur récupère les données des jobs assignés par leur nom (l'heuristique locale // n'a que le pool en mémoire) → on force le solveur VRP. if (suggestDlg.includeAssigned) suggestDlg.strategy = 'optimize' @@ -4934,14 +4939,26 @@ async function optimizeSuggestionLocal () { } // D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau). function nnOrder (entries, home) { - const pts = entries.filter(e => e.lat != null && e.lon != null) - const noLL = entries.filter(e => e.lat == null || e.lon == null) - if (pts.length <= 1) return { ordered: [...pts, ...noLL], km: 0 } - const rem = [...pts]; const out = [] - let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : rem[0] - let km = 0 - while (rem.length) { let bi = 0, bd = Infinity; for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } } if (isFinite(bd)) km += bd; cur = rem[bi]; out.push(rem.splice(bi, 1)[0]) } - return { ordered: [...out, ...noLL], km: Math.round(km * 10) / 10 } + // ORDRE = fenêtre horaire D'ABORD (AM 8-12 → sans-fenêtre → PM 12-16), plus-proche-voisin DANS chaque bloc, + // en chaînant la position d'un bloc au suivant. Respecte « faire en AM / PM » (toggle explicite OU détecté du texte + // ex. « **Passer en premier en AM** », « PM Fin de journée »). AVANT : NN pur → une job PM pouvait finir en 1er. + const ampmKey = e => { const a = jobTimeOf(e.jobName).ampm || detectAmpm(e); return a === 'am' ? 'am' : a === 'pm' ? 'pm' : 'none' } + const blocks = { am: [], none: [], pm: [] } + for (const e of entries) blocks[ampmKey(e)].push(e) + const out = []; let km = 0 + let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : null + for (const key of ['am', 'none', 'pm']) { + const rem = blocks[key].filter(e => e.lat != null && e.lon != null) + const noLL = blocks[key].filter(e => e.lat == null || e.lon == null) + while (rem.length) { + let bi = 0, bd = Infinity + if (cur) for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } } + if (cur && isFinite(bd)) km += bd + cur = rem[bi]; out.push(rem.splice(bi, 1)[0]) + } + out.push(...noLL) + } + return { ordered: out, km: Math.round(km * 10) / 10 } } const estTravelMin = km => Math.round(km / 45 * 60) // ~45 km/h moyen (route + arrêts) → estimation « ≈ » // Regroupé par tech puis par jour, chaque jour ordonné en tournée (D v2) + km/temps de route estimés. @@ -5163,9 +5180,17 @@ const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skill // Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable). const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills)) // Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique — pour les menus d'assignation. -function capableTechsFirst (skills) { return [...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))) } +// Par défaut, le picker « déplacer vers » ne montre QUE les techs SÉLECTIONNÉS pour cette simulation ET compétents ; +// « Afficher tous » (showAllMoveTechs) lève le filtre. Repli sur la liste complète si aucun sélectionné-compétent (jamais vide). +const showAllMoveTechs = ref(false) +function moveTechFilter (list, skills) { + if (showAllMoveTechs.value) return list + const sel = list.filter(t => suggestDlg.techSel[t.id] && covers(t, skills)) + return sel.length ? sel : list +} +function capableTechsFirst (skills) { return moveTechFilter([...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))), skills) } // Une entrée de la revue → objet job-like pour réutiliser techsForJob (classement proximité + occupation). -function techsForEntry (e) { return techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }) } +function techsForEntry (e) { return moveTechFilter(techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }), e.skill ? [e.skill] : []) } const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m }) // Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence. function reassignEntry (e, techId, techName) {