diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index d6e3384..f21531c 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -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'), {}) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index a72bf9a..319a4a4 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1080,13 +1080,19 @@ {{ Math.round((pj.dur || 0) * 10) / 10 }}h -
+
@@ -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 (domicile→arrê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 origine→1er 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; } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index a49bee1..4a382ef 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -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