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:
parent
2e6eb6dd01
commit
462fcef9d5
|
|
@ -100,6 +100,9 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
|
||||||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
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[], ... }
|
// 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)
|
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)
|
// 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 pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
|
||||||
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
|
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
</div>
|
||||||
</template>
|
</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 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.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>
|
<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>
|
<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="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="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</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.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>
|
<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 ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
||||||
|
|
@ -1117,6 +1123,8 @@
|
||||||
</q-btn>
|
</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>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1935,10 +1943,8 @@ async function fetchKbMatrix (techId) {
|
||||||
const use = pts.slice(0, 25) // Matrix = 25 coords max
|
const use = pts.slice(0, 25) // Matrix = 25 coords max
|
||||||
_kbMatPending.add(key)
|
_kbMatPending.add(key)
|
||||||
try {
|
try {
|
||||||
const coords = use.map(p => `${p.lon.toFixed(6)},${p.lat.toFixed(6)}`).join(';')
|
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 r = await fetch(`https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`)
|
const dur = d.durations || [], dist = d.distances || []; const map = {}
|
||||||
if (!r.ok) throw new Error('matrix ' + r.status)
|
|
||||||
const d = await r.json(); 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 } }
|
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)
|
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) }
|
} 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.
|
// 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 _routeCache = new Map()
|
||||||
const realRoutes = reactive({}) // techId → { km, mins } (la légende préfère ces valeurs réelles quand présentes)
|
const realRoutes = reactive({}) // techId → { km, mins } (la légende préfère ces valeurs réelles quand présentes)
|
||||||
async function loadRealRoutes (routes) {
|
// 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.
|
||||||
if (!MAPBOX_TOKEN || !routes.length || !_suggestMap) return
|
async function fetchTechRoute (home, stops) {
|
||||||
const results = await Promise.all(routes.map(async r => {
|
const pts = []; if (home) pts.push([+home.lon, +home.lat]); for (const s of stops) pts.push([+s.lon, +s.lat])
|
||||||
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 > 50) return null
|
||||||
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(';')
|
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) }
|
if (_routeCache.has(sig)) return _routeCache.get(sig)
|
||||||
try {
|
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 roster.osrmRoute(pts)
|
||||||
const d = await fetch(url).then(x => x.json()); const rt = d && d.routes && d.routes[0]; if (!rt) return null
|
if (!d || !Array.isArray(d.geometry)) return null
|
||||||
const info = { geometry: rt.geometry, km: Math.round(rt.distance / 100) / 10, mins: Math.round(rt.duration / 60) }
|
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins, legs: d.legs || [], hasHome: !!home }
|
||||||
_routeCache.set(sig, info); return { techId: r.techId, ...info }
|
_routeCache.set(sig, info); return info
|
||||||
} catch (e) { return null }
|
} catch (e) { return null }
|
||||||
|
}
|
||||||
|
async function loadRealRoutes (routes) {
|
||||||
|
if (!routes.length || !_suggestMap) return
|
||||||
|
const results = await Promise.all(routes.map(async r => {
|
||||||
|
const info = await fetchTechRoute(r.home, r.stops)
|
||||||
|
return info ? { techId: r.techId, ...info } : null
|
||||||
}))
|
}))
|
||||||
if (!_suggestMap || !_suggestMap.getSource('sr-line')) return
|
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 } } }
|
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 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 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
|
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 }
|
if (pts.length < 2) { 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}`
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
|
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 d = await r.json(); const dur = d.durations || [], dist = d.distances || []
|
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
|
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
|
||||||
const map = {}
|
const map = {}
|
||||||
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
|
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 || [])
|
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 }
|
if (ordered.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
|
||||||
try {
|
try {
|
||||||
const pts = ordered.slice(0, 25).map(s => `${(+s.lon).toFixed(6)},${(+s.lat).toFixed(6)}`).join(';')
|
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
|
||||||
// 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 src = _dayMap && _dayMap.getSource('day-route')
|
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) */ }
|
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
|
||||||
}
|
}
|
||||||
function destroyDayMap () {
|
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 ──
|
// ── 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 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)
|
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).
|
// 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)
|
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
|
||||||
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
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 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 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 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
|
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
|
let cand
|
||||||
if (j._reDate && winSet.has(j._reDate)) cand = [j._reDate] // date re-choisie (dans la fenêtre)
|
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 }
|
} 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.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
|
||||||
suggestDlg.mode = 'review'
|
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.
|
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs.
|
||||||
async function reoptimize () {
|
async function reoptimize () {
|
||||||
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
|
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
|
||||||
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
|
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
|
||||||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || ''
|
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.
|
// 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) {
|
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 { 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-toggle:hover { border-color: #94a3b8; }
|
||||||
.suggest-prev { opacity: 0.75; background: #f8fafc; border-radius: 6px; }
|
.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 { 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:hover { border-color: #6366f1; }
|
||||||
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
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
|
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))
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||||
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
|
// 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' })
|
if (!start) return json(res, 400, { error: 'start requis' })
|
||||||
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
|
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.
|
// 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.
|
// 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') {
|
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.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.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.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' })
|
if (!Object.keys(patch).length) return json(res, 400, { error: 'aucun champ modifiable' })
|
||||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
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
|
if (r.ok) invalidatePool() // priorité/statut/date/compétence/note changés → rafraîchir le cache du pool
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user