feat(dispatch): VRP route optimizer (OR-Tools) — "Optimiser" mode
Real vehicle-routing optimization behind the Suggérer UI, reusing our
existing OR-Tools solver service (no new stack). Fixes the greedy's
structural limits (sector-splitting, no global optimization).
- roster-solver: new route_solver.py (OR-Tools Routing / VRPTW).
Minimizes real travel; skills = hard filter (VehicleVar ∈ allowed∪{-1};
SetAllowedVehiclesForIndex has a broken Span typemap in ortools 9.15);
on-site service time within each tech's shift window; optional per-job
time windows; unfittable jobs left unassigned (drop penalty) instead of
infeasible; specialist bias via skill order (per-vehicle arc cost).
New POST /route endpoint. Dockerfile now COPYs route_solver.py.
Unit-tested: skills respected, sectors consolidated, edge cases safe.
- hub: POST /roster/optimize-routes → proxies to solver /route.
- ops SPA: "⚡ Optimiser" strategy. Greedy buckets jobs into days +
placeholders, then the solver re-optimizes each day (assignment +
routes) among shifted techs; result maps into the same review dialog
(occupation bars, route map, swap/merge reused). Graceful fallback to
greedy if the solver is unreachable (never drops jobs). shiftWindowMin
derives each vehicle's shift from templates.
Phase 2 (later): OSRM/Mapbox road-time matrix for exact travel times.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4591ef6169
commit
a6508845a7
|
|
@ -98,6 +98,8 @@ export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=
|
|||
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
|
||||
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
|
||||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
||||
// Optimisation de tournées (VRPTW + compétences + temps sur place) via le solveur OR-Tools. payload = { jobs[], vehicles[], ... }
|
||||
export const optimizeRoutes = (payload) => jpost('/roster/optimize-routes', payload)
|
||||
// Write-back legacy : aperçu (0 écriture) puis application (réassigne ticket.assign_to au tech dans osTicket)
|
||||
export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
|
||||
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
|
||||
|
|
|
|||
|
|
@ -975,8 +975,8 @@
|
|||
<template v-if="suggestDlg.mode === 'config'">
|
||||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||||
<span class="text-caption text-grey-7 q-mr-xs">Stratégie :</span>
|
||||
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
|
||||
<span class="text-caption text-grey-6">{{ ({ smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }}</span>
|
||||
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: '⚡ Optimiser', value: 'optimize' }, { label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
|
||||
<span class="text-caption text-grey-6">{{ ({ optimize: 'solveur VRP (OR-Tools) : routes réelles minimisées + compétences + temps sur place — regroupe les secteurs, spécialistes d\'abord', smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }}</span>
|
||||
</div>
|
||||
<div class="text-caption q-mb-sm" style="color:#475569;background:#eef2ff;border-radius:6px;padding:4px 8px"><q-icon name="date_range" size="14px" class="q-mr-xs" color="primary" />Répartit sur : <b>{{ suggestWindowLabel }}</b><span class="text-grey-6"> — défaut aujourd'hui + demain ; filtre par date (chips de la liste) pour cibler d'autres jours. Les jobs datés au-delà sont laissés pour plus tard.</span></div>
|
||||
<div class="row items-center q-mb-xs" style="gap:4px">
|
||||
|
|
@ -4057,12 +4057,51 @@ function techQuality (t) {
|
|||
return (5 - avgLvl) * 2 + (eff - 1) * 5 + ((Number(t.cost_h) || 0) / 100) // plus BAS = meilleur
|
||||
}
|
||||
const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b)))
|
||||
function runSuggestion () {
|
||||
async function runSuggestion () {
|
||||
if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return }
|
||||
suggestDlg.building = true; buildSuggestion(); suggestDlg.building = false
|
||||
suggestDlg.building = true
|
||||
try {
|
||||
if (suggestDlg.strategy === 'optimize') await optimizeSuggestion() // solveur VRP OR-Tools (routes + compétences + temps sur place)
|
||||
else buildSuggestion() // heuristique gloutonne (rapide, local)
|
||||
} catch (e) { err(e); buildSuggestion() } finally { suggestDlg.building = false }
|
||||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
|
||||
suggestDlg.mode = 'review'
|
||||
}
|
||||
// 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
|
||||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
|
||||
if (!isFinite(s) || !isFinite(e) || e <= s) return null
|
||||
return { start: Math.round(s * 60), end: Math.round(e * 60) }
|
||||
}
|
||||
// « Optimiser » = le greedy décide le JOUR (buckets + placeholders), puis le solveur VRP OR-Tools ré-optimise CHAQUE jour
|
||||
// (assignation + routes) parmi les techs de quart → regroupe les secteurs, respecte compétences + temps sur place.
|
||||
async function optimizeSuggestion () {
|
||||
buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders week-end/sans-quart
|
||||
const plan = suggestDlg.plan
|
||||
const out = plan.filter(e => e.placeholder) // placeholders conservés
|
||||
const byDay = {}
|
||||
for (const e of plan) if (!e.placeholder) (byDay[e.iso] = byDay[e.iso] || []).push(e)
|
||||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||||
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 || '' }))
|
||||
const vehicles = []
|
||||
for (const t of selTechs) { const w = shiftWindowMin(t.id, iso); if (!w) continue; 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: w.start, shift_end_min: w.end }) }
|
||||
if (!vehicles.length) { out.push(...entries); continue } // aucun tech de quart ce jour → garder l'assignation greedy
|
||||
let res
|
||||
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: 8, rank_weight: 6 }) } catch (e) { out.push(...entries); continue }
|
||||
if (!res || res.status !== 'OK') { out.push(...entries); continue } // solveur absent/erreur/infaisable → on GARDE l'assignation greedy (ne jamais perdre de jobs)
|
||||
const placed = new Set()
|
||||
for (const rt of (res.routes || [])) {
|
||||
const t = selTechs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name }
|
||||
for (const st of (rt.stops || [])) { const e0 = byName[st.job_id]; if (!e0) continue; placed.add(st.job_id); out.push({ ...e0, techId: t.id, techName: t.name, noShift: false, capable: true }) }
|
||||
}
|
||||
for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (!e0 || placed.has(jid)) continue; out.push({ ...e0, techId: `__hold__|${iso}|opt`, techName: `⏳ ${FR_DOW[dowOf(iso)] || ''} ${iso.slice(8)} — non casé (à assigner)`, placeholder: true, noShift: false }) }
|
||||
}
|
||||
suggestDlg.plan = out
|
||||
}
|
||||
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
|
||||
function nnOrder (entries, home) {
|
||||
const pts = entries.filter(e => e.lat != null && e.lon != null)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ WORKDIR /app
|
|||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY solver.py app.py ./
|
||||
COPY solver.py app.py route_solver.py ./
|
||||
|
||||
EXPOSE 8090
|
||||
# 1 worker : CP-SAT est déjà multi-thread (num_search_workers=8)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ from typing import Any
|
|||
import os
|
||||
|
||||
from solver import solve_roster
|
||||
from route_solver import solve_route
|
||||
|
||||
app = FastAPI(title="Roster AI Solver", version="0.1.0")
|
||||
app = FastAPI(title="Roster AI Solver", version="0.2.0")
|
||||
|
||||
API_TOKEN = os.environ.get("ROSTER_SOLVER_TOKEN", "")
|
||||
|
||||
|
|
@ -29,6 +30,17 @@ class SolveRequest(BaseModel):
|
|||
max_seconds: float | None = None
|
||||
|
||||
|
||||
class RouteRequest(BaseModel):
|
||||
jobs: list
|
||||
vehicles: list
|
||||
matrix: list | None = None
|
||||
speed_kmh: float | None = None
|
||||
default_service_min: int | None = None
|
||||
rank_weight: float | None = None
|
||||
drop_penalty: int | None = None
|
||||
max_seconds: float | None = None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "roster-solver"}
|
||||
|
|
@ -41,3 +53,12 @@ def solve(req: SolveRequest):
|
|||
return result
|
||||
except Exception as e: # noqa: BLE001 — renvoyer l'erreur proprement au hub
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "message": str(e)})
|
||||
|
||||
|
||||
@app.post("/route")
|
||||
def route(req: RouteRequest):
|
||||
"""Optimisation de TOURNÉES (VRPTW + compétences + temps sur place). Voir route_solver.solve_route."""
|
||||
try:
|
||||
return solve_route(req.model_dump())
|
||||
except Exception as e: # noqa: BLE001
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "message": str(e)})
|
||||
|
|
|
|||
200
services/roster-solver/route_solver.py
Normal file
200
services/roster-solver/route_solver.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""
|
||||
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)
|
||||
rank_w = float(req.get("rank_weight") or 5.0) # « minutes virtuelles » par cran d'ordre de compétence (spécialiste=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))
|
||||
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,
|
||||
}
|
||||
|
|
@ -910,6 +910,14 @@ async function handle (req, res, method, path, url) {
|
|||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
|
||||
}
|
||||
// Optimisation de TOURNÉES (VRPTW + compétences + temps sur place) — proxy vers le solveur OR-Tools /route.
|
||||
// Le SPA construit le payload {jobs, vehicles, ...} (il a coords/skills/durées/domiciles/quarts) ; on relaie au solveur interne.
|
||||
if (path === '/roster/optimize-routes' && method === 'POST') {
|
||||
const body = await parseBody(req)
|
||||
if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' })
|
||||
try { return json(res, 200, await postSolver('/route', body)) }
|
||||
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) }
|
||||
}
|
||||
// Jours fériés QC (déterministe) — sert le badge « férié » du day-strip + la réduction de capacité par défaut.
|
||||
if (path === '/roster/holidays' && method === 'GET') {
|
||||
const from = url.searchParams.get('from') || todayET()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user