""" Dispatch VRP — solveur de TOURNÉES (OR-Tools Routing / CP). Problème : VRPTW + compétences + temps sur place (« field service »). • nœuds = jobs ; véhicules = techniciens (chacun part/revient à SON domicile) • compétence requise du job = filtre DUR (véhicules autorisés) • temps sur place (service) + trajet comptent dans la dimension TEMPS, bornée par le quart de chaque tech (fenêtre) ; fenêtre horaire par job optionnelle • objectif : MINIMISER le trajet total (+ léger biais spécialiste via l'ordre des compétences du tech) ; un job qui ne rentre pas est laissé NON planifié (pénalité de « drop ») plutôt que de rendre le modèle infaisable. C'est ce que le greedy ne peut pas faire : optimisation GLOBALE → regroupe les jobs d'un même secteur sur le même tech (plus de fractionnement). Entrée (dict) : jobs: [{id, lat, lon, service_min, skill, tw_start_min?, tw_end_min?, priority_boost?}] vehicles: [{id, name, skills:[...] (ORDRE = priorité), home_lat?, home_lon?, shift_start_min, shift_end_min}] matrix?: [[minutes]] (N_jobs+N_veh carré, jobs d'abord puis domiciles) — sinon haversine speed_kmh?, default_service_min?, rank_weight?, drop_penalty?, max_seconds? Sortie : {status, routes:[{vehicle, vehicle_name, stops:[{job_id,arrival_min,service_min}], travel_min}], unassigned:[job_id], total_travel_min, objective, solve_ms} """ from __future__ import annotations import math from ortools.constraint_solver import pywrapcp, routing_enums_pb2 def _haversine_km(a, b) -> float: (lat1, lon1), (lat2, lon2) = a, b R = 6371.0 p1, p2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) dlmb = math.radians(lon2 - lon1) h = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 return 2 * R * math.asin(min(1.0, math.sqrt(h))) def solve_route(req: dict) -> dict: all_jobs = req.get("jobs", []) or [] vehicles = req.get("vehicles", []) or [] if not all_jobs or not vehicles: return {"status": "EMPTY", "routes": [], "unassigned": [j["id"] for j in all_jobs], "total_travel_min": 0, "objective": None, "solve_ms": 0} speed = float(req.get("speed_kmh") or 45.0) default_service = int(req.get("default_service_min") or 60) # DISTANCE PRIORITAIRE : la spécialité (ordre de compétence) ne fait que DÉPARTAGER à trajet ~égal. # rank_w = « minutes virtuelles » par cran d'ordre (spécialiste=0). Petit → un tech multi-compétences PROCHE # passe avant un spécialiste LOIN (ex. un installateur qui a aussi la réparation prend une réparation voisine). 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) def skill_ok(veh, sk): return (not sk) or (sk in (veh.get("skills") or [])) # Pré-filtre : un job sans AUCUN tech compétent est non planifiable → hors modèle (évite les "allowed" vides). jobs, unservable = [], [] for j in all_jobs: if any(skill_ok(v, j.get("skill")) for v in vehicles): jobs.append(j) else: unservable.append(j["id"]) if not jobs: return {"status": "OK", "routes": [], "unassigned": unservable, "total_travel_min": 0, "objective": 0, "solve_ms": 0} n_jobs, n_veh = len(jobs), len(vehicles) N = n_jobs + n_veh # 0..n_jobs-1 = jobs ; n_jobs+v = domicile du véhicule v coords = [None] * N for i, j in enumerate(jobs): if j.get("lat") is not None and j.get("lon") is not None: coords[i] = (float(j["lat"]), float(j["lon"])) for v, veh in enumerate(vehicles): if veh.get("home_lat") is not None and veh.get("home_lon") is not None: coords[n_jobs + v] = (float(veh["home_lat"]), float(veh["home_lon"])) supplied = req.get("matrix") def travel_min(a, b): if supplied is not None: return supplied[a][b] ca, cb = coords[a], coords[b] if ca is None or cb is None: return 0 # coordonnée inconnue → trajet neutre (ne pas pénaliser à tort) return _haversine_km(ca, cb) * 60.0 / speed service = [0] * N for i, j in enumerate(jobs): service[i] = int(j.get("service_min") or default_service) starts = [n_jobs + v for v in range(n_veh)] manager = pywrapcp.RoutingIndexManager(N, n_veh, starts, starts) # départ == retour == domicile routing = pywrapcp.RoutingModel(manager) # 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 [] 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)) 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) horizon = 1440 for veh in vehicles: horizon = max(horizon, int(veh.get("shift_end_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). 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 time_dim.CumulVar(routing.Start(v)).SetRange(s0, s1) time_dim.CumulVar(routing.End(v)).SetRange(s0, s1) # 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} # (-1 = job non planifié ; la disjonction le permet). sv = routing.solver() for i, j in enumerate(jobs): 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)) # 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 sv.Add(sv.MemberCt(routing.VehicleVar(idx), allowed + [-1])) routing.AddDisjunction([idx], base_pen + int(j.get("priority_boost") or 0)) # laisser tomber coûte cher → préfère assigner params = pywrapcp.DefaultRoutingSearchParameters() params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH params.time_limit.FromSeconds(max(1, int(max_seconds))) sol = routing.SolveWithParameters(params) if sol is None: return {"status": "NO_SOLUTION", "routes": [], "unassigned": [j["id"] for j in jobs] + unservable, "total_travel_min": 0, "objective": None, "solve_ms": 0, "message": "Aucune tournée faisable (fenêtres/quarts trop serrés)."} routes = [] assigned = set() total_travel = 0 for v, veh in enumerate(vehicles): idx = routing.Start(v) stops = [] route_travel = 0 while not routing.IsEnd(idx): node = manager.IndexToNode(idx) nxt = sol.Value(routing.NextVar(idx)) route_travel += int(round(travel_min(node, manager.IndexToNode(nxt)))) if node < n_jobs: stops.append({"job_id": jobs[node]["id"], "arrival_min": int(sol.Value(time_dim.CumulVar(idx))), "service_min": service[node]}) assigned.add(node) idx = nxt if stops: total_travel += route_travel routes.append({"vehicle": veh["id"], "vehicle_name": veh.get("name", veh["id"]), "stops": stops, "travel_min": int(route_travel)}) unassigned = [jobs[i]["id"] for i in range(n_jobs) if i not in assigned] + unservable try: solve_ms = int(routing.solver().WallTime()) except Exception: solve_ms = None return { "status": "OK", "routes": routes, "unassigned": unassigned, "total_travel_min": int(total_travel), "objective": sol.ObjectiveValue(), "solve_ms": solve_ms, }