fix(dispatch): OSRM for ALL routing + fix waypoint overshoot + detail dialog polish
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) <noreply@anthropic.com>
This commit is contained in:
parent
e92c220396
commit
fc1f61eecf
|
|
@ -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'), {})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1298,6 +1298,11 @@
|
|||
<div class="col" style="min-width:0">
|
||||
<div class="text-h6 ellipsis">{{ jobDetail.subject }}</div>
|
||||
<div class="text-caption text-grey-7"><template v-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
|
||||
<!-- QUI fait la job : chip bien visible (highlight demandé) — assigné ou non -->
|
||||
<div class="q-mt-xs">
|
||||
<q-chip v-if="jobDetail.techName" dense color="indigo-6" text-color="white" class="text-weight-bold jd-tech-chip"><q-avatar color="indigo-9" text-color="white" size="20px">{{ initials(jobDetail.techName) }}</q-avatar>{{ jobDetail.techName }}</q-chip>
|
||||
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
|
|
@ -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('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').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 } })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user