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