feat(dispatch): OSRM road-time matrix for the VRP optimizer (Phase 2)

Replace the haversine straight-line estimate with real road travel times
from a self-hosted OSRM (free/offline — Mapbox Matrix is billable).

- hub: osrmMatrix() calls OSRM /table for the job+home coords (node order
  matches route_solver: jobs then homes), returns a minutes matrix.
  Handles PARTIAL coords — OSRM for geolocated nodes, 0/neutral for the
  rest (matches the solver's own fallback), so a few tech homes without
  coords don't disable OSRM for the geolocated majority. /roster/
  optimize-routes injects body.matrix; graceful fallback to haversine if
  OSRM is unreachable or too few points.
- config: OSRM_URL (default http://osrm:5000).
- services/osrm/README.md: reproducible setup (Québec extract, MLD
  pipeline, osrm-routed on erpnext_erpnext network).

Deployed + verified: OSRM /table hit with the full coord set, no
fallback; optimizer routes on real road times.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 13:48:43 -04:00
parent 5803fb3d0e
commit 96929bed6f
3 changed files with 73 additions and 0 deletions

39
services/osrm/README.md Normal file
View File

@ -0,0 +1,39 @@
# OSRM — moteur de routes (matrice de temps de route réels)
Fournit à l'optimiseur VRP (`roster-solver`) une **matrice de temps de route** réels
(réseau routier OSM), au lieu du haversine à vol d'oiseau. Le hub (`targo-hub`,
`lib/roster.js``osrmMatrix`) appelle `OSRM /table` puis passe la matrice à
`roster-solver /route`. Auto-hébergé → **gratuit, offline, sans coût par appel**
(contrairement à Mapbox Matrix).
- Image : `osrm/osrm-backend` (pipeline **MLD**)
- Conteneur : `osrm` sur le réseau `erpnext_erpnext`, port interne `5000`, `--restart unless-stopped`
- Données : extrait **Québec** de Geofabrik, préprocessé dans `/opt/osrm/` (host)
- Config hub : `OSRM_URL` (défaut `http://osrm:5000`)
## (Re)déploiement / mise à jour de la carte
```sh
mkdir -p /opt/osrm && cd /opt/osrm
curl -sL -o quebec-latest.osm.pbf https://download.geofabrik.de/north-america/canada/quebec-latest.osm.pbf
docker pull osrm/osrm-backend
docker run --rm -v /opt/osrm:/data osrm/osrm-backend osrm-extract -p /opt/car.lua /data/quebec-latest.osm.pbf
docker run --rm -v /opt/osrm:/data osrm/osrm-backend osrm-partition /data/quebec-latest.osrm
docker run --rm -v /opt/osrm:/data osrm/osrm-backend osrm-customize /data/quebec-latest.osrm
docker rm -f osrm 2>/dev/null || true
docker run -d --name osrm --network erpnext_erpnext --restart unless-stopped \
-v /opt/osrm:/data osrm/osrm-backend \
osrm-routed --algorithm mld --max-table-size 2000 /data/quebec-latest.osrm
```
Préproc ~10 min pour le Québec (pic RAM ~4,4 Go). Rafraîchir la carte = relancer le bloc
(re-télécharge le pbf + re-préprocesse). Vérifier :
```sh
docker exec targo-hub node -e 'require("http").get({host:"osrm",port:5000,path:"/table/v1/driving/-74.10,45.05;-74.35,45.12?annotations=duration"},r=>{let d="";r.on("data",c=>d+=c);r.on("end",()=>console.log(d))})'
# → {"code":"Ok","durations":[[0,2160],[2160,0]], ...} (secondes)
```
## Phase suivante (optionnel)
- Recalculer la carte périodiquement (cron mensuel) — le réseau routier évolue peu.
- Si on sort du Québec : extrait plus large (Canada) — plus de RAM/disk au préproc.

View File

@ -60,6 +60,7 @@ module.exports = {
OMLX_URL: env('OMLX_URL', 'http://127.0.0.1:8000'),
TRACCAR_URL: env('TRACCAR_URL', 'http://tracker.targointernet.com:8082'),
ROSTER_SOLVER_URL: env('ROSTER_SOLVER_URL', 'http://roster-solver:8090'),
OSRM_URL: env('OSRM_URL', 'http://osrm:5000'), // moteur de routes OSM (matrice temps de route réels) pour l'optimiseur VRP
JWT_SECRET: env('JWT_SECRET'),
FIELD_APP_URL: env('FIELD_APP_URL', 'https://msg.gigafibre.ca'),
EXTERNAL_URL: env('EXTERNAL_URL', 'https://msg.gigafibre.ca'),

View File

@ -84,6 +84,35 @@ function postSolver (path, body) {
})
}
const OSRM_URL = cfg.OSRM_URL || 'http://osrm:5000'
// Matrice de TEMPS DE ROUTE (minutes) via OSRM /table, dans l'ordre attendu par route_solver : jobs d'abord, PUIS domiciles des véhicules.
// null si un nœud n'a pas de coordonnées → le solveur retombe proprement sur le haversine (pas de dépendance dure à OSRM).
async function osrmMatrix (jobs, vehicles) {
const ok = (a, b) => a != null && b != null && isFinite(+a) && isFinite(+b) && (Math.abs(+a) > 0.01 || Math.abs(+b) > 0.01)
// Ordre des nœuds = celui de route_solver : jobs d'abord, PUIS domiciles des véhicules. null = sans coordonnées.
const nodes = []
for (const j of jobs) nodes.push(ok(j.lat, j.lon) ? [+j.lon, +j.lat] : null)
for (const v of vehicles) nodes.push(ok(v.home_lat, v.home_lon) ? [+v.home_lon, +v.home_lat] : null)
const N = nodes.length
const withIdx = []; for (let i = 0; i < N; i++) if (nodes[i]) withIdx.push(i)
if (withIdx.length < 2) return null // pas assez de points géolocalisés → le solveur retombe sur le haversine
const coords = withIdx.map(i => nodes[i])
const path = '/table/v1/driving/' + coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';') + '?annotations=duration'
const u = new URL(OSRM_URL + path)
const raw = await new Promise((resolve, reject) => {
const rq = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET', timeout: 20000 }, (r) => {
let d = ''; r.on('data', c => { d += c }); r.on('end', () => resolve(d))
})
rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end()
})
const j = JSON.parse(raw)
if (j.code !== 'Ok' || !Array.isArray(j.durations)) throw new Error('osrm code ' + (j.code || '?'))
// Matrice NxN complète en MINUTES : temps de route OSRM pour les paires géolocalisées, 0 (neutre) pour tout nœud sans coords.
const M = Array.from({ length: N }, () => new Array(N).fill(0))
for (let a = 0; a < withIdx.length; a++) for (let b = 0; b < withIdx.length; b++) { const s = j.durations[a][b]; M[withIdx[a]][withIdx[b]] = (s == null ? 0 : Math.round(s / 60 * 10) / 10) }
return M
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
// les lignes chaudes (Dispatch Technician maj en continu par le GPS/dispatch) un
@ -915,6 +944,10 @@ async function handle (req, res, method, path, url) {
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' })
if (!body.matrix) { // temps de ROUTE réels via OSRM si dispo ; sinon le solveur retombe sur le haversine
try { const m = await osrmMatrix(body.jobs, body.vehicles); if (m) { body.matrix = m; body.matrix_source = 'osrm' } }
catch (e) { log('osrm matrix skip: ' + (e && e.message)) }
}
try { return json(res, 200, await postSolver('/route', body)) }
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) }
}