diff --git a/apps/ops/src/config/erpnext.js b/apps/ops/src/config/erpnext.js index 4e013f0..65d5799 100644 --- a/apps/ops/src/config/erpnext.js +++ b/apps/ops/src/config/erpnext.js @@ -7,7 +7,9 @@ export const BASE_URL = viteBase === '/' ? '' : viteBase.replace(/\/$/, '') // Direct link to ERPNext desk (for admin actions like user management) export const ERP_DESK_URL = 'https://erp.gigafibre.ca' -export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg' +// DÉPRÉCIÉ : les cartes OPS utilisent MapLibre + tuiles OSM auto-hébergées (config/basemap.js), plus aucun Mapbox. +// Constante gardée vide pour compat des rares injections/destructurations résiduelles (aucune n'appelle Mapbox). +export const MAPBOX_TOKEN = '' export const TECH_COLORS = [ '#6366f1', // Indigo diff --git a/apps/ops/src/modules/dispatch/components/JobEditModal.vue b/apps/ops/src/modules/dispatch/components/JobEditModal.vue index df5d78d..7f1585b 100644 --- a/apps/ops/src/modules/dispatch/components/JobEditModal.vue +++ b/apps/ops/src/modules/dispatch/components/JobEditModal.vue @@ -71,8 +71,10 @@ function close () { emit('update:modelValue', null); emit('cancel') }
- Carte + + + 📍 {{ (+modelValue.latitude).toFixed(5) }}, {{ (+modelValue.longitude).toFixed(5) }} — voir sur la carte +
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 5e170c5..5daa53f 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -941,12 +941,11 @@ function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET). function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } } function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) } function fmtDistM (m) { return m < 1000 ? (m + ' m') : ((m / 1000).toFixed(m < 10000 ? 1 : 0) + ' km') } -// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app. -const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg' +// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Carte = MapLibre + tuiles OSM (aucun jeton Mapbox). function techFieldSign (name) { return fieldSign('T:' + name) } function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null } function serveFieldApp (res) { - try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') } + try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') } } function savePhoto (jobName, dataUrl) { const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null @@ -1027,6 +1026,42 @@ async function handleFieldTech (req, res, method, path, url) { const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '', notes: j.notes || '', skill: j.required_skill || '', dur: j.duration_h || null })) return json(res, 200, { ok: true, tech, jobs }) } + // MEILLEUR TRAJET du jour : ordre optimal des arrêts via NOTRE OSRM (OSM, sans trafic). La navigation réelle + // (trafic en direct) est laissée au GPS du téléphone (Google Maps multi-arrêts) → zéro Mapbox. `from`=lat,lon = position courante. + if (path === '/field/route' && method === 'GET') { + const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' }) + const today = todayET() + const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', '=', today]], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'start_time', 'actual_end', 'status'], orderBy: 'start_time asc', limit: 60 }) + let stops = (rows || []) + .filter(j => j.latitude != null && Math.abs(+j.latitude) > 0.01 && !j.actual_end && j.status !== 'Completed' && j.status !== 'Cancelled') + .map(j => ({ name: j.name, subject: j.subject || '', address: j.address || '', lat: +j.latitude, lon: +j.longitude })) + let ordered = false + const from = url.searchParams.get('from') // "lat,lon" position courante (optionnel) + if (stops.length >= 2) { + try { + // Ordre optimal via la MATRICE OSRM /table (le plugin /trip n'est pas compilé sur notre OSRM) + plus-proche-voisin + // depuis l'origine. Heuristique TSP suffisante pour une tournée de tech ; le trafic réel = géré par la nav du téléphone. + const coords = [] + const hasFrom = from && /^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(from) + if (hasFrom) { const [la, lo] = from.split(','); coords.push([+lo, +la]) } // origine (position courante) en index 0 + stops.forEach(s => coords.push([s.lon, s.lat])) + const coordStr = coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';') + const j = await osrmGet('/table/v1/driving/' + coordStr + '?annotations=duration') + if (j && j.code === 'Ok' && Array.isArray(j.durations) && j.durations.length === coords.length) { + const D = j.durations; const n = coords.length; const visited = new Array(n).fill(false) + visited[0] = true; let cur = 0; const tour = [0] // départ = origine si fournie, sinon 1er arrêt (ordre horaire) + for (let step = 1; step < n; step++) { + let best = -1; let bestD = Infinity + for (let k = 0; k < n; k++) { if (visited[k]) continue; const dd = (D[cur] && D[cur][k] != null) ? D[cur][k] : Infinity; if (dd < bestD) { bestD = dd; best = k } } + if (best < 0) break; visited[best] = true; tour.push(best); cur = best + } + const seq = tour.map(idx => hasFrom ? (idx >= 1 ? stops[idx - 1] : null) : stops[idx]).filter(Boolean) + if (seq.length === stops.length) { stops = seq; ordered = true } + } + } catch (e) { /* OSRM indispo → ordre horaire conservé */ } + } + return json(res, 200, { ok: true, ordered, count: stops.length, stops }) + } if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job (+ géoloc → confirme la présence au bon endroit) const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' }) const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' }) diff --git a/services/targo-hub/public/field-app.html b/services/targo-hub/public/field-app.html index 2b8ac9a..60b3320 100644 --- a/services/targo-hub/public/field-app.html +++ b/services/targo-hub/public/field-app.html @@ -1,8 +1,9 @@ Targo Tech - - + + +