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:
louispaulb 2026-07-02 11:24:08 -04:00
parent 4591ef6169
commit a6508845a7
6 changed files with 276 additions and 6 deletions

View File

@ -98,6 +98,8 @@ export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan }) export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer // Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
export const unassignedJobs = () => jget('/roster/unassigned-jobs') 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) // 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 pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {}) export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})

View File

@ -975,8 +975,8 @@
<template v-if="suggestDlg.mode === 'config'"> <template v-if="suggestDlg.mode === 'config'">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap"> <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> <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' }]" /> <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">{{ ({ 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> <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>
<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="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"> <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 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))) 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 } 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.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
suggestDlg.mode = 'review' 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). // 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) { function nnOrder (entries, home) {
const pts = entries.filter(e => e.lat != null && e.lon != null) const pts = entries.filter(e => e.lat != null && e.lon != null)

View File

@ -4,7 +4,7 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 EXPOSE 8090
# 1 worker : CP-SAT est déjà multi-thread (num_search_workers=8) # 1 worker : CP-SAT est déjà multi-thread (num_search_workers=8)

View File

@ -14,8 +14,9 @@ from typing import Any
import os import os
from solver import solve_roster 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", "") API_TOKEN = os.environ.get("ROSTER_SOLVER_TOKEN", "")
@ -29,6 +30,17 @@ class SolveRequest(BaseModel):
max_seconds: float | None = None 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") @app.get("/health")
def health(): def health():
return {"ok": True, "service": "roster-solver"} return {"ok": True, "service": "roster-solver"}
@ -41,3 +53,12 @@ def solve(req: SolveRequest):
return result return result
except Exception as e: # noqa: BLE001 — renvoyer l'erreur proprement au hub except Exception as e: # noqa: BLE001 — renvoyer l'erreur proprement au hub
return JSONResponse(status_code=400, content={"status": "ERROR", "message": str(e)}) 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)})

View 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,
}

View File

@ -910,6 +910,14 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' }) if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS }) 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. // 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') { if (path === '/roster/holidays' && method === 'GET') {
const from = url.searchParams.get('from') || todayET() const from = url.searchParams.get('from') || todayET()