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:
parent
f793eb4257
commit
77e4411ba2
|
|
@ -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 }.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user