From d9215a20fa1a4e81b1acb8419afb663e30aa5dc0 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 17:39:45 -0400 Subject: [PATCH] fix(dispatch): optimize by default; per-day occupation (not 16h); balance >100% capped 120%; medium=amber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - default strategy = optimize → the FIRST Générer runs the VRP (real techs, no more greedy placeholders on first attempt). - occupation bar is now PER-DAY (worst day), so a 2-day window shows /8h not /16h; overload shows in red. - solver: each vehicle may go up to +20% overtime (≈120% cap, hard) but time past the nominal shift is soft-penalized → overflow spreads to techs still under 100% before anyone does overtime. Verified: 20 jobs / 2 techs → 9h+9h (balanced, ≤9.6h), overflow dropped; under capacity it still concentrates (no forced equalization). - priority medium flag = amber (yellow/orange) per request. Verified live: default optimize on, 16 real techs / 0 placeholders, per-day /8h occupation, none over, medium flag amber. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/composables/useConversationDisplay.js | 4 ++-- apps/ops/src/pages/PlanificationPage.vue | 22 +++++++++++++------ services/roster-solver/route_solver.py | 11 +++++++--- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/ops/src/composables/useConversationDisplay.js b/apps/ops/src/composables/useConversationDisplay.js index 71e8b35..77e40ba 100644 --- a/apps/ops/src/composables/useConversationDisplay.js +++ b/apps/ops/src/composables/useConversationDisplay.js @@ -71,11 +71,11 @@ export function channelMeta (channel) { // STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (invite à classer). const PRIORITIES = { high: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 }, - medium: { icon: 'outlined_flag', color: 'grey-5', label: 'Moyenne', rank: 2 }, // défaut ERPNext → contour gris (non classé) + medium: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 }, // jaune/orange pâle low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1 }, // rétro-compatibilité anciennes valeurs (conversations) : urgent→haute (rouge), normal→moyenne urgent: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 }, - normal: { icon: 'outlined_flag', color: 'grey-5', label: 'Moyenne', rank: 2 }, + normal: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 }, } export const PRIORITY_LEVELS = ['high', 'medium', 'low'] // 3 niveaux (standard ERPNext) — haute en tête export function priorityMeta (p) { diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 4c3e549..bf5f2ec 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -3923,7 +3923,7 @@ 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', jobTime: {} }) // strategy: optimize|smart|best|balance|enough · view: list|map · jobTime: {jobName:{ampm,early}} +const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {} }) // défaut = solveur VRP (1re génération optimise déjà, plus de placeholders) · strategy: optimize|smart|best|balance|enough // 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] || {} } @@ -4165,8 +4165,9 @@ async function optimizeSuggestion () { // 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 overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage) 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: startMin, 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, overtime_min: overtimeMin }) } if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés let res @@ -4211,15 +4212,22 @@ const suggestGroups = computed(() => { return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso) } }) // Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées → barre projetée. - let cap = 0, existing = 0, placeholder = isHoldId(g.techId) - if (!placeholder) for (const d of days) { const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso)); cap += (l.cap || 8); existing += (l.h || 0) } - const projected = existing + g.hours + // Occupation PAR JOUR (le vrai plafond = 8h/jour, PAS la somme des 2 jours) : on affiche le JOUR le plus chargé (contrainte qui lie). + const placeholder = isHoldId(g.techId) + let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false + if (!placeholder) for (const d of days) { + const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso)) + const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0) + const pct = dcap ? dproj / dcap * 100 : 0 + if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap } + if (dproj > dcap * 1.001) over = true + } const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart → alerte + créer quart const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file → cible d'assignation doit les couvrir return { ...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm), - cap: Math.round(cap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(projected * 10) / 10, - fillPct: cap ? Math.min(100, Math.round(projected / cap * 100)) : 0, over: !placeholder && projected > cap + 0.1, placeholder, noShiftDays, skills + cap: Math.round(worstCap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(worstProj * 10) / 10, + fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills } }).sort((a, b) => b.jobs - a.jobs) }) diff --git a/services/roster-solver/route_solver.py b/services/roster-solver/route_solver.py index 3b9d597..8d53033 100644 --- a/services/roster-solver/route_solver.py +++ b/services/roster-solver/route_solver.py @@ -124,21 +124,26 @@ def solve_route(req: dict) -> dict: horizon = 1440 for veh in vehicles: - horizon = max(horizon, int(veh.get("shift_end_min") or 0)) + horizon = max(horizon, int(veh.get("shift_end_min") or 0) + int(veh.get("overtime_min") or 0)) for j in jobs: if j.get("tw_end_min") is not None: horizon = max(horizon, int(j["tw_end_min"])) routing.AddDimension(time_idx, horizon, horizon, False, "Time") # slack=horizon (attente permise), start non forcé à 0 time_dim = routing.GetDimensionOrDie("Time") - # Fenêtre de QUART par véhicule (début/fin de tournée dans le quart). + # Fenêtre de QUART par véhicule. La fin peut aller jusqu'à +overtime_min (plafond ~120%), MAIS toute heure au-delà du quart + # nominal est PÉNALISÉE (borne souple) → le solveur ÉTALE l'excédent vers les techs encore sous 100% avant de faire des heures sup. + ot_coef = int(req.get("overtime_coef") or 20) for v, veh in enumerate(vehicles): s0 = int(veh.get("shift_start_min") or 0) s1 = int(veh.get("shift_end_min") or horizon) if s1 < s0: s1 = horizon + ot = max(0, int(veh.get("overtime_min") or 0)) time_dim.CumulVar(routing.Start(v)).SetRange(s0, s1) - time_dim.CumulVar(routing.End(v)).SetRange(s0, s1) + time_dim.CumulVar(routing.End(v)).SetRange(s0, s1 + ot) # jusqu'à ~120 % du quart + if ot > 0: + time_dim.SetCumulVarSoftUpperBound(routing.End(v), s1, ot_coef) # au-delà du quart nominal = coûteux → équilibre # Fenêtre horaire par job (optionnelle) + compétence (véhicules autorisés) + pénalité de drop. # NB : SetAllowedVehiclesForIndex a un typemap Span cassé en ortools 9.15 → on contraint VehicleVar ∈ autorisés ∪ {-1}