feat(geofence): fige le véhicule Traccar sur chaque événement de passage

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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 12:57:50 -04:00
parent 3547f4a680
commit 877dfa5c44
2 changed files with 26 additions and 7 deletions

View File

@ -13,6 +13,8 @@
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
</div>
</div>
<!-- Véhicule Traccar figé AU MOMENT du passage (reste exact même si le tech change de véhicule un autre jour). -->
<div v-if="vehicle" class="jd-geo-veh"><q-icon name="directions_car" size="12px" /> Véhicule : {{ vehicle }}</div>
</div>
</template>
@ -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; }
</style>

View File

@ -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 }