Le hub compare la position GPS live de chaque tech (Traccar) aux jobs du jour → détecte les transitions et les journalise par job. Le statut « live » du job est DÉRIVÉ du journal (compute-on-read, aucune écriture ERPNext — pas de valeur « en cours » côté doctype). Fiche job : timeline horizontale Assigné → En route → Arrivé (HH:MM) → Reparti (HH:MM). - lib/geofence.js : runScan() (rayon 150 m, sortie 230 m hystérésis, séjour 3 min), store data/geofence.json par job, machine à états ; startScan() scheduler 90 s (GEOFENCE_SCAN=off pour couper). Vérifié prod : 12 jobs × 14 positions, transitions OK. - server.js : démarre le scan au boot. - roster.js : GET /roster/job/:name/geofence (timeline) + /geofence-states + /geofence-scan (test). - api/roster.js : jobGeofence(), geofenceStates(). - PlanificationPage : openJobDetail charge la timeline ; stepper .jd-geo (scoped). Suite possible : écrire le vrai statut ERPNext à l'arrivée (nécessite d'ajouter une valeur « En cours » au doctype Dispatch Job) — non fait pour éviter le risque schéma. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
6.3 KiB
JavaScript
103 lines
6.3 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')
|
|
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 }
|