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>
65 lines
1.8 KiB
Python
65 lines
1.8 KiB
Python
"""
|
|
Roster AI — API HTTP (FastAPI) autour du solveur OR-Tools CP-SAT.
|
|
|
|
Endpoints :
|
|
GET /health → liveness
|
|
POST /solve → résout un horaire (payload = voir README/sample_request.json)
|
|
|
|
Pensé pour tourner à côté du hub (targo-hub l'appelle), pas exposé publiquement.
|
|
"""
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
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.2.0")
|
|
|
|
API_TOKEN = os.environ.get("ROSTER_SOLVER_TOKEN", "")
|
|
|
|
|
|
class SolveRequest(BaseModel):
|
|
horizon: dict
|
|
shift_templates: list
|
|
technicians: list
|
|
coverage: list = []
|
|
weights: dict | 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")
|
|
def health():
|
|
return {"ok": True, "service": "roster-solver"}
|
|
|
|
|
|
@app.post("/solve")
|
|
def solve(req: SolveRequest):
|
|
try:
|
|
result = solve_roster(req.model_dump())
|
|
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)})
|