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)