diff --git a/services/roster-solver/route_solver.py b/services/roster-solver/route_solver.py index 8d53033..cb7d41d 100644 --- a/services/roster-solver/route_solver.py +++ b/services/roster-solver/route_solver.py @@ -52,6 +52,24 @@ def solve_route(req: dict) -> dict: rank_w = float(req.get("rank_weight") if req.get("rank_weight") is not None else 2.0) base_pen = int(req.get("drop_penalty") or 100000) max_seconds = float(req.get("max_seconds") or 10) + # EFFICACITÉ (par compétence) : facteur de DURÉE (bas = rapide) → un tech rapide fait le job en moins de temps + # (dimension Temps par véhicule) ET coûte moins (biais de coût) → il reçoit plus de travail. Voir eff_bias. + eff_bias = float(req.get("eff_bias") if req.get("eff_bias") is not None else 8.0) + # RÉSERVE DES EXPERTS : pénalise les techs POLYVALENTS (beaucoup de compétences) → les cas simples vont d'abord + # aux moins polyvalents (qui ne pourraient rien faire d'autre), gardant les experts pour les jobs exigeants. + vers_w = float(req.get("versatility_weight") if req.get("versatility_weight") is not None else 6.0) + + def eff_of(veh, sk): + se = veh.get("skill_eff") or {} + if sk and se.get(sk): + try: + return max(0.2, min(3.0, float(se[sk]))) + except Exception: + pass + try: + return max(0.2, min(3.0, float(veh.get("efficiency") or 1.0))) + except Exception: + return 1.0 def skill_ok(veh, sk): return (not sk) or (sk in (veh.get("skills") or [])) @@ -98,29 +116,38 @@ def solve_route(req: dict) -> dict: # Coût PAR VÉHICULE = trajet + biais spécialiste (rang de la compétence chez CE tech ; 0 = principale). def make_cost_cb(v): - vskills = vehicles[v].get("skills") or [] + veh = vehicles[v] + vskills = veh.get("skills") or [] + nsk = len(vskills) def cb(fi, ti): f = manager.IndexToNode(fi) t = manager.IndexToNode(ti) base = travel_min(f, t) - rank = 0 if t < n_jobs: sk = jobs[t].get("skill") - if sk and sk in vskills: - rank = vskills.index(sk) - return int(round(base + rank * rank_w)) + rank = vskills.index(sk) if (sk and sk in vskills) else 0 + base += rank * rank_w # spécialité : garde le spécialiste sur sa compétence + base += max(0, nsk - 1) * vers_w # polyvalence pénalisée → cas simples aux moins polyvalents + base += (eff_of(veh, sk) - 1.0) * eff_bias # efficacité : rapide (<1) récompensé, lent (>1) pénalisé + return int(round(max(0.0, base))) return cb for v in range(n_veh): cidx = routing.RegisterTransitCallback(make_cost_cb(v)) routing.SetArcCostEvaluatorOfVehicle(cidx, v) - # Dimension TEMPS = trajet + temps sur place du nœud de départ. - def time_cb(fi, ti): - f = manager.IndexToNode(fi) - return int(round(travel_min(f, manager.IndexToNode(ti)) + service[f])) - time_idx = routing.RegisterTransitCallback(time_cb) + # Dimension TEMPS = trajet + temps sur place (SERVICE) du nœud de départ, ce dernier MIS À L'ÉCHELLE par l'efficacité + # du véhicule pour la compétence du job (rapide = moins de temps → tient plus de jobs) → callbacks PAR VÉHICULE. + def make_time_cb(v): + veh = vehicles[v] + + def cb(fi, ti): + f = manager.IndexToNode(fi) + svc = int(round(service[f] * eff_of(veh, jobs[f].get("skill")))) if f < n_jobs else 0 + return int(round(travel_min(f, manager.IndexToNode(ti)) + svc)) + return cb + time_cbs = [routing.RegisterTransitCallback(make_time_cb(v)) for v in range(n_veh)] horizon = 1440 for veh in vehicles: @@ -128,7 +155,7 @@ def solve_route(req: dict) -> dict: 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 + routing.AddDimensionWithVehicleTransits(time_cbs, horizon, horizon, False, "Time") # temps PAR VÉHICULE (efficacité) ; slack=horizon time_dim = routing.GetDimensionOrDie("Time") # Fenêtre de QUART par véhicule. La fin peut aller jusqu'à +overtime_min (plafond ~120%), MAIS toute heure au-delà du quart diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 6bbfd61..64bdb80 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -242,7 +242,7 @@ async function optimizePlan (body) { if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true } const used = usedMin[t.id] || 0 const o = originOf(t.id) - vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) }) + vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], skill_eff: t.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) }) } if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue } // MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe)