From fc1f61eecf2a25fd77231db913aa7577421bbf33 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 20:41:00 -0400 Subject: [PATCH] fix(dispatch): OSRM for ALL routing + fix waypoint overshoot + detail dialog polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing: the last Mapbox routing calls now go through our OSRM — useMap.computeDayRoute (Dispatch board route lines) → /roster/osrm-route, useAutoDispatch optimizeRoute → new /roster/osrm-trip (OSRM /trip TSP, same response shape as Mapbox optimized-trips). 0 Mapbox directions/ matrix/optimized-trips fetches left in the SPA. (Remaining Mapbox = gl-js renderer + base tiles + geocoding, which OSRM can't provide.) Anthony route bug: /roster/osrm-route now sends continue_straight=false. Without it OSRM forbids U-turns at intermediate waypoints, so on a home→stop→home loop it overshoots the address and turns around in a side street ("s'éloigne et tourne"). Now it goes straight to the door. Benefits every map (shared osrm-route). Job detail dialog: - Highlighted assigned-tech chip (indigo, avatar+name) in the header — "who does this job" is now obvious; grey "Non assigné" otherwise. - Mini-map shows nearby jobs (pool + occupancy within ~3km) as pale grey dots for context, under the job's own pin; centered on the job. - Normalize displayed strings: decode HTML entities (subject "d'équipement" → "d'équipement") via deEnt(). Verified: osrm-trip returns Ok order; detail shows Anthony Dion chip + decoded subject; routes render (43 pins), Anthony 31.7km/35min round trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 1 + apps/ops/src/composables/useAutoDispatch.js | 11 +++--- apps/ops/src/composables/useMap.js | 10 +++--- apps/ops/src/pages/PlanificationPage.vue | 39 +++++++++++++++++++-- services/targo-hub/lib/roster.js | 13 ++++++- 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index b41fb5e..6f47279 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -107,6 +107,7 @@ export const optimizePlanHub = (payload) => jpost('/roster/optimize-plan', paylo // 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 +export const osrmTrip = (points) => jpost('/roster/osrm-trip', { points }) // TSP → { code, waypoints:[{waypoint_index}] } (remplace Mapbox optimized-trips) // 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'), {}) diff --git a/apps/ops/src/composables/useAutoDispatch.js b/apps/ops/src/composables/useAutoDispatch.js index 214d3a4..1d34868 100644 --- a/apps/ops/src/composables/useAutoDispatch.js +++ b/apps/ops/src/composables/useAutoDispatch.js @@ -1,6 +1,7 @@ // ── Auto-dispatch composable: autoDistribute + optimizeRoute ───────────────── import { localDateStr } from './useHelpers' import { updateJob } from 'src/api/dispatch' +import * as roster from 'src/api/roster' // TSP via NOTRE OSRM (plus de Mapbox optimized-trips) export function useAutoDispatch (deps) { const { store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs, bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes } = deps @@ -110,12 +111,10 @@ export function useAutoDispatch (deps) { try { const hasHome = !!(tech.coords?.[0] && tech.coords?.[1]) const coords = [] - if (hasHome) coords.push(`${tech.coords[0]},${tech.coords[1]}`) - reordered.forEach(j => coords.push(`${j.coords[0]},${j.coords[1]}`)) - if (coords.length <= 12) { - const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/driving/${coords.join(';')}?overview=false${hasHome ? '&source=first' : ''}&roundtrip=false&destination=any&access_token=${MAPBOX_TOKEN}` - const res = await fetch(url) - const data = await res.json() + if (hasHome) coords.push([tech.coords[0], tech.coords[1]]) + reordered.forEach(j => coords.push([j.coords[0], j.coords[1]])) + if (coords.length >= 2 && coords.length <= 12) { + const data = await roster.osrmTrip(coords) // NOTRE OSRM /trip (via hub) — même forme que Mapbox optimized-trips if (data.code === 'Ok' && data.waypoints) { const off = hasHome ? 1 : 0, uc = orderedUrgent.length const mu = reordered.slice(0, uc).map((j, i) => ({ job: j, o: data.waypoints[i + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job) diff --git a/apps/ops/src/composables/useMap.js b/apps/ops/src/composables/useMap.js index 1bb8269..88e1110 100644 --- a/apps/ops/src/composables/useMap.js +++ b/apps/ops/src/composables/useMap.js @@ -1,6 +1,7 @@ // ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ──────── import { ref, watch, nextTick } from 'vue' import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers' +import * as roster from 'src/api/roster' // tracés routiers via NOTRE OSRM (plus de Mapbox Directions) export function useMap (deps) { const { @@ -374,13 +375,10 @@ export function useMap (deps) { } if (points.length < 2) { setCache([], null); return } try { - const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${points.join(';')}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}` - const r = await fetch(url) - if (!r.ok) throw new Error(`HTTP ${r.status}`) - const data = await r.json() - if (data.routes?.[0]) setCache(data.routes[0].legs.map(l => Math.round(l.duration / 60)), data.routes[0].geometry.coordinates) + const d = await roster.osrmRoute(points.map(p => p.split(',').map(Number))) // NOTRE OSRM (via hub) : geometry + legs (min par arrêt) + if (d && Array.isArray(d.geometry) && d.geometry.length) setCache((d.legs || []).map(l => l.mins), d.geometry) else setCache([], null) - } catch (e) { console.warn('[route] fetch error', e); setCache([], null) } + } catch (e) { console.warn('[route] osrm error', e); setCache([], null) } } // ── Draw route ─────────────────────────────────────────────────────────────── diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 9067f54..9f24492 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1298,6 +1298,11 @@
{{ jobDetail.subject }}
+ +
+ {{ initials(jobDetail.techName) }}{{ jobDetail.techName }} + Non assigné +
@@ -2066,10 +2071,13 @@ function exportGpx (techId) { } // ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ── const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null }) +// Décode les entités HTML (« d'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés). +const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null +function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value } async function openJobDetail (b, t) { if (!b) return jdShowTrack.value = false - Object.assign(jobDetail, { open: true, name: b.name || '', subject: b.subject || b.name || 'Job', customer: b.customer || '', address: b.address || '', skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: b.detail || '', lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null }) + Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(b.detail || ''), lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null }) if (b.legacy_id) { try { jobDetail.thread = await roster.ticketThread(b.legacy_id) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } } if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } } } @@ -2161,6 +2169,25 @@ const kbCanTrack = computed(() => { const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10) return iso === n || iso === yest }) +// Autres jobs (pool + occupation) À PROXIMITÉ (~3 km) du job affiché → points PÂLES sur la carte miniature pour le contexte. +function jdNearby () { + const la = jobDetail.lat, lo = jobDetail.lon + if (!isFinite(la) || !isFinite(lo)) return [] + const out = []; const seen = new Set() + const add = (lat, lon, subject, name) => { + lat = +lat; lon = +lon + if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) < 0.01) return + if (name) { if (seen.has(name)) return; seen.add(name) } + if (name && name === jobDetail.name) return + if (Math.abs(lat - la) < 1e-5 && Math.abs(lon - lo) < 1e-5) return // même point que le job affiché + if (haversineKm(la, lo, lat, lon) > 3) return + out.push([lon, lat, subject || '']) + } + for (const j of (assignPanel.jobs || [])) add(j.latitude, j.longitude, j.subject, j.name) + const occ = occByTechDay.value || {} + for (const k in occ) for (const j of ((occ[k] || {}).jobs || [])) if (!j.cancelled) add(j.lat, j.lon, j.subject, j.name) + return out.slice(0, 80) +} async function initJdMap () { if (!MAPBOX_TOKEN || !jdMapEl.value) return const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return @@ -2170,7 +2197,15 @@ async function initJdMap () { _jdMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') _jdMap.on('load', () => { _jdMap.resize() - if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job + // Autres jobs à proximité = points PÂLES (contexte), ajoutés AVANT le marqueur du job → celui-ci reste au-dessus. + if (hasLL) { + _jdMap.addSource('jd-near', { type: 'geojson', data: { type: 'FeatureCollection', features: jdNearby().map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p[0], p[1]] }, properties: { t: p[2] } })) } }) + _jdMap.addLayer({ id: 'jd-near', type: 'circle', source: 'jd-near', paint: { 'circle-radius': 5, 'circle-color': '#94a3b8', 'circle-opacity': 0.45, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 } }) + _jdMap.on('mouseenter', 'jd-near', () => { _jdMap.getCanvas().style.cursor = 'pointer' }) + _jdMap.on('mouseleave', 'jd-near', () => { _jdMap.getCanvas().style.cursor = '' }) + _jdMap.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('
' + String(p.t || '').replace(/[<>&]/g, '') + '
').addTo(_jdMap) }) + } + if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job (au-dessus des points pâles) _jdMap.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) _jdMap.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } }) _jdMap.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } }) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index a0aea9f..4dbb627 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -1132,7 +1132,9 @@ async function handle (req, res, method, path, url) { 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') + // continue_straight=false : autorise le demi-tour aux waypoints intermédiaires. Sinon OSRM INTERDIT le U-turn à l'arrêt + // → il DÉPASSE l'adresse et tourne dans une rue pour revenir (bug « s'éloigne et tourne » signalé sur la job d'Anthony). + const j = await osrmGet('/route/v1/driving/' + q + '?overview=full&geometries=geojson&steps=false&continue_straight=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, { @@ -1142,6 +1144,15 @@ async function handle (req, res, method, path, url) { }) } catch (e) { return json(res, 502, { error: 'osrm: ' + (e && e.message) }) } } + if (path === '/roster/osrm-trip' && method === 'POST') { // TSP OSRM (remplace Mapbox optimized-trips) : ordre de visite optimal depuis le 1er point + const b = await parseBody(req); const q = osrmPts(b, 12) + if (!q) return json(res, 400, { error: 'points [[lon,lat],…] requis (2-12)' }) + try { + const j = await osrmGet('/trip/v1/driving/' + q + '?source=first&roundtrip=false&destination=last&overview=false') + if (j.code !== 'Ok') throw new Error('code ' + (j.code || '?')) + return json(res, 200, { code: 'Ok', waypoints: (j.waypoints || []).map(w => ({ waypoint_index: w.waypoint_index })) }) // même forme que Mapbox optimized-trips + } catch (e) { return json(res, 502, { code: 'Error', 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-plan' && method === 'POST') { // P4 — orchestration COMPLÈTE hub (pool+quarts+occupation+règles d'adresse) → plan prêt pour la revue / cron auto-plan