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>
240 lines
14 KiB
JavaScript
240 lines
14 KiB
JavaScript
'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 }
|