spring-org prompt d'intégration API

Copiez le texte ci-dessous et donnez-le à une IA (Claude, ChatGPT…) : il contient le contrat complet de l'API — toutes les routes, leurs entrées/sorties, les codes de réponse et toutes les spécificités — afin qu'elle implémente correctement un client. L'URL de base de l'API en production est https://ors.stack.bzh.

← Retour au tableau de bord Swagger : /swagger-ui.html · OpenAPI : /v3/api-docs
Tu vas implémenter un client pour l'API HTTP « spring-org ». Respecte STRICTEMENT le contrat ci-dessous : noms de champs, types, valeurs par défaut, codes de réponse et règles métier. N'invente aucun champ ni aucun endpoint.

====================================================================
CONTEXTE GÉNÉRAL
====================================================================
spring-org est une API REST 100 % locale (aucune dépendance externe à l'exécution) réunissant trois briques indépendantes :
  1. Routing      — itinéraires + matrices de temps (moteur GraphHopper, extrait OpenStreetMap France).
  2. Optimisation — tournées VRP/TSP (moteur Timefold) : /optimize = ordre de passage optimal de N points ;
     /dispatch = répartition automatique de N points (ex : 100) en K tournées (ex : 5) équilibrées en durée.
     Les deux acceptent en OPTION des MISSIONS APPAIRÉES (shipments : chargement quelque part → enlèvement
     ailleurs), garanties sur le MÊME véhicule et dans cet ordre.
  3. Geocoding    — recherche / autocomplétion d'adresses, de voies, de communes (index Lucene sur la Base Adresse Nationale BAN)
     et de points d'intérêt : commerces, services, gares… (extraits du même fichier OpenStreetMap que le routing).

URL de base (production) : https://ors.stack.bzh
  (en local : http://localhost:8080)

Toutes les requêtes/réponses sont en JSON (Content-Type: application/json), SAUF /geocoding/search qui prend ses paramètres en query string. Les coordonnées sont toujours en WGS84 (degrés décimaux) : lat ∈ [-90,90], lon ∈ [-180,180].

CONVENTIONS D'ERREUR (transverses à tous les endpoints) :
  - 200 : succès.
  - 400 : requête invalide (validation des champs, coordonnées hors bornes, valeur non numérique). Corps = ProblemDetail (RFC 7807), media type application/problem+json.
  - 422 : /routing/route et /routing/matrix (et /optimization si deux points retenus n'ont aucune liaison) : requête correcte mais sans solution (point en mer, hors zone couverte, aucune route). Inutile de réessayer : corriger les points.
  - 503 : sous-système indisponible (ses données ne sont pas encore chargées, ou en cours de (re)construction). Corps = ProblemDetail.
  Un client robuste DOIT gérer le 503 : interroger le ./status de la brique avant d'appeler, et/ou réessayer plus tard.

DÉMARRAGE / DISPONIBILITÉ :
  Chaque brique se charge de façon asynchrone au démarrage du serveur (téléchargement + construction des index/graphe, potentiellement plusieurs minutes au tout premier lancement). Tant qu'une brique n'est pas prête, ses endpoints renvoient 503. Vérifier la disponibilité via les endpoints status (voir plus bas).
  Les données sont mises à jour automatiquement (au démarrage si une version plus récente est publiée par la source, et chaque dimanche à 8h Europe/Paris). Pendant la reconstruction du graphe routier (plusieurs dizaines de minutes), /routing et /optimization renvoient 503 ; le geocoding reste disponible pendant la reconstruction de ses index. GET /status expose la dernière mise à jour et la prochaine planifiée (objet dataUpdate).

====================================================================
TYPES PARTAGÉS
====================================================================
Coordinate (objet point géographique) :
  { "lat": number (REQUIS), "lon": number (REQUIS) }   // WGS84, degrés décimaux

GeometryFormat (enum, tolérant à la casse) :
  "POINTS"   — géométrie en liste de points [lat,lon] (champ `geometry`). Lisible mais volumineux. (DÉFAUT)
  "POLYLINE" — chaîne encodée (champ `geometryPolyline`), algorithme Google/OSRM précision 5 (~1 m),
               beaucoup plus compacte pour les longs trajets. Décodable avec @mapbox/polyline.
               Deltas dans l'ordre (latitude, longitude), facteur 1e5.
  "NONE"     — aucune géométrie (réponse la plus légère).

====================================================================
1) ROUTING  — préfixe /routing
====================================================================

--- GET /routing/status ---
But : savoir si le graphe routier est chargé. À interroger avant tout appel de routing.
Réponse 200 : { "ready": boolean, "profile": string }   // ex : { "ready": true, "profile": "car" }
Si ready=false → les autres endpoints routing renvoient 503.

--- POST /routing/route ---
But : calculer un itinéraire routier. DEUX modes mutuellement exclusifs :
  • Point à point        : fournir `from` et `to`.
  • Multi-points ordonné : fournir `points` (>= 2 éléments). L'itinéraire passe par TOUS les points
    DANS L'ORDRE FOURNI (aucune réoptimisation — pour réordonner, utiliser /optimization/optimize).
    Les tronçons consécutifs sont concaténés en une trace continue (sans doublon aux jonctions).
Si `points` est présent (>=2), il PRIME sur `from`/`to`.

Corps (RouteRequest) :
  {
    "from": Coordinate | null,            // requis si `points` absent
    "to":   Coordinate | null,            // requis si `points` absent
    "points": [Coordinate, ...] | null,   // mode multi-points, >= 2 éléments
    "geometryFormat": "POINTS"|"POLYLINE"|"NONE"   // défaut POINTS
  }
Règle de validation : fournir SOIT (`from` ET `to`), SOIT `points` avec >= 2 éléments. Sinon → 400.

Réponse 200 (RouteResponse) :
  {
    "distanceMeters": number,        // distance totale (cumulée sur tout le parcours)
    "durationSeconds": integer,      // durée totale (cumulée)
    "geometryFormat": string,        // format demandé, en rappel : "POINTS"|"POLYLINE"|"NONE"
    "geometry": [[lat,lon], ...] | null,   // NON nul UNIQUEMENT si format = POINTS
    "geometryPolyline": string | null      // renseigné pour POINTS ET POLYLINE ; null UNIQUEMENT si NONE
  }
À RETENIR : `geometryPolyline` est TOUJOURS rempli sauf en NONE (donc présent même en POINTS).
Codes : 200 ok ; 400 corps invalide / coordonnées invalides ; 503 moteur indisponible.

--- POST /routing/matrix ---
But : matrice N×N des temps de trajet (secondes) entre tous les points fournis.
Corps (MatrixRequest) :
  { "points": [Coordinate, ...] }      // au moins 1 point (REQUIS, non vide). L'ordre est conservé.
Réponse 200 (MatrixResponse) :
  {
    "size": integer,                       // N
    "durationsSeconds": [[integer,...], ...]   // durationsSeconds[i][j] = temps i -> j, en secondes.
  }                                            // Peut être ASYMÉTRIQUE (sens uniques). Diagonale = 0.
Codes : 200 ok ; 400 liste vide/invalide ; 503 moteur indisponible.

====================================================================
2) OPTIMISATION  — préfixe /optimization
====================================================================

--- POST /optimization/optimize ---
But : optimiser l'ordre de passage d'une liste de points depuis/vers un dépôt commun (VRP/TSP/VRPTW),
en minimisant le temps de conduite total (+ attentes), sous contrainte de capacité optionnelle et de
FENÊTRES HORAIRES optionnelles par point (ex : pharmacie à livrer entre 21h et 23h).
Dépend du routing (calcule sa matrice via GraphHopper) : si routing KO → 503.

Corps (OptimizeRequest) :
  {
    "depot": Coordinate (REQUIS),          // départ ET arrivée commun à tous les véhicules
    "vehicleCount": integer | null,        // défaut 1 (1 = TSP simple). < 1 ramené à 1.
    "vehicleCapacity": integer | null,     // null = capacité illimitée (aucune contrainte de charge)
    "departureTime": "ISO-8601 LocalDateTime" | null,  // heure de DÉPART ESTIMÉE du dépôt, ex "2026-06-15T20:00:00" ;
                                                       // défaut = heure courante serveur. Origine de toutes les heures
                                                       // de la réponse ET référence des fenêtres horaires → à TOUJOURS
                                                       // renseigner dès qu'une visite a une fenêtre.
    "maxWaitingSeconds": integer | null,   // attente MAX tolérée devant une fenêtre pas encore ouverte (s).
                                           // null = défaut serveur (3600 s = 1 h) ; 0 = désactivé (attente illimitée).
                                           // Au-delà → l'arrêt « ne correspond pas au créneau » (contrainte dure, cf. ci-dessous).
    "geometryFormat": "POINTS"|"POLYLINE"|"NONE",      // défaut POINTS
    "includeGeometry": boolean | null,     // DÉPRÉCIÉ — préférer geometryFormat. true->POINTS, false->NONE. Ignoré si geometryFormat fourni.
    "maxSolvingSeconds": integer | null,   // temps de résolution (s), ≥ 1. SANS EFFET sur une tournée classique
                                           // (terminaison globale 1 s). Utile seulement avec des shipments :
                                           // null → défaut serveur 5 s, plafonné à 60 s. Renvoyé dans solvingTimeSeconds.
    "visits": [ VisitDto, ... ] | null,    // arrêts INDÉPENDANTS, ordonnés librement par le solveur
    "shipments": [ ShipmentDto, ... ] | null  // MISSIONS APPAIRÉES (cf. plus bas). Mélangeable avec visits.
  }
  Au moins un élément au total entre visits et shipments, sinon 400. Omettre shipments = comportement
  historique à l'identique.

VisitDto :
  {
    "id": string | null,        // optionnel ; auto-généré "v" si absent
    "name": string | null,      // libellé lisible, optionnel
    "lat": number (REQUIS),
    "lon": number (REQUIS),
    "demand": integer | null,           // charge consommée au point ; défaut 0 ; comparée à vehicleCapacity
    "serviceDurationSeconds": integer | null, // durée d'arrêt/service ; défaut 0 ; décale les heures d'arrivée suivantes
    "timeWindowStart": LocalDateTime | null,  // début de fenêtre de livraison (même référence que departureTime).
                                              // Arrivée avant → le véhicule ATTEND sur place (waitingSeconds). null = pas de borne basse.
    "timeWindowEnd": LocalDateTime | null     // heure LIMITE D'ARRIVÉE (contrainte dure). null = pas de borne haute.
                                              // Les deux null = « pas d'importance » (aucune contrainte horaire).
                                              // timeWindowStart > timeWindowEnd → 400.
  }

ShipmentDto (MISSION APPAIRÉE : chargement → enlèvement) :
  {
    "id": string | null,        // optionnel ; auto-généré "s". Repris dans stops[].shipmentId.
    "name": string | null,      // libellé de la mission, ex "Palette Dupont"
    "pickup":   VisitDto | null,  // arrêt de CHARGEMENT  ; null = chargé au dépôt au départ
    "delivery": VisitDto | null   // arrêt d'ENLÈVEMENT   ; null = ramené au dépôt en fin de tournée
  }
  • Au moins une des deux extrémités, sinon 400.
  • Chaque extrémité est un VisitDto COMPLET : elle a sa PROPRE durée de service et sa PROPRE fenêtre
    horaire (on peut donc exiger un chargement le matin et une dépose l'après-midi).
  • Avec les DEUX extrémités, l'API ajoute deux contraintes DURES : même véhicule, et chargement AVANT
    enlèvement. Avec une seule, aucun ordre n'est imposé (le dépôt encadre déjà la tournée) : l'arrêt se
    comporte comme un point de visits.
  • demand garde sa sémantique : une SOMME comparée à vehicleCapacity sur toute la tournée, PAS un suivi
    de la charge à bord. Le porter sur le pickup et laisser le delivery à 0, sinon la charge compte double.
  • Les identifiants doivent être UNIQUES dans la requête dès qu'il y a des shipments (l'appairage se fait
    par identifiant) → sinon 400.
  • Si une extrémité n'est pas rattachable au réseau, la mission est écartée EN ENTIER : l'autre extrémité
    apparaît dans skippedVisits avec reason="PAIRED_POINT_SKIPPED".
  Exemple : charger 2 palettes chez le fournisseur, les déposer chez le client avant midi :
    { "depot": {"lat": 48.1173, "lon": -1.6778},
      "shipments": [ { "id": "M1", "name": "Palette Dupont",
                       "pickup":   {"lat": 48.12, "lon": -1.70, "demand": 2, "serviceDurationSeconds": 600},
                       "delivery": {"lat": 47.22, "lon": -1.55, "timeWindowEnd": "2026-06-15T12:00:00"} } ] }
    → routes[0].stops = [ M1-pickup (stopType "PICKUP"), M1-delivery (stopType "DELIVERY") ], dans cet ordre.

FENÊTRES HORAIRES (VRPTW) :
  • Exemple : départ 20h, pharmacie entre 21h et 23h, deux autres clients sans contrainte :
    { "departureTime": "2026-06-15T20:00:00", "visits": [
        { "name": "Pharmacie", "lat": 47.21, "lon": -1.55, "timeWindowStart": "2026-06-15T21:00:00", "timeWindowEnd": "2026-06-15T23:00:00" },
        { "name": "Client B", "lat": 47.25, "lon": -1.60 },
        { "name": "Client C", "lat": 47.19, "lon": -1.50 } ] }
  • Le solveur réordonne les points pour arriver dans chaque fenêtre ; s'il arrive en avance il attend
    (attente minimisée). L'attente est tolérée jusqu'à `maxWaitingSeconds` (défaut 1 h) : au-delà, l'arrêt
    est considéré comme NE CORRESPONDANT PAS au créneau (contrainte dure) → le solveur réordonne pour l'éviter ;
    si aucun ordre ne le permet, la tournée est renvoyée avec feasible=false, timeWindowViolations>0,
    stops[].timeWindowStatus="WAITING_TOO_LONG" et stops[].excessiveWaitingSeconds>0 (les heures restent
    calculées comme si le véhicule attendait l'ouverture). Passer maxWaitingSeconds=0 pour retrouver une
    attente illimitée.
  • S'il est IMPOSSIBLE d'arriver avant timeWindowEnd (fenêtre déjà passée, trop de points…) → la tournée est
    quand même renvoyée (meilleure solution) avec feasible=false, timeWindowViolations>0,
    stops[].timeWindowStatus="LATE" et stops[].lateSeconds>0 ; ce n'est PAS un 400 : lire `feasible` et décider
    (accepter le retard, changer departureTime, retirer un point).

CAS D'USAGE :
  • 1 véhicule, capacité illimitée → simple optimisation d'ordre (TSP). Omettre vehicleCapacity et demand.
    L'ordre optimal de passage = routes[0].stops[].visitId dans l'ordre du tableau.
  • N véhicules + vehicleCapacity + demand → répartition capacitaire (CVRP).
  Note : avec capacité illimitée et plusieurs véhicules, le solveur tend à n'en utiliser qu'un seul
  (chaque véhicule ajoute un aller-retour au dépôt). Pour répartir N points en K tournées équilibrées,
  utiliser POST /optimization/dispatch (ci-dessous).

TOLÉRANCE AUX POINTS NON RATTACHABLES (important) :
  Avant l'optimisation, chaque point est testé contre le réseau routier. Une VISITE non rattachable
  (en mer, hors zone OSM, réseau déconnecté) ou trop loin de toute route (> seuil ~1000 m par défaut)
  est ÉCARTÉE : elle n'apparaît dans aucune tournée et figure dans `skippedVisits[]`. Une visite invalide
  ne fait DONC PLUS échouer la requête (plus de 503 pour ce motif) ; la tournée est calculée avec les
  visites valides restantes. Si TOUTES les visites sont écartées → 200 avec `routes` vide.
  EN REVANCHE, si c'est le DÉPÔT qui n'est pas rattachable → 400.
  → Le client DOIT inspecter `skippedVisits` et signaler/corriger ces points.

Réponse 200 (OptimizeResponse) :
  {
    "score": string,                    // score Timefold "hard/soft" ; "0hard" = contraintes dures OK ; soft = conduite + attente
    "feasible": boolean,                // true = capacités ET fenêtres horaires toutes respectées (ni retard, ni attente > maxWaitingSeconds). false → voir timeWindowViolations / stops[].timeWindowStatus
    "timeWindowViolations": integer,    // nb d'arrêts avec timeWindowStatus ≠ OK (LATE ou WAITING_TOO_LONG) ; 0 si OK ou sans fenêtres
    "maxWaitingSeconds": integer | null,// limite d'attente effectivement appliquée (requête ou défaut serveur) ; null = aucune limite
    "totalDrivingTimeSeconds": integer, // temps de conduite total, tous véhicules
    "totalDistanceMeters": number,      // distance totale, tous véhicules
    "routes": [ RouteDto, ... ],        // une entrée par véhicule
    "skippedVisits": [ SkippedVisitDto, ... ]   // visites écartées (peut être vide)
  }

RouteDto (tournée d'un véhicule, dépôt -> arrêts -> dépôt) :
  {
    "vehicleId": string,                // ex "vehicle-0"
    "departureTime": LocalDateTime,     // départ du dépôt (= departureTime de la requête)
    "returnTime": LocalDateTime,        // retour au dépôt (attentes incluses)
    "drivingTimeSeconds": integer,
    "serviceTimeSeconds": integer,
    "waitingTimeSeconds": integer,      // somme des attentes aux fenêtres horaires (0 sans fenêtres)
    "durationSeconds": integer,         // durée TOTALE de la tournée = conduite + service + attente (= returnTime - departureTime) ; 0 si vide
    "distanceMeters": number,
    "totalDemand": integer,             // somme des demandes des visites de la tournée
    "stops": [ StopDto, ... ],          // arrêts dans l'ordre OPTIMAL de passage
    "returnLeg": LegDto,                // segment du dernier arrêt vers le dépôt
    "geometry": [[lat,lon], ...] | null,   // TRACE COMPLÈTE de la tournée ; non nul UNIQUEMENT si POINTS
    "geometryPolyline": string | null      // TRACE COMPLÈTE en polyligne ; non nul pour POINTS et POLYLINE ; null si NONE
  }
  IMPORTANT : pour tracer toute la tournée d'un seul trait, utiliser route.geometry / route.geometryPolyline
  (les géométries par segment NE se concatènent PAS proprement).

StopDto (un arrêt) :
  {
    "visitId": string,
    "name": string,
    "lat": number, "lon": number,
    "legFromPrevious": LegDto,          // segment depuis le point précédent (ou le dépôt) vers cet arrêt
    "cumulativeDistanceMeters": number, // cumul depuis le dépôt
    "cumulativeDrivingSeconds": integer,// cumul depuis le dépôt
    "arrivalTime": LocalDateTime,       // arrivée physique au point
    "serviceStartTime": LocalDateTime,  // = arrivalTime, ou timeWindowStart si arrivé en avance (attente)
    "departureTime": LocalDateTime,     // serviceStartTime + durée de service
    "timeWindowStart": LocalDateTime | null,  // fenêtre demandée, telle que fournie
    "timeWindowEnd": LocalDateTime | null,
    "waitingSeconds": integer,          // attente avant ouverture de la fenêtre (0 sinon)
    "excessiveWaitingSeconds": integer, // part de l'attente au-delà de maxWaitingSeconds ; 0 = OK ; >0 = VIOLATION (feasible=false)
    "lateSeconds": integer,             // retard sur timeWindowEnd ; 0 = OK ; >0 = VIOLATION (feasible=false)
    "shipmentId": string | null,        // mission dont cet arrêt est une extrémité ; null = arrêt simple (visits)
    "stopType": "PICKUP"|"DELIVERY"|null, // rôle dans la mission ; null = arrêt simple.
                                        // Dans une tournée valide, le PICKUP précède TOUJOURS son DELIVERY.
    "timeWindowStatus": "OK"|"LATE"|"WAITING_TOO_LONG",  // OK = fenêtre tenue ou pas de fenêtre ; sinon violation
    "demand": integer
  }

LegDto (un segment routier) :
  {
    "distanceMeters": number,
    "durationSeconds": integer,
    "geometry": [[lat,lon], ...] | null,   // non nul UNIQUEMENT si POINTS
    "geometryPolyline": string | null      // non nul UNIQUEMENT si POLYLINE
  }
  NB : sur un LegDto (par segment), geometry suit POINTS et geometryPolyline suit POLYLINE
  (contrairement aux champs au niveau RouteDto/RouteResponse où la polyline est remplie aussi en POINTS).

SkippedVisitDto (visite écartée) :
  {
    "visitId": string,                  // id fourni ou auto-généré "v"
    "name": string | null,
    "lat": number, "lon": number,       // coordonnées fournies
    "reason": "UNROUTABLE" | "TOO_FAR" | "PAIRED_POINT_SKIPPED",
                                        // UNROUTABLE = aucune route trouvable ; TOO_FAR = route la plus proche au-delà du seuil ;
                                        // PAIRED_POINT_SKIPPED = ce point est bon, mais l'AUTRE extrémité de sa mission ne l'est pas
    "snapDistanceMeters": number | null,// distance jusqu'à la route la plus proche ; renseigné pour TOO_FAR, null sinon
    "shipmentId": string | null,        // mission concernée ; null = arrêt simple
    "stopType": "PICKUP"|"DELIVERY"|null
  }

La réponse porte aussi :
  "pairingViolations": integer,   // missions cassées (extrémités séparées ou enlèvement avant chargement). 0 attendu.
  "solvingTimeSeconds": integer | null  // budget réellement alloué ; null = terminaison globale (1 s)

Codes : 200 (vérifier feasible + pairingViolations + skippedVisits) ; 400 dépôt manquant/non rattachable,
requête sans aucun point (visits ET shipments vides), mission sans pickup ni delivery, identifiant dupliqué
(requête avec shipments), timeWindowStart > timeWindowEnd, maxWaitingSeconds négatif ou maxSolvingSeconds < 1 ;
503 routing indisponible.

--- POST /optimization/dispatch ---
But : RÉPARTIR automatiquement une liste de points (typiquement 20 à 300, ex : 100) en K tournées
(vehicleCount, ex : 5) ÉQUILIBRÉES EN DURÉE, puis ordonner chaque tournée. L'API décide elle-même quel point
va dans quelle tournée : aucune capacité ni demand n'est nécessaire.
Différence avec /optimize : /optimize minimise le temps TOTAL et, avec plusieurs véhicules sans capacité, finit
par tout mettre sur UN SEUL véhicule (chaque véhicule ajoute un aller-retour au dépôt). /dispatch minimise la
SOMME DES CARRÉS des durées de tournée (durée = conduite + service + attente) : cela réduit à la fois le temps
global ET l'écart entre véhicules → tous les véhicules sont utilisés, chaque tournée couvre une zone géographique
cohérente (les points proches finissent dans la même tournée), les durées sont proches sans être strictement
égales (un déséquilibre est accepté s'il fait gagner du temps globalement, ou si les fenêtres horaires / la
géographie l'imposent). vehicleCount=1 donne le même optimum que /optimize.
Dépend du routing (matrice (N+1)² via GraphHopper : ~10 000 routages pour 100 points) : si routing KO → 503.

Corps (DispatchRequest) :
  {
    "depot": Coordinate (REQUIS),          // départ ET arrivée commun à toutes les tournées
    "vehicleCount": integer (REQUIS, ≥ 1), // NOMBRE de tournées à produire ; absent ou < 1 → 400
    "vehicleCapacity": integer | null,     // FACULTATIF (inutile pour la répartition). Si fourni : contrainte DURE, la somme des
                                           // demand d'une tournée ne peut pas le dépasser. null = illimité (demand ignoré).
    "departureTime": LocalDateTime | null, // heure de départ ESTIMÉE du dépôt, commune à TOUTES les tournées ; défaut = heure
                                           // courante du serveur. Origine des heures de la réponse et référence des fenêtres
                                           // horaires → à renseigner dès qu'une visite a une fenêtre.
    "maxWaitingSeconds": integer | null,   // attente MAX tolérée devant une fenêtre pas encore ouverte (s). null = défaut serveur
                                           // (3600) ; 0 = illimitée. Même sémantique que /optimize.
    "maxSolvingSeconds": integer | null,   // temps de RÉSOLUTION alloué au solveur (s), ≥ 1. null = défaut serveur (10 s).
                                           // Plafonné SILENCIEUSEMENT au maximum serveur (60 s) ; la valeur appliquée est renvoyée
                                           // dans solvingTimeSeconds. La requête HTTP dure AU MOINS ce temps + le calcul de la
                                           // matrice → prévoir un timeout client ≥ 90 s. 100 points / 5 tournées : 10 s correct,
                                           // 20-30 s très bon.
    "geometryFormat": "POINTS"|"POLYLINE"|"NONE",  // défaut POINTS ; POLYLINE recommandé (≈ 100 segments à tracer)
    "visits": [ VisitDto, ... ] | null,   // MÊME VisitDto que /optimize (id, name, lat, lon, demand,
                                          // serviceDurationSeconds, timeWindowStart, timeWindowEnd)
    "shipments": [ ShipmentDto, ... ] | null  // MÊME ShipmentDto que /optimize (cf. plus haut). Mélangeable avec visits.
  }
  Au moins un élément au total entre visits et shipments, sinon 400.
  Pas de champ includeGeometry sur cette route (utiliser geometryFormat).

MISSIONS APPAIRÉES SUR /dispatch :
  Une mission n'est JAMAIS coupée entre deux tournées : ses deux arrêts partent dans la même, chargement
  d'abord (contraintes DURES). L'équilibrage porte donc sur des missions ENTIÈRES, ce qui réduit un peu la
  finesse de la répartition : à nombre de points égal, balance.spreadSeconds est généralement plus grand
  qu'avec des points indépendants, et un maxSolvingSeconds plus large est recommandé (le solveur doit
  d'abord réparer la précédence avant d'optimiser). La réponse porte pairingViolations (0 attendu) et
  chaque arrêt porte shipmentId + stopType.

RÈGLES :
  • Fenêtres horaires, attente max, points non rattachables (skippedVisits) et dépôt non rattachable (400) :
    EXACTEMENT les mêmes règles que /optimize. Une fenêtre intenable n'est pas un 400 : la meilleure répartition
    est renvoyée avec feasible=false, timeWindowViolations>0 et stops[].timeWindowStatus = LATE / WAITING_TOO_LONG.
  • Toutes les tournées partent du dépôt à departureTime (pas de décalage entre véhicules).
  • routes contient EXACTEMENT vehicleCount entrées ("vehicle-0" … "vehicle-"), chacune DÉJÀ ordonnée
    (stops[] = ordre de passage). Une tournée n'est vide (stops=[]) que s'il y a moins de points valides que de
    véhicules.
  • Si TOUTES les visites sont écartées → 200, routes=[], usedVehicleCount=0, balance à zéro.
  • Le solveur occupe un thread pendant toute la résolution : les appels simultanés au-delà du parallélisme
    configuré sont mis en file d'attente (la réponse arrive plus tard, pas d'erreur).

Réponse 200 (DispatchResponse) :
  {
    "score": string,                    // "hard/soft" ; 0hard = contraintes dures OK. Le soft (somme des carrés, s²)
                                        // n'est comparable qu'entre deux résolutions du MÊME problème.
    "feasible": boolean,                // true = fenêtres horaires, attente max et capacité (si fournie) toutes respectées
    "timeWindowViolations": integer,    // nb d'arrêts avec timeWindowStatus ≠ OK
    "maxWaitingSeconds": integer | null,// limite d'attente appliquée ; null = aucune
    "solvingTimeSeconds": integer,      // budget de résolution RÉELLEMENT appliqué (requête plafonnée, ou défaut serveur)
    "vehicleCount": integer,            // nombre de tournées demandé = routes.length
    "usedVehicleCount": integer,        // tournées avec au moins 1 arrêt
    "totalDrivingTimeSeconds": integer, // conduite cumulée, toutes tournées
    "totalDistanceMeters": number,      // distance cumulée, toutes tournées
    "totalDurationSeconds": integer,    // somme des routes[].durationSeconds (conduite + service + attente)
    "balance": {                        // équilibre entre les tournées UTILISÉES, en secondes (sur routes[].durationSeconds)
      "longestRouteSeconds": integer,   // tournée la plus longue
      "shortestRouteSeconds": integer,  // tournée la plus courte (parmi celles qui ont ≥ 1 arrêt)
      "averageRouteSeconds": integer,   // moyenne arrondie
      "spreadSeconds": integer          // longest - shortest ; petit = charge équitable
    },
    "routes": [ RouteDto, ... ],        // MÊME RouteDto que /optimize (stops ordonnés, legFromPrevious, cumuls, heures,
                                        // durationSeconds, geometry / geometryPolyline = trace complète de la tournée)
    "skippedVisits": [ SkippedVisitDto, ... ],  // même structure que /optimize
    "pairingViolations": integer        // missions cassées (0 attendu ; toujours 0 sans shipments)
  }

Exemple : 100 points, 5 tournées, 20 s de résolution, polylignes :
  { "depot": {"lat": 48.1173, "lon": -1.6778}, "vehicleCount": 5, "departureTime": "2026-06-15T08:00:00",
    "maxSolvingSeconds": 20, "geometryFormat": "POLYLINE",
    "visits": [ {"id": "A", "name": "Client A", "lat": 48.12, "lon": -1.70, "serviceDurationSeconds": 300}, … ] }
  → pour chaque i : routes[i].stops = ordre de passage de la tournée i, routes[i].durationSeconds = sa durée,
    routes[i].geometryPolyline = son tracé ; balance.spreadSeconds = écart entre la plus longue et la plus courte.

Codes : 200 (vérifier feasible + pairingViolations + skippedVisits) ; 400 dépôt manquant/non rattachable,
vehicleCount absent ou < 1, requête sans aucun point (visits ET shipments vides), mission sans pickup ni delivery,
identifiant dupliqué, coordonnées hors bornes, plus de 1000 véhicules ou de points, timeWindowStart > timeWindowEnd, maxWaitingSeconds < 0 ou
maxSolvingSeconds < 1 ; 503 routing indisponible.

====================================================================
3) GEOCODING  — préfixe /geocoding
====================================================================

--- GET /geocoding/status ---
Réponse 200 : { "ready": boolean, "poiReady": boolean, "addressCount": integer, "poiCount": integer }
  ready=false → /search renvoie 503. poiReady=false → /search répond, mais sans résultats de type "poi"
  (index en construction, fichier OSM absent ou fonction désactivée).

--- GET /geocoding/search ---
But : recherche / autocomplétion plein texte (style GPS) d'adresses, de voies, de communes et de POINTS D'INTÉRÊT
(commerces, enseignes, services publics, gares, sites touristiques…), avec coordonnées GPS.
Paramètres de query :
  q     : string (REQUIS)  — adresse, rue, commune, code postal, nom de lieu, enseigne, type de commerce.
  limit : integer          — nb max de résultats, défaut 10, borné à [1,50] (valeurs hors bornes ramenées).
  lat   : number | absent  — latitude de la position de référence (biais de proximité, optionnel).
  lon   : number | absent  — longitude de la position de référence.
  types : string | absent  — types acceptés, séparés par des virgules : housenumber, street, municipality, poi.
                             Absent = tous. Ex : types=poi ; types=housenumber,street pour un usage purement postal.
Règle lat/lon : fournir les DEUX ou AUCUN. L'un sans l'autre, hors bornes WGS84, valeur non numérique ou type inconnu → 400.

NATURE DES RÉSULTATS (champ "type", tous mélangés par défaut) :
  • housenumber  : adresse précise. Seulement si q contient un numéro de voie existant, en tête ("12 bis rue du bocage")
    ou en fin ("rue du bocage 12"). Un code postal n'est pas un numéro. "rue du 8 mai 1945" reste une voie.
  • street       : voie, une entrée par rue distincte, sans numéro. Proposée aussi si le numéro demandé n'existe pas.
  • municipality : commune, une entrée même si elle a plusieurs codes postaux ("rennes", "35000").
  • poi          : point d'intérêt OpenStreetMap, avec name, category, categoryLabel, brand. Cherchable par nom
    ("tour eiffel"), enseigne ("carrefour"), type ("boulangerie", "pharmacie", "essence"), et combinable avec une
    commune ("carrefour rennes"). Seuls les objets avec un nom ou une enseigne dans OSM sont connus.

ÉCRITURE TOLÉRANTE :
  • casse, accents, ligatures, apostrophes, tirets ignorés ; mots vides (de, la, du…) non exigés ; pluriels simples ignorés ;
  • abréviations : st, ste, av, bd, pl, rte, che, imp, dr, gal, mal, pdt, hop… ; dernier mot complété ("rue de la paix ren") ;
  • fautes de frappe : "carefour" trouve "Carrefour" (classé après les correspondances exactes). Si rien n'est trouvé, la
    recherche se relâche : sans le numéro, puis correction de tous les mots de 4 lettres ou plus, puis un mot manquant toléré.

CLASSEMENT & PROXIMITÉ :
  • Un nom entièrement couvert par q passe devant une correspondance partielle : l'enseigne "Carrefour" avant
    "Rue du Carrefour", "Rue du Bocage" avant "Rue du Parc du Bocage".
  • Si lat+lon fournis → les résultats proches sont favorisés et chaque résultat expose distanceMeters. Les communes ne sont
    PAS pénalisées par la distance ("paris" depuis Rennes trouve Paris). Sans position → distanceMeters = null.
  • TOUJOURS fournir la position pour une recherche générique ("boulangerie", "carrefour"), sinon les résultats peuvent
    être n'importe où en France.

Réponse 200 : tableau (éventuellement vide), trié par pertinence, de AddressResult :
  {
    "label": string,            // libellé lisible, à afficher tel quel : "12 bis Rue du Bocage, 35000 Rennes" (housenumber),
                                // "Rue du Bocage, 35000 Rennes" (street), "35000 Rennes" (municipality),
                                // "Carrefour Market, 35000 Rennes" (poi : nom, adresse si connue, commune)
    "houseNumber": string,      // "12 bis" ; vide si pas une adresse précise
    "street": string,           // vide pour municipality, et pour un poi dont la rue est inconnue d'OSM
    "postcode": string,         // pour un poi : celui de la voie la plus proche (vide si aucune à moins de 3 km)
    "city": string,             // idem
    "lat": number, "lon": number,
    "type": "housenumber" | "street" | "municipality" | "poi",
    "distanceMeters": number | null, // null si aucune position fournie
    "score": number,            // à comparer uniquement entre résultats d'une même requête
    "name": string | null,          // poi uniquement : nom (ou enseigne si l'objet n'a pas de nom)
    "category": string | null,      // poi uniquement : tag OSM "shop=supermarket", stable, pour choisir une icône
    "categoryLabel": string | null, // poi uniquement : "Supermarche"
    "brand": string | null          // poi uniquement : enseigne si connue
  }
Codes : 200 (liste, possiblement vide) ; 400 (paramètres invalides) ; 503 index des adresses indisponible.

Exemples :
  GET /geocoding/search?q=rennes
  GET /geocoding/search?q=rue du bocage&lat=48.11&lon=-1.68
  GET /geocoding/search?q=12 bis rue de la paix ren&limit=5
  GET /geocoding/search?q=carrefour rennes
  GET /geocoding/search?q=boulangerie&lat=48.11&lon=-1.68&types=poi

====================================================================
4) STATUT / SANTÉ
====================================================================
--- GET /status ---
Réponse 200 (StatusResponse) : état global de l'API.
  {
    "application": "spring-org",
    "status": "UP" | "STARTING" | "DEGRADED",
    "uptimeSeconds": integer,
    "startedAtMillis": integer,
    "routingProfile": string,
    "addressCount": integer,         // nb d'adresses numérotées indexées (-1 si indispo)
    "poiCount": integer,             // nb de points d'intérêt indexés (0 si leur index n'est pas chargé)
    "jvm": { "javaVersion": string, "pid": integer, "cpuCores": integer,
             "memUsedBytes": integer, "memMaxBytes": integer, "memUsedPercent": integer },
    "components": [ { "name": string, "state": string, "detail": string, "ready": boolean } ],
        // state ∈ WAITING | DOWNLOADING | INITIALIZING | READY | DISABLED | ERROR
    "downloads": [ { "name": string, "downloadedBytes": integer, "totalBytes": integer, "percent": integer, "done": boolean } ],
    "data": [ { "name": string, "path": string, "present": boolean, "sizeBytes": integer } ],
    "dataUpdate": {                  // mise à jour automatique des données (OSM + BAN)
      "autoDownload": boolean, "updateOnStart": boolean, "cron": string,
      "nextScheduledAtMillis": integer | null,
      "last": null | { "trigger": "Demarrage de l'API" | "Planificateur hebdomadaire",
                       "state": "RUNNING" | "SUCCESS" | "UP_TO_DATE" | "PARTIAL" | "FAILED",
                       "startedAtMillis": integer, "finishedAtMillis": integer | null, "summary": string }
    },
    "links": { "swaggerUi": "/swagger-ui.html", "openApi": "/v3/api-docs" }
  }

