feat(planif): géofencing des jobs — timeline En route/Arrivé/Reparti (suivi façon colis)
Le hub compare la position GPS live de chaque tech (Traccar) aux jobs du jour → détecte les transitions et les journalise par job. Le statut « live » du job est DÉRIVÉ du journal (compute-on-read, aucune écriture ERPNext — pas de valeur « en cours » côté doctype). Fiche job : timeline horizontale Assigné → En route → Arrivé (HH:MM) → Reparti (HH:MM). - lib/geofence.js : runScan() (rayon 150 m, sortie 230 m hystérésis, séjour 3 min), store data/geofence.json par job, machine à états ; startScan() scheduler 90 s (GEOFENCE_SCAN=off pour couper). Vérifié prod : 12 jobs × 14 positions, transitions OK. - server.js : démarre le scan au boot. - roster.js : GET /roster/job/:name/geofence (timeline) + /geofence-states + /geofence-scan (test). - api/roster.js : jobGeofence(), geofenceStates(). - PlanificationPage : openJobDetail charge la timeline ; stepper .jd-geo (scoped). Suite possible : écrire le vrai statut ERPNext à l'arrivée (nécessite d'ajouter une valeur « En cours » au doctype Dispatch Job) — non fait pour éviter le risque schéma. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d966e1b749
commit
1af8c62142
|
|
@ -184,6 +184,10 @@ export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days)
|
|||
export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech))
|
||||
// Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }.
|
||||
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')
|
||||
// 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 }.
|
||||
export const listTraccarDevices = () => jget('/traccar/devices')
|
||||
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
|
||||
|
|
|
|||
|
|
@ -1343,6 +1343,16 @@
|
|||
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
|
||||
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
|
||||
</div>
|
||||
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis — En route → Arrivé → Reparti, avec l'heure atteinte. -->
|
||||
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
|
||||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
|
||||
<div class="jd-geo-track">
|
||||
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
|
||||
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
|
||||
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="jobDetail.canTeam" class="jd-team">
|
||||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
|
||||
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
|
||||
|
|
@ -2130,7 +2140,7 @@ function exportGpx (techId) {
|
|||
window.open(roster.gpxUrl(techId, from, to), '_blank')
|
||||
}
|
||||
// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
|
||||
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null })
|
||||
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, geofence: null })
|
||||
// Décode les entités HTML (« d'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
||||
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
|
||||
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
|
||||
|
|
@ -2138,9 +2148,21 @@ async function openJobDetail (b, t) {
|
|||
if (!b) return
|
||||
jdShowTrack.value = false
|
||||
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(b.detail || ''), lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
|
||||
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
|
||||
jobDetail.geofence = null
|
||||
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
|
||||
if (b.legacy_id) { try { jobDetail.thread = await roster.ticketThread(b.legacy_id) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } }
|
||||
if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } }
|
||||
}
|
||||
// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte.
|
||||
const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }]
|
||||
const geoTimeline = computed(() => {
|
||||
const gf = jobDetail.geofence; const events = (gf && gf.events) || []
|
||||
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
|
||||
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
|
||||
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
|
||||
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
|
||||
})
|
||||
// Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe.
|
||||
const jdTeamOptions = computed(() => { const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)]); return (techs.value || []).filter(t => !taken.has(t.id)).map(t => ({ label: t.name, value: { id: t.id, name: t.name } })) })
|
||||
async function jdAddAssistant () {
|
||||
|
|
@ -5720,6 +5742,19 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.jd-meta > div { display: flex; align-items: center; gap: 6px; }
|
||||
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
|
||||
.jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
|
||||
/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */
|
||||
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
|
||||
.jd-geo-track { display: flex; align-items: flex-start; }
|
||||
.jd-geo-step { flex: 1; text-align: center; position: relative; }
|
||||
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
|
||||
.jd-geo-step:first-child::before { display: none; }
|
||||
.jd-geo-step.done::before { background: #26a69a; }
|
||||
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
|
||||
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
|
||||
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
|
||||
.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-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
|
||||
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
|
||||
.dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }
|
||||
|
|
|
|||
102
services/targo-hub/lib/geofence.js
Normal file
102
services/targo-hub/lib/geofence.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use strict'
|
||||
// Géofencing des jobs (rec C) : compare la position GPS live de chaque tech (Traccar) à la localisation de SES jobs
|
||||
// du jour → détecte les transitions EN ROUTE → ARRIVÉ (sur place) → REPARTI, et les journalise sur le job (timeline
|
||||
// façon « suivi de colis »). Le statut « live » du job est DÉRIVÉ de ce journal (compute-on-read, aucune écriture ERPNext).
|
||||
// Persistance : data/geofence.json (par job). Scheduler doux (~90 s) tant que le hub tourne.
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { log } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const traccar = require('./traccar')
|
||||
|
||||
const STORE = path.join(__dirname, '..', 'data', 'geofence.json')
|
||||
const RADIUS_M = Number(process.env.GEOFENCE_RADIUS_M) || 150 // entrer à < 150 m = candidat « sur place »
|
||||
const EXIT_M = Number(process.env.GEOFENCE_EXIT_M) || 230 // sortir à > 230 m (hystérésis anti-jitter GPS)
|
||||
const DWELL_MIN = Number(process.env.GEOFENCE_DWELL_MIN) || 3 // rester ≥ 3 min dans le rayon → « Arrivé »
|
||||
const SCAN_SEC = Number(process.env.GEOFENCE_SCAN_SEC) || 90
|
||||
|
||||
let _store = null
|
||||
function store () { if (_store) return _store; try { _store = JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch { _store = {} } return _store }
|
||||
function persist () { try { fs.writeFileSync(STORE, JSON.stringify(store())) } catch (e) { log('geofence persist err: ' + e.message) } }
|
||||
|
||||
function haversineM (aLat, aLon, bLat, bLon) {
|
||||
const R = 6371000, toRad = d => d * Math.PI / 180
|
||||
const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon)
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(s))
|
||||
}
|
||||
const todayISO = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||
|
||||
// Jobs assignés AUJOURD'HUI, avec tech + coordonnées valides.
|
||||
async function todayAssignedJobs () {
|
||||
const rows = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', '=', todayISO()], ['status', 'in', ['open', 'assigned', 'On Hold']]],
|
||||
fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'subject', 'customer_name', 'status'],
|
||||
limit: 400,
|
||||
})
|
||||
return (rows || []).filter(j => j.assigned_tech && j.latitude != null && j.longitude != null &&
|
||||
isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01)
|
||||
}
|
||||
|
||||
// Position GPS live par technician_id (appareil Traccar associé).
|
||||
async function techPositionsById () {
|
||||
const techs = await erp.list('Dispatch Technician', {
|
||||
filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'traccar_device_id'], limit: 200,
|
||||
})
|
||||
const withDev = (techs || []).filter(t => t.traccar_device_id)
|
||||
const ids = [...new Set(withDev.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
|
||||
if (!ids.length) return {}
|
||||
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
|
||||
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 } }
|
||||
return out
|
||||
}
|
||||
|
||||
// UN passage : met à jour la timeline de chaque job selon la distance tech↔job. Idempotent (n'ajoute un événement qu'à un CHANGEMENT d'état).
|
||||
async function runScan ({ dryRun = false } = {}) {
|
||||
let jobs, pos
|
||||
try { [jobs, pos] = await Promise.all([todayAssignedJobs(), techPositionsById()]) }
|
||||
catch (e) { return { ok: false, error: e.message } }
|
||||
const s = store(); const now = new Date().toISOString(); const nowMs = Date.parse(now)
|
||||
let arrived = 0, departed = 0, enroute = 0; const changes = []
|
||||
for (const j of jobs) {
|
||||
const rec = s[j.name] || { state: 'assigned', events: [] }
|
||||
const p = pos[j.assigned_tech]
|
||||
if (p) {
|
||||
const dist = haversineM(p.lat, p.lon, +j.latitude, +j.longitude)
|
||||
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' }) }
|
||||
} 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
|
||||
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' }) }
|
||||
} else {
|
||||
rec.enterMs = null
|
||||
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++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
rec.tech = j.assigned_tech; rec.subject = j.subject || ''; s[j.name] = rec
|
||||
}
|
||||
if (!dryRun && (arrived || departed || enroute)) persist()
|
||||
return { ok: true, dryRun, jobs: jobs.length, positions: Object.keys(pos).length, arrived, departed, enroute, changes }
|
||||
}
|
||||
|
||||
// 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: [] } }
|
||||
// É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 }
|
||||
|
||||
let _timer = null
|
||||
function startScan () {
|
||||
if (String(process.env.GEOFENCE_SCAN || 'on').toLowerCase() === 'off') { log('geofence scan DÉSACTIVÉ (GEOFENCE_SCAN=off)'); return }
|
||||
if (_timer) return
|
||||
const tick = () => runScan().then(r => { if (r && (r.arrived || r.departed)) log(`geofence: +${r.arrived} arrivé, +${r.departed} reparti (${r.jobs} jobs, ${r.positions} pos)`) }).catch(e => log('geofence scan err: ' + e.message))
|
||||
_timer = setInterval(tick, SCAN_SEC * 1000)
|
||||
setTimeout(tick, 8000) // amorçage après le boot
|
||||
log(`geofence scan activé (${SCAN_SEC}s · rayon ${RADIUS_M}m · sortie ${EXIT_M}m · séjour ${DWELL_MIN}min)`)
|
||||
}
|
||||
|
||||
module.exports = { startScan, runScan, timeline, statesFor }
|
||||
|
|
@ -1828,6 +1828,19 @@ async function handle (req, res, method, path, url) {
|
|||
}
|
||||
return json(res, 200, { positions: out })
|
||||
}
|
||||
// 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]))) }
|
||||
// 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)
|
||||
return json(res, 200, { states: require('./geofence').statesFor(names) })
|
||||
}
|
||||
// Géofencing : forcer un passage (diagnostic/test). ?dry=1 = aperçu sans écrire.
|
||||
if (path === '/roster/geofence-scan' && method === 'GET') {
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
return json(res, 200, await require('./geofence').runScan({ dryRun: u.searchParams.get('dry') === '1' }))
|
||||
}
|
||||
// Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif.
|
||||
// device_id = id numérique Traccar (ou '' pour dissocier).
|
||||
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
|
||||
|
|
|
|||
|
|
@ -392,4 +392,7 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
|
|||
// Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on
|
||||
try { require('./lib/campaigns').startGiftReclaimCron() }
|
||||
catch (e) { log('gift reclaim cron failed to start:', e.message) }
|
||||
// Géofencing des jobs : compare GPS live des techs (Traccar) aux jobs du jour → timeline En route/Arrivé/Reparti. Désactivable GEOFENCE_SCAN=off
|
||||
try { require('./lib/geofence').startScan() }
|
||||
catch (e) { log('geofence scan failed to start:', e.message) }
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user