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 }}
{{ jobDetail.customer }} · Ticket #{{ jobDetail.lid }} · {{ jobDetail.dept }}
+
+
+ {{ 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