gigafibre-fsm/services/targo-hub/lib/geofence.js
louispaulb 77e4411ba2 feat(geofence): réconciliation véhicule — assistant / mauvaise affectation (suggestions)
Niveau 2 de Request 1 : à partir des traces GPS, détecte QUELS véhicules ont
séjourné sur un job, puis SUGGÈRE (jamais auto) :
- ASSISTANT : le véhicule du tech assigné était là ET un autre a séjourné → propose
  d'ajouter le tech de cet autre véhicule comme assistant.
- MAUVAISE AFFECTATION : le véhicule du tech assigné n'a PAS été vu mais un autre a
  fait la visite → le tech a pris un autre camion (mapping non mis à jour).
Garde-fous : séjour (pas passage), fenêtre du jour, garde MÊME-ADRESSE (un véhicule
présent pour SON propre job co-localisé n'est pas un assistant). Cache traces/jour + 1 h/job.

geofence.js : onsiteReview(job) + dismissOnsite ; roster.js : GET /roster/job/:name/onsite
+ POST .../onsite-dismiss ; api/roster : jobOnsite/onsiteDismiss ; GeofenceTimeline : bloc
suggestions avec [Ajouter comme assistant] (addAssistant) / [Assigner ce véhicule au tech]
(setTechTraccarDevice, si device non mappé) / [Ignorer] (dismiss) — confirmées, non destructives.
Mauvaise-affectation vers le véhicule d'UN AUTRE tech = ambigu → pas d'auto-bouton (flag + Ignorer).

Vérifié live : 45 devices ; LEG-255021 → « 37 - Anthony » = tech assigné, 0 suggestion (OK) ;
scan du jour : 31 jobs, 11 avec véhicule sur place, 3 mauvaises affectations détectées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:28:40 -04:00

274 lines
19 KiB
JavaScript

'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
// Noms de device (pour horodater QUEL véhicule a produit l'événement — l'affectation peut changer demain).
let devNames = {}
try { const devs = await traccar.getDevices(); for (const d of (devs || [])) devNames[parseInt(d.id)] = d.name || '' } catch (e) { /* Traccar devices injoignables → nom vide */ }
const out = {}
for (const t of withDev) { const did = parseInt(t.traccar_device_id); const p = byDev[did]; 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, deviceId: did, deviceName: devNames[did] || '' } }
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)
const dev = { deviceId: p.deviceId, deviceName: p.deviceName } // véhicule qui a produit CE fix → figé sur l'événement (survit à un changement d'affectation)
if (rec.state === 'on_site') {
if (dist > EXIT_M) { rec.state = 'departed'; rec.events.push({ status: 'departed', at: now, dist: Math.round(dist), ...dev }); 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), ...dev }); rec.device = dev; 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), ...dev }); 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) }
let devNames = {}
try { const devs = await traccar.getDevices(); for (const dd of (devs || [])) devNames[parseInt(dd.id)] = dd.name || '' } catch (e) { /* noms indisponibles */ }
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 devInfo = { deviceId: parseInt(dev), deviceName: devNames[parseInt(dev)] || '' } // véhicule RÉELLEMENT utilisé ce jour-là (rejoué depuis sa trace) — figé sur l'événement
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', ...devInfo })
rec.device = devInfo
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', ...devInfo })
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.
// ═══ RÉCONCILIATION VÉHICULE (Request 1 « niveau 2 ») ═══
// À partir des traces GPS, on regarde QUELS véhicules ont RÉELLEMENT séjourné sur un job, puis on SUGGÈRE (jamais
// d'auto-application) : (a) ASSISTANT — le véhicule du tech assigné était là ET un autre véhicule a séjourné aussi ;
// (b) MAUVAISE AFFECTATION — le véhicule du tech assigné n'a PAS été vu mais un autre a fait toute la visite (le tech
// a pris un autre camion et on a oublié de mettre à jour). Garde-fous : séjour (pas de passage), fenêtre du jour,
// et garde MÊME-ADRESSE (un véhicule présent pour SON PROPRE job co-localisé n'est PAS un assistant).
let _dayTracks = { date: null, at: 0, tracks: null }
async function dayTracks (dateISO, devices) {
if (_dayTracks.date === dateISO && _dayTracks.tracks && (Date.now() - _dayTracks.at) < 600000) return _dayTracks.tracks
const { from, to } = dayBounds(dateISO)
const tracks = {}
for (const dev of (devices || [])) {
try { const pts = await traccar.getHistory(dev.id, from, to); tracks[parseInt(dev.id)] = (pts || []).filter(p => p.latitude != null).sort((a, b) => Date.parse(a.fixTime || a.deviceTime) - Date.parse(b.fixTime || b.deviceTime)) }
catch (e) { tracks[parseInt(dev.id)] = [] }
}
_dayTracks = { date: dateISO, at: Date.now(), tracks }
return tracks
}
async function deviceTechMap () {
const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'traccar_device_id'], limit: 300 })
const byDev = {}; const devByTech = {}
for (const t of (techs || [])) { const d = parseInt(t.traccar_device_id); if (d) { byDev[d] = { techId: t.technician_id || t.name, techName: t.full_name || t.name, docname: t.name }; if (t.technician_id) devByTech[t.technician_id] = d; devByTech[t.name] = d } }
return { byDev, devByTech }
}
// Analyse d'un job : véhicules réellement sur place + suggestions (assistant / mauvaise affectation). Résultat mis en cache 1 h.
async function onsiteReview (jobName, { rescan = false } = {}) {
const rec = store()[jobName] || { state: 'assigned', events: [] }
if (!rescan && rec.onsiteScanAt && (Date.now() - rec.onsiteScanAt) < 3600000) return { onsiteDevices: rec.onsiteDevices || [], suggestions: (rec.suggestions || []).filter(s => !(rec.onsiteDismissed || []).includes(s.deviceId)) }
const rows = await erp.list('Dispatch Job', { filters: [['name', '=', jobName]], fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'scheduled_date'], limit: 1 })
const j = rows && rows[0]
if (!j || j.latitude == null || Math.abs(+j.latitude) < 0.01) return { onsiteDevices: [], suggestions: [] }
const dateISO = String(j.scheduled_date || '').slice(0, 10) || todayISO()
const jLat = +j.latitude, jLon = +j.longitude
const devices = await traccar.getDevices().catch(() => [])
const { byDev, devByTech } = await deviceTechMap()
const tracks = await dayTracks(dateISO, devices)
const dayJobs = (await erp.list('Dispatch Job', { filters: [['scheduled_date', '=', dateISO], ['status', 'in', ['open', 'assigned', 'On Hold']]], fields: ['name', 'assigned_tech', 'latitude', 'longitude'], limit: 400 }).catch(() => [])).filter(x => x.latitude != null && Math.abs(+x.latitude) > 0.01)
const onsite = []
for (const dev of (devices || [])) {
const did = parseInt(dev.id); const pts = tracks[did] || []
if (!pts.length) continue
const r = detectArrivalDeparture(pts, jLat, jLon)
if (!r.arrivedAt) continue
const tm = byDev[did] || {}
onsite.push({ deviceId: did, deviceName: dev.name || '', techId: tm.techId || null, techName: tm.techName || null, techDoc: tm.docname || null, arrivedAt: r.arrivedAt, departedAt: r.departedAt || null, dwellMin: r.departedAt ? Math.round((Date.parse(r.departedAt) - Date.parse(r.arrivedAt)) / 60000) : null })
}
const assignedDeviceId = devByTech[j.assigned_tech] != null ? devByTech[j.assigned_tech] : null
const assignedPresent = assignedDeviceId != null && onsite.some(o => o.deviceId === assignedDeviceId)
const dismissed = rec.onsiteDismissed || []
const suggestions = []
for (const o of onsite) {
if (assignedDeviceId != null && o.deviceId === assignedDeviceId) continue // véhicule attendu = OK
if (dismissed.includes(o.deviceId)) continue
if (o.techId) { // garde MÊME-ADRESSE : ce tech a-t-il SON propre job co-localisé (<150 m) ce jour ? → pas un assistant
const own = dayJobs.some(jj => jj.name !== jobName && (jj.assigned_tech === o.techId || jj.assigned_tech === o.techDoc) && haversineM(jLat, jLon, +jj.latitude, +jj.longitude) < 150)
if (own) continue
}
if (assignedPresent) {
suggestions.push({ type: o.techId ? 'assistant' : 'unmapped_present', deviceId: o.deviceId, deviceName: o.deviceName, techId: o.techId, techName: o.techName, techDoc: o.techDoc, dwellMin: o.dwellMin, note: o.techId ? `${o.techName} sur place${o.dwellMin != null ? ' · ' + o.dwellMin + ' min' : ''} — assistant ?` : `Véhicule non enregistré « ${o.deviceName} » sur place — à rattacher à un tech ?` })
} else {
suggestions.push({ type: 'misassignment', deviceId: o.deviceId, deviceName: o.deviceName, techId: o.techId, techName: o.techName, techDoc: o.techDoc, dwellMin: o.dwellMin, assignedTech: j.assigned_tech, note: `Véhicule du tech assigné non détecté ; « ${o.deviceName} »${o.techName ? ' (de ' + o.techName + ')' : ''} sur place${o.dwellMin != null ? ' · ' + o.dwellMin + ' min' : ''} — mauvais véhicule ?` })
}
}
rec.onsiteDevices = onsite; rec.suggestions = suggestions; rec.onsiteScanAt = Date.now()
store()[jobName] = rec; persist()
return { onsiteDevices: onsite, suggestions, assignedPresent, assignedDeviceId }
}
function dismissOnsite (jobName, deviceId) {
const rec = store()[jobName]; if (!rec) return { ok: false }
const id = parseInt(deviceId)
rec.onsiteDismissed = [...new Set([...(rec.onsiteDismissed || []), id])]
rec.suggestions = (rec.suggestions || []).filter(s => s.deviceId !== id)
persist(); return { ok: true }
}
function timeline (jobName) { const e = store()[jobName]; return e ? { state: e.state, events: (e.events || []), device: e.device || null } : { state: null, events: [], device: null } }
// É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, onsiteReview, dismissOnsite }