'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') // Rayon 250 m (aligné sur l'arrivée-auto de l'app terrain field-app.html) : 150 m était trop serré pour les adresses // RURALES géocodées à l'à-peu-près (Saint-Anicet/Franklin/Lac des Pins) → le tech au domicile restait > 150 m du point stocké. const RADIUS_M = Number(process.env.GEOFENCE_RADIUS_M) || 250 // entrer à < 250 m = candidat « sur place » const EXIT_M = Number(process.env.GEOFENCE_EXIT_M) || 350 // sortir à > 350 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 const RECONCILE_SEC = Number(process.env.GEOFENCE_RECONCILE_SEC) || 900 // passe TRACE (rétrospective) toutes les 15 min 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, dirty = false; 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; dirty = true } // FIX : le compteur de séjour est persisté → un redémarrage du hub ne remet plus l'arrivée à zéro 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 { if (rec.enterMs) { rec.enterMs = null; dirty = true } 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 || dirty)) persist() // dirty = un enterMs a changé → on le sauve pour survivre à un restart return { ok: true, dryRun, jobs: jobs.length, positions: Object.keys(pos).length, arrived, departed, enroute, changes } } // ── PASSE « TRACE » (rétrospective, robuste) ──────────────────────────────────────────────────────────── // Le scan live (runScan) ne voit que la DERNIÈRE position à chaque tick + accumule le séjour EN MÉMOIRE → fragile // (un redémarrage du hub ou un tick manqué casse le séjour de 3 min ⇒ « Arrivé » ne s'accroche jamais, même si le // tech est resté des heures sur place — cas Nathan 2026-07-18). Cette passe rejoue la TRACE COMPLÈTE du device sur // la journée (historique Traccar) et en DÉRIVE arrivée/départ → insensible aux redémarrages, ticks manqués, bursts. const ORD = { assigned: 0, en_route: 1, on_site: 2, departed: 3 } async function techDeviceMap () { const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['technician_id', 'traccar_device_id'], limit: 300 }) const m = {}; for (const t of (techs || [])) { const d = parseInt(t.traccar_device_id); if (d) m[t.technician_id] = d } return m } // Détecte la 1re arrivée (séjour ≥ DWELL_MIN dans le rayon) + le 1er départ (sortie > EXIT_M après) depuis une trace triée. function detectArrivalDeparture (pts, jLat, jLon) { let enterAt = null, arrivedAt = null, arrivedDist = null, departedAt = null, departedDist = null for (const p of pts) { if (p.latitude == null) continue const ms = Date.parse(p.fixTime || p.deviceTime || p.serverTime); if (!ms) continue const d = haversineM(p.latitude, p.longitude, jLat, jLon) if (!arrivedAt) { if (d <= RADIUS_M) { if (enterAt == null) { enterAt = ms; arrivedDist = Math.round(d) } if ((ms - enterAt) / 60000 >= DWELL_MIN) { arrivedAt = new Date(enterAt).toISOString() } // arrivée = 1er fix du séjour retenu } else if (d > RADIUS_M) { enterAt = null } // doit rester dans le rayon en continu pour accumuler } else if (!departedAt) { if (d > EXIT_M) { departedAt = new Date(ms).toISOString(); departedDist = Math.round(d) } } } return { arrivedAt, arrivedDist, departedAt, departedDist } } // Fenêtre UTC d'une journée locale (Québec EDT l'été = -04:00 ; borne ±1 h sans jamais rogner la journée de travail). function dayBounds (dateISO) { const from = new Date(dateISO + 'T00:00:00-04:00').toISOString() const to = new Date(Date.parse(dateISO + 'T00:00:00-04:00') + 24 * 3600 * 1000).toISOString() return { from, to } } async function reconcileFromTrack ({ date, dryRun = false } = {}) { const d = date || todayISO() const traccar = require('./traccar') let rows try { rows = await erp.list('Dispatch Job', { filters: [['scheduled_date', '=', d]], fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'subject', 'status'], limit: 400, }) } catch (e) { return { ok: false, error: e.message } } const jobs = (rows || []).filter(j => j.assigned_tech && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01) if (!jobs.length) return { ok: true, date: d, jobs: 0, arrived: 0, departed: 0 } const devMap = await techDeviceMap() const { from, to } = dayBounds(d) const byDev = {}; for (const j of jobs) { const dev = devMap[j.assigned_tech]; if (dev) (byDev[dev] = byDev[dev] || []).push(j) } const s = store(); let arrived = 0, departed = 0; const changes = [] for (const dev of Object.keys(byDev)) { let pts = [] try { pts = await traccar.getHistory(dev, from, to) } catch (e) { continue } pts = (pts || []).filter(p => p.latitude != null).sort((a, b) => Date.parse(a.fixTime || a.deviceTime) - Date.parse(b.fixTime || b.deviceTime)) if (!pts.length) continue for (const j of byDev[dev]) { const r = detectArrivalDeparture(pts, +j.latitude, +j.longitude) if (!r.arrivedAt) continue const rec = s[j.name] || { state: 'assigned', events: [] } rec.tech = j.assigned_tech; rec.subject = j.subject || '' const has = (st) => (rec.events || []).some(e => e.status === st) if (!has('on_site')) { rec.events.push({ status: 'on_site', at: r.arrivedAt, dist: r.arrivedDist, src: 'track' }) if ((ORD[rec.state] || 0) < ORD.on_site) rec.state = 'on_site' arrived++; changes.push({ job: j.name, to: 'on_site', at: r.arrivedAt }) } if (r.departedAt && !has('departed')) { rec.events.push({ status: 'departed', at: r.departedAt, dist: r.departedDist, src: 'track' }) rec.state = 'departed' departed++; changes.push({ job: j.name, to: 'departed', at: r.departedAt }) } s[j.name] = rec } } if (!dryRun && (arrived || departed)) persist() return { ok: true, date: d, dryRun, jobs: jobs.length, devices: Object.keys(byDev).length, arrived, departed, 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 let _rtimer = 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 // PASSE TRACE (rétrospective) sur les jobs du JOUR toutes les 15 min : rattrape ce que le scan live a manqué // (redémarrages, ticks manqués, bursts GPS). Le scan live reste pour la réactivité « En route/Arrivé » instantanée. const rtick = () => reconcileFromTrack({}).then(r => { if (r && (r.arrived || r.departed)) log(`geofence-track: +${r.arrived} arrivé, +${r.departed} reparti (rétrospectif, ${r.jobs} jobs)`) }).catch(e => log('geofence reconcile err: ' + e.message)) _rtimer = setInterval(rtick, RECONCILE_SEC * 1000) setTimeout(rtick, 30000) log(`geofence scan activé (${SCAN_SEC}s · rayon ${RADIUS_M}m · sortie ${EXIT_M}m · séjour ${DWELL_MIN}min · passe trace ${RECONCILE_SEC}s)`) } module.exports = { startScan, runScan, reconcileFromTrack, timeline, statesFor }