fix(geofence): arrivées manquées — passe TRACE rétrospective + dwell persisté + rayon 250m
Diagnostic (Nathan Morrisseau 2026-07-18) : 3 jobs où le tech était à 8-13m pendant 37-166 min sont restés « En route », jamais « Arrivé ». Cause : le scan live n'accroche « on_site » que si le hub tourne 3 min EN CONTINU avec le tech dans le rayon, et le compteur de séjour (enterMs) était en mémoire, persisté SEULEMENT à un changement d'état → tout redémarrage du hub le remettait à zéro. 18/07 = 0 arrivée pour TOUTE la flotte (seul jour), alors que « en route » (1 tick) s'accrochait. Fixes (les 3 demandés) : - FIX 1 dwell persisté : runScan marque le store 'dirty' quand enterMs change → survit aux redémarrages. - FIX 2 passe TRACE rétrospective : reconcileFromTrack(date) rejoue l'historique GPS Traccar complet du jour (traccar.getHistory ajouté) et DÉRIVE arrivée/départ → insensible aux redémarrages/ticks manqués/bursts. Planifiée /15 min sur le jour courant (scan live conservé pour la réactivité UI) + route GET /roster/geofence-reconcile?date=&dry=1 (backfill/diag). - FIX 3 rayon 150→250m (+ sortie 230→350m), aligné sur l'arrivée-auto de l'app terrain → couvre le géocodage rural imprécis. Vérifié live : backfill 18/07 → LEG-254929/255009/255042 = Arrivé+Reparti (source track), idempotent (re-run 0/0), today OK. Restent en_route : LEG-254770 (187m, séjour <3min) + LEG-255029 (579m, coords à corriger) = imprécision géocodage, pas géofence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9cb9739d3f
commit
16da087e18
|
|
@ -10,10 +10,13 @@ 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)
|
||||
// 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 }
|
||||
|
|
@ -60,7 +63,7 @@ async function runScan ({ dryRun = false } = {}) {
|
|||
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 = []
|
||||
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]
|
||||
|
|
@ -70,33 +73,118 @@ async function runScan ({ dryRun = false } = {}) {
|
|||
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 (!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 {
|
||||
rec.enterMs = null
|
||||
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)) persist()
|
||||
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
|
||||
log(`geofence scan activé (${SCAN_SEC}s · rayon ${RADIUS_M}m · sortie ${EXIT_M}m · séjour ${DWELL_MIN}min)`)
|
||||
// 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, timeline, statesFor }
|
||||
module.exports = { startScan, runScan, reconcileFromTrack, timeline, statesFor }
|
||||
|
|
|
|||
|
|
@ -2229,6 +2229,12 @@ async function handle (req, res, method, path, url) {
|
|||
const u = new URL(req.url, 'http://localhost')
|
||||
return json(res, 200, await require('./geofence').runScan({ dryRun: u.searchParams.get('dry') === '1' }))
|
||||
}
|
||||
// Géofencing : passe TRACE rétrospective (dérive Arrivé/Reparti de l'historique GPS complet) — corrige les arrivées
|
||||
// manquées par le scan live + BACKFILL d'une journée. ?date=YYYY-MM-DD (défaut aujourd'hui) · ?dry=1 = aperçu.
|
||||
if (path === '/roster/geofence-reconcile' && method === 'GET') {
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
return json(res, 200, await require('./geofence').reconcileFromTrack({ date: u.searchParams.get('date') || undefined, 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$/)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ async function getPositions (deviceIds) {
|
|||
return results.flatMap(r => r.status === 'fulfilled' ? r.value : [])
|
||||
}
|
||||
|
||||
// HISTORIQUE des positions d'un device sur une fenêtre (ISO from/to) — pour la détection d'arrivée « rétrospective »
|
||||
// (géofence basé sur la TRACE, robuste aux redémarrages/scans manqués), contrairement à getPositions (dernière position).
|
||||
async function getHistory (deviceId, fromISO, toISO) {
|
||||
if (!deviceId) return []
|
||||
return traccarFetch('/api/positions?deviceId=' + encodeURIComponent(deviceId) + '&from=' + encodeURIComponent(fromISO) + '&to=' + encodeURIComponent(toISO))
|
||||
}
|
||||
|
||||
// Distance en mètres (haversine).
|
||||
function haversineM (aLat, aLon, bLat, bLon) {
|
||||
const R = 6371000, toR = x => x * Math.PI / 180
|
||||
|
|
@ -236,4 +243,4 @@ async function handle (req, res, method, path) {
|
|||
json(res, 404, { error: 'Traccar endpoint not found' })
|
||||
}
|
||||
|
||||
module.exports = { handle, getDevices, getPositions, crossOnSite }
|
||||
module.exports = { handle, getDevices, getPositions, getHistory, crossOnSite }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user