Autres endpoints utiles :
  GET /                  → tableau de bord HTML (état en direct).
  GET /swagger-ui.html   → Swagger UI.
  GET /v3/api-docs       → spec OpenAPI JSON (source de vérité du contrat).

====================================================================
CONSIGNES D'IMPLÉMENTATION
====================================================================
- Configure l'URL de base sur https://ors.stack.bzh (paramétrable pour pointer vers http://localhost:8080 en dev).
- Envoie/lis du JSON. Gère explicitement 400 et 503 (lire le ProblemDetail : champs `title`, `detail`, `status`).
- Pour les longs trajets/tournées, demande geometryFormat="POLYLINE" et décode avec @mapbox/polyline
  (précision 5, ordre lat,lon). Pour alléger au maximum, "NONE".
- Sur /optimization/optimize et /optimization/dispatch, inspecte TOUJOURS skippedVisits ET feasible avant
  d'afficher les tournées ; renseigne departureTime dès qu'une visite a une fenêtre horaire.
- Dès qu'une marchandise doit être PRISE quelque part puis DÉPOSÉE ailleurs, utilise shipments (et non deux
  entrées de visits) : c'est le seul moyen de garantir le même véhicule et l'ordre chargement → enlèvement.
  Vérifie pairingViolations == 0, et relie les deux arrêts d'une mission via stops[].shipmentId /
  stops[].stopType. Prévois un timeout plus large : la présence de shipments relève le temps de résolution
  de /optimize (5 s par défaut au lieu d'1 s).
- Pour répartir beaucoup de points entre plusieurs chauffeurs, utilise /optimization/dispatch (pas /optimize
  avec vehicleCount > 1). Prévois un timeout HTTP ≥ 90 s et affiche un état « calcul en cours » pendant
  maxSolvingSeconds ; propose 10 s par défaut et 20-30 s pour un meilleur résultat.
- Avant d'appeler une brique, tu peux vérifier sa disponibilité via son endpoint ./status (ready=true).
- Ne suppose aucune symétrie de la matrice des temps (sens uniques possibles).
spring-org · prompt généré pour intégration assistée par IA · base : https://ors.stack.bzh