gigafibre-fsm/services/targo-hub/lib/traccar.js
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

240 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
// Prefer bearer token (revocable, no password in env); fall back to Basic auth for legacy.
const authHeader = cfg.TRACCAR_TOKEN
? 'Bearer ' + cfg.TRACCAR_TOKEN
: 'Basic ' + Buffer.from(cfg.TRACCAR_USER + ':' + cfg.TRACCAR_PASS).toString('base64')
async function traccarFetch (path) {
const res = await fetch(cfg.TRACCAR_URL + path, {
headers: { Authorization: authHeader, Accept: 'application/json' },
})
if (!res.ok) throw new Error(`Traccar ${res.status}: ${path}`)
return res.json()
}
// Variante RAW (texte) — pour GPX/KML (Accept différent, pas de parse JSON).
async function traccarRaw (path, accept) {
const res = await fetch(cfg.TRACCAR_URL + path, { headers: { Authorization: authHeader, Accept: accept } })
if (!res.ok) throw new Error(`Traccar ${res.status}: ${path}`)
return res.text()
}
// Résout l'appareil Traccar depuis ?device= OU ?tech= (technician_id puis docname Dispatch Technician).
async function deviceFor (url) {
let device = url.searchParams.get('device'); const tech = url.searchParams.get('tech')
if (!device && tech) {
let rows = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tech]], fields: ['traccar_device_id'], limit: 1 })
if (!rows.length) rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['traccar_device_id'], limit: 1 })
device = rows.length ? rows[0].traccar_device_id : null
}
return device
}
// Cache devices for 60s (they rarely change)
let _devCache = null, _devCacheTs = 0
async function getDevices () {
if (_devCache && Date.now() - _devCacheTs < 60000) return _devCache
_devCache = await traccarFetch('/api/devices?all=true')
_devCacheTs = Date.now()
return _devCache
}
async function getPositions (deviceIds) {
if (!deviceIds?.length) return []
const results = await Promise.allSettled(
deviceIds.map(id => traccarFetch('/api/positions?deviceId=' + id))
)
return results.flatMap(r => r.status === 'fulfilled' ? r.value : [])
}
// Distance en mètres (haversine).
function haversineM (aLat, aLon, bLat, bLon) {
const R = 6371000, toR = x => x * Math.PI / 180
const dLat = toR(bLat - aLat), dLon = toR(bLon - aLon)
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toR(aLat)) * Math.cos(toR(bLat)) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(s))
}
// ANCRE D'ACTIVATION SERVICE (GenieACS) : le 1er inform (`registered`) ou le dernier bootstrap (`lastBoot`) du modem,
// s'il tombe dans la fenêtre du jour, = le moment RÉEL où le service a été livré sur place. Sert à BORNER la « traîne »
// du GPS (camion resté stationné après le travail → faux +500 %). Résout customer → Service Equipment → serial →
// résumé device (cache si déjà `registered`, sinon fetch ACS). Renvoie {serial, anchor (ISO), kind} ou null.
async function modemAnchor (customer, dayFromMs, dayToMs) {
if (!customer) return null
const devices = require('./devices')
let eq = []
try { eq = await erp.list('Service Equipment', { filters: [['customer', '=', customer]], fields: ['serial_number'], limit: 3 }) } catch (e) { return null }
for (const e of (eq || [])) {
const sn = e.serial_number; if (!sn) continue
let sum = null
// le poller rapide ne stocke que lastInform → si `registered` absent du cache, on fetch le détail complet (ACS).
try { const c = devices.getCached(sn); sum = (c && c.summary && c.summary.registered) ? c.summary : await devices.fetchDeviceDetails(sn) } catch (_) { /* ACS indispo → on saute */ }
if (!sum) continue
for (const [t, kind] of [[sum.registered, 'registered'], [sum.lastBoot, 'boot']]) {
const ms = t ? Date.parse(t) : NaN
if (ms && ms >= dayFromMs && ms <= dayToMs) return { serial: sn, anchor: t, kind }
}
}
return null
}
// CROISEMENT positions Traccar × emplacement de job → fenêtre « sur site » (rec C). Pour les jobs assignés/localisés
// récents : on tire l'historique GPS du tech ce jour-là, on garde les points à ≤ radiusM du job, et la 1ère→dernière
// proximité = actual_start/end. dryRun=true (défaut) = APERÇU (n'écrit rien).
// - anchor=true (défaut) : enrichit chaque fenêtre avec l'ancre d'activation modem (clamped_end / clamped_minutes).
// - clamp=true : ÉCRIT la fin BORNÉE par l'ancre (corrige les outliers GPS-stationné) — réécrit même si déjà capté.
// Sinon (clamp=false), n'écrit que les jobs SANS temps déjà capté (idempotent), avec la fin GPS brute.
async function crossOnSite ({ days = 14, radiusM = 150, minMin = 10, dryRun = true, anchor = true, clamp = false, bufferMin = 30 } = {}) {
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)
const jobs = await erp.list('Dispatch Job', { filters: [['assigned_tech', '!=', ''], ['scheduled_date', '>', since]], fields: ['name', 'assigned_tech', 'customer', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end'], limit: 800 })
const techIds = [...new Set((jobs || []).map(j => j.assigned_tech).filter(Boolean))]
const devOf = {}
if (techIds.length) { const tr = await erp.list('Dispatch Technician', { filters: [['name', 'in', techIds]], fields: ['name', 'traccar_device_id'], limit: 500 }); for (const t of (tr || [])) if (t.traccar_device_id) devOf[t.name] = t.traccar_device_id }
const out = []; let noDevice = 0, noGps = 0, anchored = 0, clamped = 0
const fmt = s => new Date(s).toISOString().slice(0, 19).replace('T', ' ')
for (const j of (jobs || [])) {
if (!j.latitude || !j.longitude || Math.abs(j.latitude) < 1) continue
const dev = devOf[j.assigned_tech]; if (!dev) { noDevice++; continue }
const from = j.scheduled_date + 'T00:00:00-04:00'; const to = j.scheduled_date + 'T23:59:59-04:00'
let pts = []
try { pts = await traccarFetch(`/api/positions?deviceId=${encodeURIComponent(dev)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`) } catch (e) { noGps++; continue }
const times = (Array.isArray(pts) ? pts : []).filter(p => p.latitude != null && haversineM(j.latitude, j.longitude, p.latitude, p.longitude) <= radiusM).map(p => p.fixTime || p.deviceTime || p.serverTime).filter(Boolean).sort()
if (times.length < 2) continue
const start = times[0]; const end = times[times.length - 1]
const rawMins = Math.round((Date.parse(end) - Date.parse(start)) / 60000)
if (rawMins < minMin) continue
// Ancre d'activation : si le modem est venu en ligne durant la visite, borner la fin à activation + tampon de
// nettoyage/test (retire la traîne du GPS stationné). On garde toujours fin ≥ start + minMin.
let anc = null, clampedEnd = end, clampedMins = rawMins
if (anchor) {
anc = await modemAnchor(j.customer, Date.parse(from), Date.parse(to))
if (anc) {
anchored++
const cap = Date.parse(anc.anchor) + bufferMin * 60000
if (cap < Date.parse(end) && cap >= Date.parse(start) + minMin * 60000) {
clampedEnd = new Date(cap).toISOString(); clampedMins = Math.round((cap - Date.parse(start)) / 60000); clamped++
}
}
}
const already = !!(j.actual_start && String(j.actual_start).trim())
const writeEnd = clamp ? clampedEnd : end
out.push({ job: j.name, tech: j.assigned_tech, customer: j.customer || null, date: j.scheduled_date, onsite_start: start, onsite_end: end, minutes: rawMins, clamped_end: clampedEnd, clamped_minutes: clampedMins, service_anchor: anc ? anc.anchor : null, anchor_kind: anc ? anc.kind : null, points: times.length, already })
if (!dryRun && (clamp || !already)) { await erp.update('Dispatch Job', j.name, { actual_start: fmt(start), actual_end: fmt(writeEnd) }).catch(() => {}) }
}
return { ok: true, days, radius_m: radiusM, min_min: minMin, buffer_min: bufferMin, dryRun, clamp, candidates: (jobs || []).length, no_device: noDevice, gps_errors: noGps, anchored, clamped, matched: out.length, written: dryRun ? 0 : out.filter(o => clamp || !o.already).length, results: out.slice(0, 50) }
}
async function handle (req, res, method, path) {
const sub = path.replace('/traccar', '').replace(/^\//, '')
// Croisement temps réel sur site (rec C) : GET = aperçu (dryRun) ; POST = écrit actual_start/end.
// &anchor=0 désactive l'ancre GenieACS ; &clamp=1 (POST) écrit la fin BORNÉE par l'activation modem (corrige outliers) ;
// &buffer=<min> = tampon après activation (défaut 30).
if (sub === 'onsite' && (method === 'GET' || method === 'POST')) {
try {
const url = new URL(req.url, 'http://localhost')
const opts = {
days: Math.min(120, Math.max(1, Number(url.searchParams.get('days')) || 14)),
radiusM: Math.min(1000, Math.max(30, Number(url.searchParams.get('radius')) || 150)),
minMin: Math.max(1, Number(url.searchParams.get('min')) || 10),
bufferMin: Math.min(180, Math.max(0, Number(url.searchParams.get('buffer')) || 30)),
anchor: url.searchParams.get('anchor') !== '0',
clamp: url.searchParams.get('clamp') === '1',
dryRun: method === 'GET',
}
return json(res, 200, await crossOnSite(opts))
} catch (e) { log('Traccar onsite error:', e.message); return json(res, 200, { ok: false, error: e.message }) }
}
if (sub === 'devices' && method === 'GET') {
try {
return json(res, 200, await getDevices())
} catch (e) {
log('Traccar devices error:', e.message)
return json(res, 502, { error: 'Traccar unavailable' })
}
}
if (sub === 'positions' && method === 'GET') {
try {
const url = new URL(req.url, 'http://localhost')
const ids = (url.searchParams.get('deviceIds') || '').split(',').map(Number).filter(Boolean)
return json(res, 200, await getPositions(ids))
} catch (e) {
log('Traccar positions error:', e.message)
return json(res, 502, { error: 'Traccar unavailable' })
}
}
// Export GPX du parcours d'un tech sur une fenêtre (jour courant) — résout le device depuis Dispatch Technician.
if (sub === 'gpx' && method === 'GET') {
try {
const url = new URL(req.url, 'http://localhost')
const device = await deviceFor(url)
if (!device) return json(res, 404, { error: 'Aucun appareil GPS associé à ce technicien' })
const from = url.searchParams.get('from'); const to = url.searchParams.get('to')
if (!from || !to) return json(res, 400, { error: 'from + to (ISO 8601) requis' })
const gpx = await traccarRaw(`/api/positions?deviceId=${encodeURIComponent(device)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, 'application/gpx+xml')
res.writeHead(200, { 'Content-Type': 'application/gpx+xml; charset=utf-8', 'Content-Disposition': `attachment; filename="parcours-${device}-${String(from).slice(0, 10)}.gpx"` })
return res.end(gpx)
} catch (e) { log('Traccar gpx error:', e.message); return json(res, 502, { error: 'Traccar GPX indisponible' }) }
}
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → coords [lon,lat] (JSON léger) pour superposer sur la carte.
// IMPORTANT : exiger une plage EXACTE (1 jour) — sinon Traccar renvoie un volume énorme et lent.
if (sub === 'track' && method === 'GET') {
try {
const url = new URL(req.url, 'http://localhost')
const device = await deviceFor(url)
if (!device) return json(res, 404, { error: 'Aucun appareil GPS associé à ce technicien' })
const from = url.searchParams.get('from'); const to = url.searchParams.get('to')
if (!from || !to) return json(res, 400, { error: 'from + to (ISO 8601) requis' })
const pts = await traccarFetch(`/api/positions?deviceId=${encodeURIComponent(device)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
const arr = (Array.isArray(pts) ? pts : []).filter(p => p.latitude != null && p.longitude != null)
const coords = arr.map(p => [p.longitude, p.latitude])
// Distance + temps de CONDUITE réels : segments > 25 m ET < 30 min d'écart (ignore la gigue GPS à l'arrêt + les trous de signal).
let km = 0, movingMs = 0, firstT = null, lastT = null
const tOf = p => Date.parse(p.fixTime || p.deviceTime || p.serverTime || 0) || 0
for (let i = 0; i < arr.length; i++) {
const t = tOf(arr[i]); if (t) { if (firstT == null) firstT = t; lastT = t }
if (i > 0) {
const d = haversineM(arr[i - 1].latitude, arr[i - 1].longitude, arr[i].latitude, arr[i].longitude)
const dt = t - tOf(arr[i - 1])
if (d > 25 && dt > 0 && dt < 1800000) { km += d; movingMs += dt }
}
}
return json(res, 200, { device, count: coords.length, coords, km: Math.round(km / 100) / 10, moving_min: Math.round(movingMs / 60000), span_min: (firstT && lastT) ? Math.round((lastT - firstT) / 60000) : null })
} catch (e) { log('Traccar track error:', e.message); return json(res, 502, { error: 'Traccar track indisponible' }) }
}
// Position GPS LIVE (dernière connue) d'un tech → pour fixer son domicile/point de départ à sa position actuelle.
// ?tech=<id> (ou ?device=). Renvoie {ok, lat, lon, time, speed} ; ok:false si pas d'appareil associé / pas de fix.
if (sub === 'live' && method === 'GET') {
try {
const url = new URL(req.url, 'http://localhost')
const device = await deviceFor(url)
if (!device) return json(res, 200, { ok: false, error: 'Aucun appareil GPS associé à ce technicien' })
const pts = await traccarFetch('/api/positions?deviceId=' + encodeURIComponent(device))
const p = (Array.isArray(pts) ? pts : [])[0]
if (!p || p.latitude == null) return json(res, 200, { ok: false, error: 'Aucune position GPS récente' })
return json(res, 200, { ok: true, device, lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed })
} catch (e) { log('Traccar live error:', e.message); return json(res, 200, { ok: false, error: e.message }) }
}
// Proxy any other Traccar API path (fallback)
if (method === 'GET') {
try {
return json(res, 200, await traccarFetch('/api/' + sub))
} catch (e) {
return json(res, 502, { error: e.message })
}
}
json(res, 404, { error: 'Traccar endpoint not found' })
}
module.exports = { handle, getDevices, getPositions, crossOnSite }