From 6c5ed0060fbaf0beab646e03c333545485dfd91c Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 11:58:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20=C2=AB=20Sugg=C3=A9rer=20=C2=BB?= =?UTF-8?q?=20ne=20r=C3=A9partit=20que=20les=20jobs=20des=20jours=20coch?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dispatch auto utilisait tout le pool avec son propre sélecteur de jour, en ignorant les chips de date du pool (et même la sélection lasso, sur le chemin solveur hub). Désormais les chips pilotent le dispatch : - FENÊTRE = les chips de date cochés (⏰ en retard / Sans date → placés aujourd'hui) ; aucun chip → repli sur le sélecteur Aujourd'hui/Demain/date. - JEU DE JOBS restreint aux jours cochés (ou à la sélection lasso) sur les 3 chemins : heuristique locale, solveur local, ET solveur hub (nouveau param `job_names` dans optimize-plan, additif/rétrocompatible). - UI : bouton « Suggérer (N) » + dialogue « N job(s) des jours cochés → jours ». - Helper partagé jobMatchesDateChips (dé-dup avec le filtre d'affichage). Vérifié en preview : chip 07-06 → 19 arrêts tous le 07-06 (local) ; chips 07-06+07-07 → 34 arrêts, uniquement ces 2 jours (solveur hub déployé). Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/pages/PlanificationPage.vue | 62 ++++++++++++++++++------ services/targo-hub/lib/roster.js | 2 + 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index fb91ac9..acbb6dc 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -880,7 +880,7 @@
Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }}) Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse → base RQA + GPS (ils apparaissent ensuite sur la carte) - Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection' : '' }} — tu revois et ajustes avant d'appliquer + Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection lasso' : (assignDateFilter.length ? ' pour les jours cochés' : '') }} — tu revois et ajustes avant d'appliquer Carte des jobs (regrouper par région) @@ -1020,13 +1020,21 @@ Vitesse de repli (nœuds sans coordonnées) Budget de calcul par jour
- +
- Répartir pour : - - - - jobs en retard/sans date → tirés sur ce jour + Répartir : + +
Rapide : @@ -2564,10 +2572,18 @@ const assignDates = computed(() => { function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] } // Depuis l'aperçu « jobs sur la colonne du jour » → ouvre le panneau « À assigner » filtré sur ce jour. async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] } +// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées. +// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »). +function jobMatchesDateChips (j) { + const df = assignDateFilter.value; if (!df.length) return true + const ds = new Set(df); const t = todayISO() + const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date' + return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) +} const assignJobsFiltered = computed(() => { let arr = assignPanel.jobs const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) } - const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); const t = todayISO(); arr = arr.filter(j => { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) }) } // '__overdue__' = toutes dates passées + if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips) return arr }) function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) } @@ -4233,8 +4249,7 @@ function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.durati function buildSuggestion () { const useSel = selectedNames.value.length > 0 suggestDlg.fromSel = useSel - let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold') - if (useSel) jobs = jobs.filter(j => selectedJobs[j.name]) + const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés) const days = (dayList.value || []); const dayIsos = days.map(d => d.iso) const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour) if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return } @@ -4347,19 +4362,35 @@ function buildSuggestion () { suggestDlg.plan = plan } function openSuggest () { - suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.) + suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // repli si aucun chip coché suggestDlg.fromSel = selectedNames.value.length > 0 - // Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles. + // Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles. + const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur const techs = visibleTechs.value || [] - const withShift = techs.filter(t => hasShiftDay(t.id, suggestDay.value)) + const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso))) const base = withShift.length ? withShift : techs const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true } const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length) -// Dispatch auto = UNE SEULE journée (celle sélectionnée). Les jobs en retard / sans date sont tirés sur CE jour. Non destructif : on revoit avant d'appliquer (re-simuler aujourd'hui = OK). +// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) → le jour choisi dans le sélecteur. +// Les chips « ⏰ en retard » / « Sans date » ne sont pas des jours calendaires → les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer). const suggestDay = ref('') -const suggestWindow = computed(() => [suggestDay.value || todayISO()]) +const suggestWindow = computed(() => { + const f = assignDateFilter.value || [] + if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché → sélecteur de jour du dialogue + const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur) + if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date → placés aujourd'hui + return [...days].sort() +}) +// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon le pool filtré par les CHIPS de date. +const suggestJobs = () => { + const useSel = selectedNames.value.length > 0 + let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold') + jobs = useSel ? jobs.filter(j => selectedJobs[j.name]) : jobs.filter(jobMatchesDateChips) + return jobs +} +const suggestJobCount = computed(() => suggestJobs().length) const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso)) const suggestWindowLabel = computed(() => { const w = suggestWindow.value; if (!w.length) return '—' @@ -4436,6 +4467,7 @@ async function optimizeSuggestion () { try { const r = await roster.optimizePlanHub({ dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id), + job_names: suggestJobs().map(j => j.name), // ne répartir QUE les jobs des jours cochés (ou la sélection lasso) opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef }, dur_overrides: { ...durOverride }, job_windows, }) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 3b3ccf6..82ddf61 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -165,6 +165,7 @@ async function optimizePlan (body) { const durOv = body.dur_overrides || {} const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM) const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null + const wantJobs = Array.isArray(body.job_names) && body.job_names.length ? new Set(body.job_names) : null // ne répartir QUE ces jobs (jours cochés / sélection UI) const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([ buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span), @@ -197,6 +198,7 @@ async function optimizePlan (body) { // 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau. const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = [] for (const j of pool) { + if (wantJobs && !wantJobs.has(j.name)) continue // restreint aux jobs demandés (chips de date / sélection) const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null if (sched && sched > hi) { skipped.push(j.name); continue } const iso = (sched && dset.has(sched)) ? sched : dates[0]