From 877dfa5c441be892f6566f5688561a8bc95eb5cf Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 20 Jul 2026 12:57:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(geofence):=20fige=20le=20v=C3=A9hicule=20T?= =?UTF-8?q?raccar=20sur=20chaque=20=C3=A9v=C3=A9nement=20de=20passage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demande user : garder l'historique du véhicule au moment du hit géofence — rester exact même si le tech change de véhicule un autre jour. Les événements (en_route/on_site/departed) enregistrent désormais deviceId+deviceName ; rec.device = véhicule du passage. runScan (live) fige le device du fix courant ; reconcileFromTrack (rejeu trace) fige le device rejoué ce jour-là. timeline() renvoie device ; GeofenceTimeline affiche « Véhicule : … ». Vérifié : hub OK, timeline() expose device (null sur les 351 events antérieurs = normal ; capturé pour les prochains passages). Build spa OK, md5 match. Co-Authored-By: Claude Opus 4.8 --- .../modules/GeofenceTimeline.vue | 11 ++++++++++ services/targo-hub/lib/geofence.js | 22 +++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/ops/src/components/shared/detail-sections/modules/GeofenceTimeline.vue b/apps/ops/src/components/shared/detail-sections/modules/GeofenceTimeline.vue index 257b1c7..054d8e0 100644 --- a/apps/ops/src/components/shared/detail-sections/modules/GeofenceTimeline.vue +++ b/apps/ops/src/components/shared/detail-sections/modules/GeofenceTimeline.vue @@ -13,6 +13,8 @@
{{ s.label }}{{ s.at }}
+ +
Véhicule : {{ vehicle }}
@@ -43,6 +45,14 @@ const timeline = computed(() => { return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx })) }) +// Véhicule utilisé lors du passage : device figé sur le suivi (rec.device), sinon dernier événement portant un deviceName. +const vehicle = computed(() => { + const g = gf.value; if (!g) return '' + if (g.device && g.device.deviceName) return g.device.deviceName + const ev = [...((g && g.events) || [])].reverse().find(e => e.deviceName) + return (ev && ev.deviceName) || '' +}) + async function load () { if (props.geofence || !props.name) return try { loaded.value = await roster.jobGeofence(props.name) } catch (e) { loaded.value = null } @@ -64,4 +74,5 @@ onMounted(load) .jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; } .jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; } .jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; } +.jd-geo-veh { margin-top: 8px; font-size: 11px; color: #37474f; font-weight: 600; display: flex; align-items: center; gap: 4px; } diff --git a/services/targo-hub/lib/geofence.js b/services/targo-hub/lib/geofence.js index 49ebffd..c6f2f38 100644 --- a/services/targo-hub/lib/geofence.js +++ b/services/targo-hub/lib/geofence.js @@ -52,8 +52,11 @@ async function techPositionsById () { 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 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 } } + 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 } @@ -69,15 +72,16 @@ async function runScan ({ dryRun = false } = {}) { 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) }); departed++; changes.push({ job: j.name, to: 'departed' }) } + 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) }); arrived++; changes.push({ job: j.name, to: 'on_site' }) } + 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) }); enroute++ } + 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++ } } } } @@ -137,6 +141,8 @@ async function reconcileFromTrack ({ date, dryRun = false } = {}) { 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 = [] @@ -148,14 +154,16 @@ async function reconcileFromTrack ({ date, dryRun = false } = {}) { 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' }) + 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' }) + 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 }) } @@ -167,7 +175,7 @@ async function reconcileFromTrack ({ date, dryRun = false } = {}) { } // 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: [] } } +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 }