From f5a7547c1fd03bda400539bca46361bb3eba622c Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 14:42:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20urgency=20=E2=86=92=20early-i?= =?UTF-8?q?n-day=20+=20AM/PM=20windows=20per=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time-aware dispatch in the VRP optimizer: - solver: per-job urgent_weight → SetCumulVarSoftUpperBound(idx, 0, w), a soft cost on arrival time that pulls urgent jobs to the START of the route (conditions the tech's day start). Verified: a far urgent job is served first instead of last. AM/PM handled via existing tw_start/end. - SPA: per-entry AM / PM / ⚡ chips (session map suggestDlg.jobTime, survives re-solves) + a "Ré-optimiser" button. optimizeSuggestion maps AM→[480,720], PM→[720,960] (hard window) and urgency (job priority OR manual ⚡) → urgent_weight. Verified: chips render, toggle persists across re-optimize, OSRM still engaged. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 30 ++++++++++++++++++++++-- services/roster-solver/route_solver.py | 7 +++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 5c2715e..734943f 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1075,6 +1075,10 @@ {{ Math.round(e.dur * 10) / 10 }}h {{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner Aucun quart publié ce jour (assignable, mais ⚠ hors quart) + + Avant-midi (8-12) — Ré-optimiser pour appliquerAM + Après-midi (12-16) — Ré-optimiser pour appliquerPM + Urgent — servir en DÉBUT de journée (Ré-optimiser) Déplacer vers un autre tech @@ -1097,6 +1101,7 @@ + Relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs @@ -3904,7 +3909,12 @@ function selectAllSector (city) { if (assignPanel.showMap) refreshAssignMap() } // ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ── -const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'smart', view: 'list' }) // strategy: optimize|smart|best|balance|enough · view: list|map +const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'smart', view: 'list', jobTime: {} }) // strategy: optimize|smart|best|balance|enough · view: list|map · jobTime: {jobName:{ampm,early}} +// Contraintes horaires par job (⚡ Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence → début de journée). Session (clé = jobName, survit aux ré-optimisations). +const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge) +function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} } +function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } } +function toggleJobEarly (name) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, early: !c.early } } // Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement. const solverOpts = reactive({ rankWeight: 2, speedKmh: 45, maxSeconds: 8 }) try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ } @@ -4088,6 +4098,12 @@ async function runSuggestion () { suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut suggestDlg.mode = 'review' } +// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs. +async function reoptimize () { + suggestDlg.strategy = 'optimize'; suggestDlg.building = true + try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false } + suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' +} // Fenêtre de quart (min depuis minuit) d'un tech un jour donné, depuis ses cellules + le modèle. null si aucun quart régulier. function shiftWindowMin (techId, iso) { let s = Infinity, e = -Infinity @@ -4112,11 +4128,17 @@ async function optimizeSuggestion () { // Priorité du job → « priority_boost » (pénalité de drop) : URGENT jamais laissé de côté ; BASSE lâchée en 1er si la capacité manque. 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: 6, high: 3, 'élevée': 3 }[p] || 0) // priorité → poids « servir tôt » const out = [...kept] for (const iso of Object.keys(byDay)) { const entries = byDay[iso] const byName = Object.fromEntries(entries.map(e => [e.jobName, e])) - const jobs = entries.map(e => ({ id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(poolPr[e.jobName]) })) + const jobs = entries.map(e => { + const jt = suggestDlg.jobTime[e.jobName] || {} + const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure + const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), jt.early ? 12 : 0) // urgence (priorité OU ⚡ manuel) → début de journée + return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(poolPr[e.jobName]), urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) } + }) const vehicles = [] for (const t of selTechs) { // Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule. @@ -5067,6 +5089,10 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .suggest-occ-fill.high { background: #f59e0b; } .suggest-occ-fill.over { background: #ef4444; } .suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; } +.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; } +.jt-chip:hover { border-color: #6366f1; } +.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; } +.jt-early.on { background: #f59e0b; border-color: #f59e0b; } .suggest-day { padding: 2px 8px 6px; } .suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; } .suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; } diff --git a/services/roster-solver/route_solver.py b/services/roster-solver/route_solver.py index 60b84a6..3b9d597 100644 --- a/services/roster-solver/route_solver.py +++ b/services/roster-solver/route_solver.py @@ -148,7 +148,12 @@ def solve_route(req: dict) -> dict: idx = manager.NodeToIndex(i) tws, twe = j.get("tw_start_min"), j.get("tw_end_min") if tws is not None and twe is not None and int(twe) >= int(tws): - time_dim.CumulVar(idx).SetRange(int(tws), int(twe)) + time_dim.CumulVar(idx).SetRange(int(tws), int(twe)) # fenêtre AM/PM (dur) : ex. AM [480,720], PM [720,960] + uw = int(j.get("urgent_weight") or 0) + if uw > 0: + # URGENCE → servir TÔT : coût = uw × heure d'arrivée (borne souple à 0) → tire le job en début de tournée + # (conditionne le début de journée du tech). Reste souple : ne casse pas la faisabilité, se combine aux fenêtres AM/PM. + time_dim.SetCumulVarSoftUpperBound(idx, 0, uw) sk = j.get("skill") allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, sk)] if len(allowed) < n_veh: # compétence restreint réellement → filtre DUR