feat(dispatch): P1 OSRM everywhere + review ticket detail, editable time, per-leg travel

P1 — self-hosted OSRM replaces ALL paid Mapbox routing in the SPA:
- hub: POST /roster/osrm-route (geometry + total + per-leg km/min) and
  /roster/osrm-table (durations/distances matrix, Mapbox-shaped) proxying
  the local OSRM; osrmGet helper.
- SPA: loadRealRoutes (review map), kanban travel matrix, day-editor
  matrix, day-map route geometry → all via hub OSRM. 0 paid
  directions/matrix calls left (tiles + geocoding only).

Review usability (evaluate whether times fit):
- Click a job → the SAME job-detail dialog as the grid (address, ticket
  thread, duration, team). openEntryDetail maps the pool job.
- Per-task time is editable (q-popup-edit on the "X.Xh"): updates the
  plan live, survives Ré-optimiser (durOverride), persists duration_h
  via patchJob (hub whitelist extended, 0-24h).
- Real travel time BETWEEN stops: one OSRM route per tech×day (cached)
  → "🚗 X min" rows between entries + "domicile → 1er arrêt";
  haversine ≈ fallback when coords missing.

Verified: OSRM proxies live (route 7.8km/12min with legs 7+6min, table
3x3); review shows 10 real leg rows; detail dialog shows address;
duration popup opens/cancels cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 18:41:44 -04:00
parent 2e6eb6dd01
commit 462fcef9d5
3 changed files with 129 additions and 31 deletions

View File

@ -100,6 +100,9 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
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)
// Proxys OSRM auto-hébergé (points [[lon,lat],…]) — tracés + matrices SANS Mapbox payant.
export const osrmRoute = (points) => jpost('/roster/osrm-route', { points }) // → { km, mins, geometry, legs[] (temps entre arrêts) }
export const osrmTable = (points) => jpost('/roster/osrm-table', { points }) // → { durations (s), distances (m) } même forme que Mapbox Matrix
// 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'), {})

View File

