diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index da0b709..76fb20b 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -184,6 +184,10 @@ export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days) export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech)) // Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }. export const techPositions = () => jget('/roster/tech-positions') +// Géofencing : timeline d'un job → { state, events:[{status,at,dist}] } (status: en_route|on_site|departed). +export const jobGeofence = (name) => jget('/roster/job/' + encodeURIComponent(name) + '/geofence') +// Géofencing : états live pour une liste de jobs → { states: { jobName: state } }. +export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' + encodeURIComponent((names || []).join(','))) // Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }. export const listTraccarDevices = () => jget('/traccar/devices') // Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif. diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index d29f148..f7f68f2 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1343,6 +1343,16 @@
{{ jobDetail.skill }}
{{ jobDetail.address }}
+ +
+
Suivi terrain (GPS)
+
+
+
+
{{ s.label }}{{ s.at }}
+
+
+
Équipe / renfort
@@ -2130,7 +2140,7 @@ function exportGpx (techId) { window.open(roster.gpxUrl(techId, from, to), '_blank') } // ── 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 }) +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, geofence: 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 } @@ -2138,9 +2148,21 @@ async function openJobDetail (b, t) { if (!b) return jdShowTrack.value = false 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 }) + // Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant. + jobDetail.geofence = null + if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {}) 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 } } } +// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte. +const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }] +const geoTimeline = computed(() => { + const gf = jobDetail.geofence; const events = (gf && gf.events) || [] + const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null } + const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 } + const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0 + return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx })) +}) // Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe. const jdTeamOptions = computed(() => { const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)]); return (techs.value || []).filter(t => !taken.has(t.id)).map(t => ({ label: t.name, value: { id: t.id, name: t.name } })) }) async function jdAddAssistant () { @@ -5720,6 +5742,19 @@ tr.res-hidden .hide-eye { opacity: 1; } .jd-meta > div { display: flex; align-items: center; gap: 6px; } .jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; } .jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; } +/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */ +.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; } +.jd-geo-track { display: flex; align-items: flex-start; } +.jd-geo-step { flex: 1; text-align: center; position: relative; } +.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; } +.jd-geo-step:first-child::before { display: none; } +.jd-geo-step.done::before { background: #26a69a; } +.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; } +.jd-geo-step.done .jd-geo-ic { background: #26a69a; } +.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); } +.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; } +.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; } +.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; } .jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; } .jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; } .dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; } diff --git a/services/targo-hub/lib/geofence.js b/services/targo-hub/lib/geofence.js new file mode 100644 index 0000000..01036b8 --- /dev/null +++ b/services/targo-hub/lib/geofence.js @@ -0,0 +1,102 @@ +'use strict' +// Géofencing des jobs (rec C) : compare la position GPS live de chaque tech (Traccar) à la localisation de SES jobs +// du jour → détecte les transitions EN ROUTE → ARRIVÉ (sur place) → REPARTI, et les journalise sur le job (timeline +// façon « suivi de colis »). Le statut « live » du job est DÉRIVÉ de ce journal (compute-on-read, aucune écriture ERPNext). +// Persistance : data/geofence.json (par job). Scheduler doux (~90 s) tant que le hub tourne. +const fs = require('fs') +const path = require('path') +const { log } = require('./helpers') +const erp = require('./erp') +const traccar = require('./traccar') + +const STORE = path.join(__dirname, '..', 'data', 'geofence.json') +const RADIUS_M = Number(process.env.GEOFENCE_RADIUS_M) || 150 // entrer à < 150 m = candidat « sur place » +const EXIT_M = Number(process.env.GEOFENCE_EXIT_M) || 230 // sortir à > 230 m (hystérésis anti-jitter GPS) +const DWELL_MIN = Number(process.env.GEOFENCE_DWELL_MIN) || 3 // rester ≥ 3 min dans le rayon → « Arrivé » +const SCAN_SEC = Number(process.env.GEOFENCE_SCAN_SEC) || 90 + +let _store = null +function store () { if (_store) return _store; try { _store = JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch { _store = {} } return _store } +function persist () { try { fs.writeFileSync(STORE, JSON.stringify(store())) } catch (e) { log('geofence persist err: ' + e.message) } } + +function haversineM (aLat, aLon, bLat, bLon) { + const R = 6371000, toRad = d => d * Math.PI / 180 + const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon) + const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2 + return 2 * R * Math.asin(Math.sqrt(s)) +} +const todayISO = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) + +// Jobs assignés AUJOURD'HUI, avec tech + coordonnées valides. +async function todayAssignedJobs () { + const rows = await erp.list('Dispatch Job', { + filters: [['scheduled_date', '=', todayISO()], ['status', 'in', ['open', 'assigned', 'On Hold']]], + fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'subject', 'customer_name', 'status'], + limit: 400, + }) + return (rows || []).filter(j => j.assigned_tech && j.latitude != null && j.longitude != null && + isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01) +} + +// Position GPS live par technician_id (appareil Traccar associé). +async function techPositionsById () { + const techs = await erp.list('Dispatch Technician', { + filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'traccar_device_id'], limit: 200, + }) + const withDev = (techs || []).filter(t => t.traccar_device_id) + const ids = [...new Set(withDev.map(t => parseInt(t.traccar_device_id)).filter(Boolean))] + if (!ids.length) return {} + let positions = [] + try { positions = await traccar.getPositions(ids) } catch (e) { return {} } + const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p + const out = {} + for (const t of withDev) { const p = byDev[parseInt(t.traccar_device_id)]; if (p && p.latitude != null) out[t.technician_id] = { lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 } } + return out +} + +// UN passage : met à jour la timeline de chaque job selon la distance tech↔job. Idempotent (n'ajoute un événement qu'à un CHANGEMENT d'état). +async function runScan ({ dryRun = false } = {}) { + let jobs, pos + try { [jobs, pos] = await Promise.all([todayAssignedJobs(), techPositionsById()]) } + catch (e) { return { ok: false, error: e.message } } + const s = store(); const now = new Date().toISOString(); const nowMs = Date.parse(now) + let arrived = 0, departed = 0, enroute = 0; const changes = [] + for (const j of jobs) { + const rec = s[j.name] || { state: 'assigned', events: [] } + const p = pos[j.assigned_tech] + if (p) { + const dist = haversineM(p.lat, p.lon, +j.latitude, +j.longitude) + if (rec.state === 'on_site') { + if (dist > EXIT_M) { rec.state = 'departed'; rec.events.push({ status: 'departed', at: now, dist: Math.round(dist) }); departed++; changes.push({ job: j.name, to: 'departed' }) } + } else if (rec.state !== 'departed') { // assigned / en_route → détecte l'arrivée (séjour) et le départ vers le site + if (dist <= RADIUS_M) { + if (!rec.enterMs) rec.enterMs = nowMs + if ((nowMs - rec.enterMs) / 60000 >= DWELL_MIN) { rec.state = 'on_site'; rec.enterMs = null; rec.events.push({ status: 'on_site', at: now, dist: Math.round(dist) }); arrived++; changes.push({ job: j.name, to: 'on_site' }) } + } else { + rec.enterMs = null + if (rec.state === 'assigned' && (p.speed > 3 || dist < 3000)) { rec.state = 'en_route'; rec.events.push({ status: 'en_route', at: now, dist: Math.round(dist) }); enroute++ } + } + } + } + rec.tech = j.assigned_tech; rec.subject = j.subject || ''; s[j.name] = rec + } + if (!dryRun && (arrived || departed || enroute)) persist() + return { ok: true, dryRun, jobs: jobs.length, positions: Object.keys(pos).length, arrived, departed, enroute, changes } +} + +// Timeline d'un job (UI) : { state, events:[{status,at,dist}] }. state ∈ null|assigned|en_route|on_site|departed. +function timeline (jobName) { const e = store()[jobName]; return e ? { state: e.state, events: (e.events || []) } : { state: null, events: [] } } +// États live pour une LISTE de jobs (badges sur le board/carte) : { jobName: state }. +function statesFor (names) { const s = store(); const out = {}; for (const n of (names || [])) { const e = s[n]; if (e) out[n] = e.state } return out } + +let _timer = null +function startScan () { + if (String(process.env.GEOFENCE_SCAN || 'on').toLowerCase() === 'off') { log('geofence scan DÉSACTIVÉ (GEOFENCE_SCAN=off)'); return } + if (_timer) return + const tick = () => runScan().then(r => { if (r && (r.arrived || r.departed)) log(`geofence: +${r.arrived} arrivé, +${r.departed} reparti (${r.jobs} jobs, ${r.positions} pos)`) }).catch(e => log('geofence scan err: ' + e.message)) + _timer = setInterval(tick, SCAN_SEC * 1000) + setTimeout(tick, 8000) // amorçage après le boot + log(`geofence scan activé (${SCAN_SEC}s · rayon ${RADIUS_M}m · sortie ${EXIT_M}m · séjour ${DWELL_MIN}min)`) +} + +module.exports = { startScan, runScan, timeline, statesFor } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index b787405..ca90e26 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -1828,6 +1828,19 @@ async function handle (req, res, method, path, url) { } return json(res, 200, { positions: out }) } + // Géofencing : timeline d'un job (En route → Arrivé → Reparti, façon suivi de colis). + const mGf = path.match(/^\/roster\/job\/(.+)\/geofence$/) + if (mGf && method === 'GET') { return json(res, 200, require('./geofence').timeline(decodeURIComponent(mGf[1]))) } + // Géofencing : états live pour une LISTE de jobs (badges board/carte). ?jobs=a,b,c + if (path === '/roster/geofence-states' && method === 'GET') { + const u = new URL(req.url, 'http://localhost'); const names = (u.searchParams.get('jobs') || '').split(',').map(x => x.trim()).filter(Boolean) + return json(res, 200, { states: require('./geofence').statesFor(names) }) + } + // Géofencing : forcer un passage (diagnostic/test). ?dry=1 = aperçu sans écrire. + if (path === '/roster/geofence-scan' && method === 'GET') { + const u = new URL(req.url, 'http://localhost') + return json(res, 200, await require('./geofence').runScan({ dryRun: u.searchParams.get('dry') === '1' })) + } // Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif. // device_id = id numérique Traccar (ou '' pour dissocier). const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/) diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 27b203e..e6b7859 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -392,4 +392,7 @@ server.listen(cfg.PORT, '0.0.0.0', () => { // Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on try { require('./lib/campaigns').startGiftReclaimCron() } catch (e) { log('gift reclaim cron failed to start:', e.message) } + // Géofencing des jobs : compare GPS live des techs (Traccar) aux jobs du jour → timeline En route/Arrivé/Reparti. Désactivable GEOFENCE_SCAN=off + try { require('./lib/geofence').startScan() } + catch (e) { log('geofence scan failed to start:', e.message) } })