feat(geofence): réconciliation véhicule — assistant / mauvaise affectation (suggestions)

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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 13:28:40 -04:00
parent f793eb4257
commit 77e4411ba2
4 changed files with 123 additions and 5 deletions

View File

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

View File

@ -15,12 +15,25 @@
</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>
<!-- Réconciliation : autres véhicules réellement sur place assistant / mauvaise affectation. Suggestions, confirmées (jamais auto). -->
<div v-if="onsiteSug.length" class="jd-geo-sug">
<div v-for="(s, i) in onsiteSug" :key="i" class="jd-sug">
<div class="jd-sug-note"><q-icon :name="s.type === 'assistant' ? 'group_add' : s.type === 'misassignment' ? 'sync_problem' : 'help_outline'" size="13px" :color="s.type === 'misassignment' ? 'deep-orange-7' : 'teal-7'" /> {{ s.note }}</div>
<div class="jd-sug-acts">
<q-btn v-if="s.type === 'assistant'" dense flat no-caps size="sm" color="teal-8" icon="group_add" label="Ajouter comme assistant" :loading="busy === s.deviceId" @click="addAssist(s)" />
<q-btn v-else-if="s.type === 'misassignment' && !s.techId" dense flat no-caps size="sm" color="deep-orange-8" icon="directions_car" label="Assigner ce véhicule au tech" :loading="busy === s.deviceId" @click="fixVehicle(s)" />
<q-btn dense flat no-caps size="sm" color="grey-6" label="Ignorer" @click="ignore(s)" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
const $q = useQuasar()
const props = defineProps({
name: { type: String, default: '' }, // docname du Dispatch Job
@ -53,11 +66,29 @@ const vehicle = computed(() => {
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 }
// Réconciliation véhicule : suggestions (assistant / mauvaise affectation) issues des traces GPS. Confirmées, jamais auto.
const onsite = ref(null); const busy = ref(null)
const onsiteSug = computed(() => (onsite.value && onsite.value.suggestions) || [])
async function drop (s) { try { await roster.onsiteDismiss(props.name, s.deviceId) } catch (e) {} if (onsite.value) onsite.value = { ...onsite.value, suggestions: onsiteSug.value.filter(x => x.deviceId !== s.deviceId) } }
async function addAssist (s) {
busy.value = s.deviceId
try { await roster.addAssistant(props.name, { tech_id: s.techDoc || s.techId, tech_name: s.techName }); await drop(s); $q.notify({ type: 'positive', message: (s.techName || 'Technicien') + ' ajouté comme assistant' }) }
catch (e) { $q.notify({ type: 'negative', message: e.message || 'Échec' }) } finally { busy.value = null }
}
watch(() => props.name, () => { loaded.value = null; load() })
async function fixVehicle (s) {
busy.value = s.deviceId
try { await roster.setTechTraccarDevice(s.assignedTech, s.deviceId); await drop(s); $q.notify({ type: 'positive', message: 'Véhicule du tech mis à jour' }) }
catch (e) { $q.notify({ type: 'negative', message: e.message || 'Échec' }) } finally { busy.value = null }
}
async function ignore (s) { await drop(s) }
async function load () {
if (!props.name) return
if (!props.geofence) { try { loaded.value = await roster.jobGeofence(props.name) } catch (e) { loaded.value = null } }
// Scan des véhicules sur place seulement s'il y a un suivi terrain (rec de géofence existe) évite un scan inutile.
if (gf.value && gf.value.state) { try { onsite.value = await roster.jobOnsite(props.name) } catch (e) { onsite.value = null } }
}
watch(() => props.name, () => { loaded.value = null; onsite.value = null; load() })
onMounted(load)
</script>
@ -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; }
</style>

View File

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

View File

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