@ -1080,13 +1080,19 @@
<span class="text-grey-5 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round((pj.dur || 0) * 10) / 10 }}h</span>
</div>
</template>
<div v-for="(e, ei) in d.entries" :key="e.jobName" class="suggest-entry">
<template v-for="(e, ei) in d.entries" :key="e.jobName">
<div v-if="ei === 0 && legHome(g, d) != null" class="suggest-leg-time">🚗 domicile 1er arrêt : {{ legHome(g, d) }} min</div>
<div class="suggest-entry">
<span class="suggest-step">{{ ei + 1 }}</span>
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
<span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span>
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
<span class="suggest-entry-t ellipsis">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
<span class="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</span>
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
<q-input type="number" v-model.number="scope.value" dense autofocus :step="0.25" :min="0.25" :max="24" suffix="h" label="Temps sur place" @keyup.enter="scope.set" />
</q-popup-edit>
</span>
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » à réassigner</q-tooltip></q-icon>
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais hors quart)</q-tooltip></q-icon>
<!-- Contraintes horaires (prises en compte par -optimiser) : AM/PM = fenêtre · = urgent, servir en début de journée -->
@ -1117,6 +1123,8 @@
</q-btn>
<q-btn flat dense round size="sm" icon="inbox" color="grey-6" @click="removeSuggestEntry(e)"><q-tooltip>Retirer (le job reste au pool)</q-tooltip></q-btn>
</div>
<div v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ' }}</div>
</template>
</div>
</div>
</div>
@ -1935,10 +1943,8 @@ async function fetchKbMatrix (techId) {
const use = pts.slice(0, 25) // Matrix = 25 coords max
_kbMatPending.add(key)
try {
const coords = use.map(p => `${p.lon.toFixed(6)},${p.lat.toFixed(6)}`).join(';')
const r = await fetch(`https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`)
if (!r.ok) throw new Error('matrix ' + r.status)
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []; const map = {}
const d = await roster.osrmTable(use.map(p => [p.lon, p.lat])) // OSRM auto-hébergé (via hub) même forme que Mapbox Matrix
const dur = d.durations || [], dist = d.distances || []; const map = {}
for (let i = 0; i < use.length; i++) for (let k = 0; k < use.length; k++) { if (i === k) continue; const sec = dur[i] && dur[i][k]; if (sec == null) continue; const m = dist[i] && dist[i][k]; map[use[i].name + '>' + use[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null } }
kbMatrix.value = { ...kbMatrix.value, [key]: { sig, map } } // réactif les legs se recalculent (temps routiers réels)
} catch (e) { /* repli haversine (géré dans kanbanLaneBlocks) */ } finally { _kbMatPending.delete(key) }
@ -2973,19 +2979,24 @@ function refreshSuggestMap () {
// D v2 routes RÉELLES : Mapbox Directions par tech (domicilearrêts) géométrie routière + distance/durée réelles. Cache par signature.
const _routeCache = new Map()
const realRoutes = reactive({}) // techId { km, mins } (la légende préfère ces valeurs réelles quand présentes)
// Tournée routière RÉELLE d'un tech (OSRM auto-hébergé via hub, plus de Mapbox payant) : tracé + total + temps ENTRE arrêts. Cache par signature.
async function fetchTechRoute (home, stops) {
const pts = []; if (home) pts.push([+home.lon, +home.lat]); for (const s of stops) pts.push([+s.lon, +s.lat])
if (pts.length < 2 || pts.length > 50) return null
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
if (_routeCache.has(sig)) return _routeCache.get(sig)
try {
const d = await roster.osrmRoute(pts)
if (!d || !Array.isArray(d.geometry)) return null
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins, legs: d.legs || [], hasHome: !!home }
_routeCache.set(sig, info); return info
} catch (e) { return null }
}
async function loadRealRoutes (routes) {
if (!MAPBOX_TOKEN || !routes.length || !_suggestMap) return
if (!routes.length || !_suggestMap) return
const results = await Promise.all(routes.map(async r => {
const pts = []; if (r.home) pts.push([r.home.lon, r.home.lat]); for (const s of r.stops) pts.push([s.lon, s.lat])
if (pts.length < 2 || pts.length > 25) return null // Directions = 25 points max
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
if (_routeCache.has(sig)) return { techId: r.techId, ..._routeCache.get(sig) }
try {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving/${pts.map(p => p[0].toFixed(6) + ',' + p[1].toFixed(6)).join(';')}?geometries=geojson&overview=full&access_token=${MAPBOX_TOKEN}`
const d = await fetch(url).then(x => x.json()); const rt = d && d.routes && d.routes[0]; if (!rt) return null
const info = { geometry: rt.geometry, km: Math.round(rt.distance / 100) / 10, mins: Math.round(rt.duration / 60) }
_routeCache.set(sig, info); return { techId: r.techId, ...info }
} catch (e) { return null }
const info = await fetchTechRoute(r.home, r.stops)
return info ? { techId: r.techId, ...info } : null
}))
if (!_suggestMap || !_suggestMap.getSource('sr-line')) return
const byTech = {}; for (const x of results) { if (x) { byTech[x.techId] = x; realRoutes[x.techId] = { km: x.km, mins: x.mins } } }
@ -3101,12 +3112,10 @@ async function loadDayRoute () {
const jobsLL = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon))
const origin = dayOrigin.value // l'origine (dépôt/domicile) entre dans la matrice trajet origine1er job RÉEL
const pts = ((origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [origin, ...jobsLL] : jobsLL).slice(0, 25) // Matrix = 25 coords max
if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
if (pts.length < 2) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
try {
const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []
const d = await roster.osrmTable(pts.map(j => [+j.lon, +j.lat])) // OSRM auto-hébergé (via hub) même forme que Mapbox Matrix
const dur = d.durations || [], dist = d.distances || []
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
const map = {}
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
@ -3349,14 +3358,9 @@ async function fetchDayRouteGeom (stops, origin) { // itinéraire ROUTIER réel
const ordered = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [{ lon: +origin.lon, lat: +origin.lat }, ...(stops || [])] : (stops || [])
if (ordered.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
try {
const pts = ordered.slice(0, 25).map(s => `${(+s.lon).toFixed(6)},${(+s.lat).toFixed(6)}`).join(';')
// continue_straight=false : autorise le demi-tour aux points intermédiaires (sinon Mapbox interdit le U-turn au
// waypoint grande boucle parasite quand le job suivant est « derrière » ; un tech PEUT faire demi-tour).
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&continue_straight=false&access_token=${MAPBOX_TOKEN}`
const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status)
const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry
const d = await roster.osrmRoute(ordered.slice(0, 50).map(s => [+s.lon, +s.lat])) // OSRM auto-hébergé (via hub) l'U-turn aux waypoints est permis par défaut
const src = _dayMap && _dayMap.getSource('day-route')
if (geom && src) src.setData({ type: 'Feature', geometry: geom, properties: {} })
if (d && Array.isArray(d.geometry) && d.geometry.length && src) src.setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: d.geometry }, properties: {} })
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
}
function destroyDayMap () {
@ -3937,6 +3941,54 @@ function selectAllSector (city) {
// D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {} }) // défaut = solveur VRP (1re génération optimise déjà, plus de placeholders) · strategy: optimize|smart|best|balance|enough
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
// Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM)
const durOverride = reactive({}) // jobName heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
const legsByKey = reactive({}) // 'techId|iso' { homeTo: min|null, after: { jobName: min } } temps de route réels entre arrêts
function openEntryDetail (e, g) { // clic sur un job de la revue même fiche détail que la grille (adresse, détail, fil du ticket F)
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName) || {}
openJobDetail({
name: e.jobName, subject: e.subject || j.subject, customer: e.customer || j.customer_name || '',
address: j.address || j.location_label || '', skill: e.skill || j.required_skill || '', dur: e.dur,
detail: j.legacy_detail || '', legacy_id: j.legacy_ticket_id || null, dept: j.legacy_dept || '',
lat: e.lat, lon: e.lon,
}, (g && !g.placeholder) ? { id: g.techId, name: g.techName } : null)
}
function setEntryDur (e, v) { // temps révisé : plan (réactif) + mémoire de session + persistance ERPNext duration_h
const h = Math.round(Math.max(0.25, Math.min(24, Number(v) || 0)) * 4) / 4
if (!h) return
e.dur = h; durOverride[e.jobName] = h
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName)
if (j) patchJob(j, { duration_h: h }, 'Temps révisé : ' + h + ' h — Ré-optimiser pour recalculer les tournées')
}
// Temps de route réels entre les arrêts de chaque tournée (1 appel OSRM par tech×jour, cache par signature dans fetchTechRoute).
async function loadLegTimes () {
for (const g of suggestGroups.value) {
if (g.placeholder) continue
const home = techHomes.value[g.techId]
const h = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? home : null
for (const d of g.dayList) {
const geo = d.entries.filter(e => e.lat != null && e.lon != null)
if (geo.length < (h ? 1 : 2)) continue
const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon })))
if (!info || !info.legs || !info.legs.length) continue
const legs = info.legs; const out = { homeTo: null, after: {} }
let li = 0
if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 }
for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins }
legsByKey[g.techId + '|' + d.iso] = out
}
}
}
// Temps de route affiché ENTRE deux lignes : réel (OSRM) si connu, sinon estimation haversine, sinon rien.
function legInfo (g, d, ei) {
const e = d.entries[ei]; const nx = d.entries[ei + 1]
if (!e || !nx) return null
const real = (legsByKey[g.techId + '|' + d.iso] || {}).after
if (real && real[e.jobName] != null) return { mins: real[e.jobName], real: true }
if (e.lat != null && e.lon != null && nx.lat != null && nx.lon != null) return { mins: estTravelMin(haversineKm(+e.lat, +e.lon, +nx.lat, +nx.lon)), real: false }
return null
}
function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).homeTo; return v != null ? v : null }
// Contraintes horaires par job ( Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence début de journée). Session (clé = jobName, survit aux ré-optimisations).
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
@ -3984,7 +4036,7 @@ function buildSuggestion () {
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) plus de défaut global par compétence
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
const jcity = jobCity(j) || '' // municipalité sert de repli de proximité quand le job n'a pas de coordonnées
const dur = jobDur(j)
const dur = Number(durOverride[j.name]) > 0 ? Number(durOverride[j.name]) : jobDur(j) // temps révisé par le dispatcher prioritaire
const sched = (j.scheduled_date && j.scheduled_date !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(j.scheduled_date)) ? j.scheduled_date : null
let cand
if (j._reDate && winSet.has(j._reDate)) cand = [j._reDate] // date re-choisie (dans la fenêtre)
@ -4130,12 +4182,14 @@ async function runSuggestion () {
} 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'
loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant)
}
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/ posées sur les jobs.
async function reoptimize () {
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || ''
loadLegTimes().catch(() => {}) // recalcule les temps entre arrêts après ré-optimisation
}
// 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) {
@ -5163,6 +5217,11 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.suggest-prev-toggle { display: inline-flex; align-items: center; gap: 2px; margin-left: 8px; font-size: 10.5px; font-weight: 600; color: #64748b; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0 6px; cursor: pointer; user-select: none; }
.suggest-prev-toggle:hover { border-color: #94a3b8; }
.suggest-prev { opacity: 0.75; background: #f8fafc; border-radius: 6px; }
.suggest-clk { cursor: pointer; }
.suggest-clk:hover { color: #4f46e5; text-decoration: underline; }
.suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; }
.suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
.suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; }
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
.jt-chip:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }

View File

@ -113,6 +113,17 @@ async function osrmMatrix (jobs, vehicles) {
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
}
// GET brut vers OSRM (helper des proxys /roster/osrm-*) — remplace les appels Mapbox payants du SPA.
function osrmGet (path) {
const u = new URL(OSRM_URL + path)
return 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', () => { try { resolve(JSON.parse(d)) } catch (e) { reject(new Error('osrm: réponse invalide')) } })
})
rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end()
})
}
const osrmPts = (b, max) => { const pts = Array.isArray(b.points) ? b.points.slice(0, max) : []; return (pts.length >= 2 && pts.every(p => Array.isArray(p) && isFinite(+p[0]) && isFinite(+p[1]))) ? pts.map(p => (+p[0]).toFixed(6) + ',' + (+p[1]).toFixed(6)).join(';') : null }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
@ -950,6 +961,30 @@ 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 })
}
// Proxys OSRM pour le SPA (points = [[lon,lat],…]) — remplacent Mapbox Matrix/Directions (payants) par l'OSRM auto-hébergé.
if (path === '/roster/osrm-table' && method === 'POST') {
const b = await parseBody(req); const q = osrmPts(b, 60)
if (!q) return json(res, 400, { error: 'points [[lon,lat],…] requis (2-60)' })
try {
const j = await osrmGet('/table/v1/driving/' + q + '?annotations=duration,distance')
if (j.code !== 'Ok') throw new Error('code ' + (j.code || '?'))
return json(res, 200, { durations: j.durations || [], distances: j.distances || [] }) // même forme que Mapbox Matrix (secondes/mètres)
} catch (e) { return json(res, 502, { error: 'osrm: ' + (e && e.message) }) }
}
if (path === '/roster/osrm-route' && method === 'POST') {
const b = await parseBody(req); const q = osrmPts(b, 60)
if (!q) return json(res, 400, { error: 'points [[lon,lat],…] requis (2-60)' })
try {
const j = await osrmGet('/route/v1/driving/' + q + '?overview=full&geometries=geojson&steps=false')
const r0 = (j.code === 'Ok' && j.routes && j.routes[0]) || null
if (!r0) throw new Error('code ' + (j.code || 'vide'))
return json(res, 200, {
km: Math.round(r0.distance / 100) / 10, mins: Math.round(r0.duration / 60),
geometry: (r0.geometry && r0.geometry.coordinates) || [], // LineString [[lon,lat],…] (tracé routier réel)
legs: (r0.legs || []).map(l => ({ km: Math.round(l.distance / 100) / 10, mins: Math.round(l.duration / 60) })), // temps de route ENTRE chaque arrêt
})
} catch (e) { return json(res, 502, { error: 'osrm: ' + (e && e.message) }) }
}
// 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') {
@ -1284,6 +1319,7 @@ async function handle (req, res, method, path, url) {
if (src.required_skill !== undefined) patch.required_skill = String(src.required_skill || '').slice(0, 80)
if (src.scheduled_date !== undefined) { const d = String(src.scheduled_date || ''); if (d && !/^\d{4}-\d{2}-\d{2}$/.test(d)) return json(res, 400, { error: 'date invalide' }); patch.scheduled_date = d || null }
if (src.notes !== undefined) patch.notes = String(src.notes || '').slice(0, 2000)
if (src.duration_h !== undefined) { const dh = Number(src.duration_h); if (!isFinite(dh) || dh <= 0 || dh > 24) return json(res, 400, { error: 'duration_h invalide (0-24)' }); patch.duration_h = Math.round(dh * 100) / 100 } // temps révisé par le dispatcher
if (!Object.keys(patch).length) return json(res, 400, { error: 'aucun champ modifiable' })
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok) invalidatePool() // priorité/statut/date/compétence/note changés → rafraîchir le cache du pool