From 77e4411ba23b950b07823236d8e35370009e4e22 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 20 Jul 2026 13:28:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(geofence):=20r=C3=A9conciliation=20v=C3=A9?= =?UTF-8?q?hicule=20=E2=80=94=20assistant=20/=20mauvaise=20affectation=20(?= =?UTF-8?q?suggestions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Niveau 2 de Request 1 : à partir des traces GPS, détecte QUELS véhicules ont séjourné sur un job, puis SUGGÈRE (jamais auto) : - ASSISTANT : le véhicule du tech assigné était là ET un autre a séjourné → propose d'ajouter le tech de cet autre véhicule comme assistant. - MAUVAISE AFFECTATION : le véhicule du tech assigné n'a PAS été vu mais un autre a fait la visite → le tech a pris un autre camion (mapping non mis à jour). Garde-fous : séjour (pas passage), fenêtre du jour, garde MÊME-ADRESSE (un véhicule présent pour SON propre job co-localisé n'est pas un assistant). Cache traces/jour + 1 h/job. geofence.js : onsiteReview(job) + dismissOnsite ; roster.js : GET /roster/job/:name/onsite + POST .../onsite-dismiss ; api/roster : jobOnsite/onsiteDismiss ; GeofenceTimeline : bloc suggestions avec [Ajouter comme assistant] (addAssistant) / [Assigner ce véhicule au tech] (setTechTraccarDevice, si device non mappé) / [Ignorer] (dismiss) — confirmées, non destructives. Mauvaise-affectation vers le véhicule d'UN AUTRE tech = ambigu → pas d'auto-bouton (flag + Ignorer). Vérifié live : 45 devices ; LEG-255021 → « 37 - Anthony » = tech assigné, 0 suggestion (OK) ; scan du jour : 31 jobs, 11 avec véhicule sur place, 3 mauvaises affectations détectées. Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/api/roster.js | 3 + .../modules/GeofenceTimeline.vue | 43 ++++++++++- services/targo-hub/lib/geofence.js | 77 ++++++++++++++++++- services/targo-hub/lib/roster.js | 5 ++ 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 5e9df9a..00903b9 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -233,6 +233,9 @@ export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURI export const techPositions = () => jget('/roster/tech-positions') // Géofencing : timeline d'un job → { state, events:[{status,at,dist}] } (status: en_route|on_site|departed). export const jobGeofence = (name) => jget('/roster/job/' + encodeURIComponent(name) + '/geofence') +// Réconciliation véhicule : { onsiteDevices, suggestions[] (assistant / misassignment / unmapped_present) } +export const jobOnsite = (name, rescan) => jget('/roster/job/' + encodeURIComponent(name) + '/onsite' + (rescan ? '?rescan=1' : '')) +export const onsiteDismiss = (name, deviceId) => jpost('/roster/job/' + encodeURIComponent(name) + '/onsite-dismiss', { deviceId }) // Géofencing : états live pour une liste de jobs → { states: { jobName: state } }. export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' + encodeURIComponent((names || []).join(','))) // Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }. 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 054d8e0..9474fc1 100644 --- a/apps/ops/src/components/shared/detail-sections/modules/GeofenceTimeline.vue +++ b/apps/ops/src/components/shared/detail-sections/modules/GeofenceTimeline.vue @@ -15,12 +15,25 @@
Véhicule : {{ vehicle }}
+ +
+
+
{{ s.note }}
+
+ + + +
+
+
@@ -75,4 +106,8 @@ onMounted(load) .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; } +.jd-geo-sug { margin-top: 8px; border-top: 1px dashed #cfe3e0; padding-top: 6px; } +.jd-sug { margin-top: 4px; } +.jd-sug-note { font-size: 11px; color: #37474f; display: flex; align-items: center; gap: 4px; line-height: 1.35; } +.jd-sug-acts { display: flex; flex-wrap: wrap; gap: 2px; margin-top: 2px; } diff --git a/services/targo-hub/lib/geofence.js b/services/targo-hub/lib/geofence.js index c6f2f38..35ed8d1 100644 --- a/services/targo-hub/lib/geofence.js +++ b/services/targo-hub/lib/geofence.js @@ -175,6 +175,81 @@ async function reconcileFromTrack ({ date, dryRun = false } = {}) { } // Timeline d'un job (UI) : { state, events:[{status,at,dist}] }. state ∈ null|assigned|en_route|on_site|departed. +// ═══ RÉCONCILIATION VÉHICULE (Request 1 « niveau 2 ») ═══ +// À partir des traces GPS, on regarde QUELS véhicules ont RÉELLEMENT séjourné sur un job, puis on SUGGÈRE (jamais +// d'auto-application) : (a) ASSISTANT — le véhicule du tech assigné était là ET un autre véhicule a séjourné aussi ; +// (b) MAUVAISE AFFECTATION — le véhicule du tech assigné n'a PAS été vu mais un autre a fait toute la visite (le tech +// a pris un autre camion et on a oublié de mettre à jour). Garde-fous : séjour (pas de passage), fenêtre du jour, +// et garde MÊME-ADRESSE (un véhicule présent pour SON PROPRE job co-localisé n'est PAS un assistant). +let _dayTracks = { date: null, at: 0, tracks: null } +async function dayTracks (dateISO, devices) { + if (_dayTracks.date === dateISO && _dayTracks.tracks && (Date.now() - _dayTracks.at) < 600000) return _dayTracks.tracks + const { from, to } = dayBounds(dateISO) + const tracks = {} + for (const dev of (devices || [])) { + try { const pts = await traccar.getHistory(dev.id, from, to); tracks[parseInt(dev.id)] = (pts || []).filter(p => p.latitude != null).sort((a, b) => Date.parse(a.fixTime || a.deviceTime) - Date.parse(b.fixTime || b.deviceTime)) } + catch (e) { tracks[parseInt(dev.id)] = [] } + } + _dayTracks = { date: dateISO, at: Date.now(), tracks } + return tracks +} +async function deviceTechMap () { + const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'traccar_device_id'], limit: 300 }) + const byDev = {}; const devByTech = {} + for (const t of (techs || [])) { const d = parseInt(t.traccar_device_id); if (d) { byDev[d] = { techId: t.technician_id || t.name, techName: t.full_name || t.name, docname: t.name }; if (t.technician_id) devByTech[t.technician_id] = d; devByTech[t.name] = d } } + return { byDev, devByTech } +} +// Analyse d'un job : véhicules réellement sur place + suggestions (assistant / mauvaise affectation). Résultat mis en cache 1 h. +async function onsiteReview (jobName, { rescan = false } = {}) { + const rec = store()[jobName] || { state: 'assigned', events: [] } + if (!rescan && rec.onsiteScanAt && (Date.now() - rec.onsiteScanAt) < 3600000) return { onsiteDevices: rec.onsiteDevices || [], suggestions: (rec.suggestions || []).filter(s => !(rec.onsiteDismissed || []).includes(s.deviceId)) } + const rows = await erp.list('Dispatch Job', { filters: [['name', '=', jobName]], fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'scheduled_date'], limit: 1 }) + const j = rows && rows[0] + if (!j || j.latitude == null || Math.abs(+j.latitude) < 0.01) return { onsiteDevices: [], suggestions: [] } + const dateISO = String(j.scheduled_date || '').slice(0, 10) || todayISO() + const jLat = +j.latitude, jLon = +j.longitude + const devices = await traccar.getDevices().catch(() => []) + const { byDev, devByTech } = await deviceTechMap() + const tracks = await dayTracks(dateISO, devices) + const dayJobs = (await erp.list('Dispatch Job', { filters: [['scheduled_date', '=', dateISO], ['status', 'in', ['open', 'assigned', 'On Hold']]], fields: ['name', 'assigned_tech', 'latitude', 'longitude'], limit: 400 }).catch(() => [])).filter(x => x.latitude != null && Math.abs(+x.latitude) > 0.01) + const onsite = [] + for (const dev of (devices || [])) { + const did = parseInt(dev.id); const pts = tracks[did] || [] + if (!pts.length) continue + const r = detectArrivalDeparture(pts, jLat, jLon) + if (!r.arrivedAt) continue + const tm = byDev[did] || {} + onsite.push({ deviceId: did, deviceName: dev.name || '', techId: tm.techId || null, techName: tm.techName || null, techDoc: tm.docname || null, arrivedAt: r.arrivedAt, departedAt: r.departedAt || null, dwellMin: r.departedAt ? Math.round((Date.parse(r.departedAt) - Date.parse(r.arrivedAt)) / 60000) : null }) + } + const assignedDeviceId = devByTech[j.assigned_tech] != null ? devByTech[j.assigned_tech] : null + const assignedPresent = assignedDeviceId != null && onsite.some(o => o.deviceId === assignedDeviceId) + const dismissed = rec.onsiteDismissed || [] + const suggestions = [] + for (const o of onsite) { + if (assignedDeviceId != null && o.deviceId === assignedDeviceId) continue // véhicule attendu = OK + if (dismissed.includes(o.deviceId)) continue + if (o.techId) { // garde MÊME-ADRESSE : ce tech a-t-il SON propre job co-localisé (<150 m) ce jour ? → pas un assistant + const own = dayJobs.some(jj => jj.name !== jobName && (jj.assigned_tech === o.techId || jj.assigned_tech === o.techDoc) && haversineM(jLat, jLon, +jj.latitude, +jj.longitude) < 150) + if (own) continue + } + if (assignedPresent) { + suggestions.push({ type: o.techId ? 'assistant' : 'unmapped_present', deviceId: o.deviceId, deviceName: o.deviceName, techId: o.techId, techName: o.techName, techDoc: o.techDoc, dwellMin: o.dwellMin, note: o.techId ? `${o.techName} sur place${o.dwellMin != null ? ' · ' + o.dwellMin + ' min' : ''} — assistant ?` : `Véhicule non enregistré « ${o.deviceName} » sur place — à rattacher à un tech ?` }) + } else { + suggestions.push({ type: 'misassignment', deviceId: o.deviceId, deviceName: o.deviceName, techId: o.techId, techName: o.techName, techDoc: o.techDoc, dwellMin: o.dwellMin, assignedTech: j.assigned_tech, note: `Véhicule du tech assigné non détecté ; « ${o.deviceName} »${o.techName ? ' (de ' + o.techName + ')' : ''} sur place${o.dwellMin != null ? ' · ' + o.dwellMin + ' min' : ''} — mauvais véhicule ?` }) + } + } + rec.onsiteDevices = onsite; rec.suggestions = suggestions; rec.onsiteScanAt = Date.now() + store()[jobName] = rec; persist() + return { onsiteDevices: onsite, suggestions, assignedPresent, assignedDeviceId } +} +function dismissOnsite (jobName, deviceId) { + const rec = store()[jobName]; if (!rec) return { ok: false } + const id = parseInt(deviceId) + rec.onsiteDismissed = [...new Set([...(rec.onsiteDismissed || []), id])] + rec.suggestions = (rec.suggestions || []).filter(s => s.deviceId !== id) + persist(); return { ok: true } +} + 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 } @@ -195,4 +270,4 @@ function startScan () { 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, reconcileFromTrack, timeline, statesFor } +module.exports = { startScan, runScan, reconcileFromTrack, timeline, statesFor, onsiteReview, dismissOnsite } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index ead41f7..a184799 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -2464,6 +2464,11 @@ async function handle (req, res, method, path, url) { // Géofencing : timeline d'un job (En route → Arrivé → Reparti, façon suivi de colis). const mGf = path.match(/^\/roster\/job\/(.+)\/geofence$/) if (mGf && method === 'GET') { return json(res, 200, require('./geofence').timeline(decodeURIComponent(mGf[1]))) } + // Réconciliation véhicule : quels véhicules ont RÉELLEMENT séjourné sur le job (assistant / mauvaise affectation). + const mOn = path.match(/^\/roster\/job\/(.+)\/onsite$/) + if (mOn && method === 'GET') { const u = new URL(req.url, 'http://localhost'); return json(res, 200, await require('./geofence').onsiteReview(decodeURIComponent(mOn[1]), { rescan: u.searchParams.get('rescan') === '1' })) } + const mOnD = path.match(/^\/roster\/job\/(.+)\/onsite-dismiss$/) + if (mOnD && method === 'POST') { const b = await parseBody(req); return json(res, 200, require('./geofence').dismissOnsite(decodeURIComponent(mOnD[1]), b.deviceId)) } // Géofencing : états live pour une LISTE de jobs (badges board/carte). ?jobs=a,b,c if (path === '/roster/geofence-states' && method === 'GET') { const u = new URL(req.url, 'http://localhost'); const names = (u.searchParams.get('jobs') || '').split(',').map(x => x.trim()).filter(Boolean)