diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
index b32e0fb..a176ff0 100644
--- a/apps/ops/src/api/roster.js
+++ b/apps/ops/src/api/roster.js
@@ -32,6 +32,8 @@ export const listRequirements = (start, days = 7) => jget(`/roster/requirements?
export const createRequirement = (r) => jpost('/roster/requirements', r)
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
+// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due
+export const getCapacity = (start, days = 7) => jget(`/roster/capacity?start=${start}&days=${days}`)
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
@@ -93,7 +95,7 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
// Write-back legacy : aperçu (0 écriture) puis application (réassigne ticket.assign_to au tech dans osTicket)
export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
-export const pushLegacyApply = () => jpost('/dispatch/legacy-sync/push-assignments', {})
+export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
// Fil complet d'un ticket legacy (osTicket) : messages + réponses, pour l'expand au clic
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
@@ -106,7 +108,22 @@ export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job,
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
-// Retirer un job d'un tech (retour au pool non assigné)
+// Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto)
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
+// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
+export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
+// Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel
+// Lien app PWA du tech (à copier / SMS) + son téléphone
+export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech))
+// Table additive « durées par caractéristique » (éditable inline) — seed + apprentissage futur (learned_min)
+export const getJobChars = () => jget('/roster/job-characteristics')
+export const saveJobChars = (items) => jpost('/roster/job-characteristics', { items })
+// Chrono (boucle de capture) : début/fin réels d'un job → durée réelle (apprentissage)
+export const startJob = (job) => jpost('/roster/job/start', { job })
+export const finishJob = (job) => jpost('/roster/job/finish', { job })
+export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
+export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {})
+// Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket
+export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
diff --git a/apps/ops/src/composables/useSSE.js b/apps/ops/src/composables/useSSE.js
index 563521c..1c2f969 100644
--- a/apps/ops/src/composables/useSSE.js
+++ b/apps/ops/src/composables/useSSE.js
@@ -53,6 +53,13 @@ export function useSSE (opts = {}) {
} catch {}
})
+ // Événements nommés génériques : opts.listeners = { 'legacy-update': fn, ... }
+ if (opts.listeners) {
+ for (const [name, cb] of Object.entries(opts.listeners)) {
+ es.addEventListener(name, (e) => { try { cb(JSON.parse(e.data)) } catch {} })
+ }
+ }
+
es.onerror = (e) => {
connected.value = false
if (opts.onError) opts.onError(e)
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index 3b765ad..a553dee 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -47,6 +47,8 @@
Cadence équipe
Gérer les compétences (tags)
+ Synchroniser les techniciens
+ Durées par caractéristique
Jobs à assigner (glisser-déposer)
Tableau Dispatch (détails & priorités)
Congés / absences
@@ -155,6 +157,14 @@
{{ sk }}Niveau {{ (t.skill_levels || {})[sk] || '—' }} · {{ effSuffix(skillEffOf(t, sk)) }} {{ (t.skill_levels || {})[sk] || '·' }}
+ compétences
@@ -553,6 +564,7 @@
+
{{ satView ? 'Vue carte' : 'Vue satellite' }}
📍 Pins colorés par compétence · lettre = repère (même lettre dans la liste) · clic = sélectionne le(s) job(s) · ⚠ {{ assignNoCoord }} sans coords
@@ -574,7 +586,7 @@
{{ j.required_skill }}
- {{ j.customer_name || j.location_label || j.service_location || '' }} · après {{ j.depends_on }} · {{ j.duration_h || 1 }}h · 📅 {{ fmtDueLabel(j.scheduled_date) }}
+ {{ j.customer_name || j.location_label || j.service_location || '' }} · après {{ j.depends_on }} · ≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}Estimé (auto) : {{ j.est_labels.join(' + ') }} · 📅 {{ fmtDueLabel(j.scheduled_date) }}
Chargement du fil…
@@ -624,12 +636,124 @@
+
+ Envoie à chaque tech réassigné le courriel d'assignation (résumé + adresse + lien Répondre ), identique au legacy.
+
+
+
+
+
+ 📍 Situer le job — {{ locPicker.subject }}
+
+
+
+
+
+
+
+
+
+
+ {{ rr.address_full || ((rr.numero || '') + ' ' + (rr.rue || '')) }}
+ {{ rr.ville }} {{ rr.code_postal }} · fibre
+
+
+
+
+
+
+
{{ satView ? 'Vue carte' : 'Vue satellite' }}
+
Clic = situer · glisser le repère · clic droit = Street View
+
+
+ Coordonnées : {{ locPicker.lat }}, {{ locPicker.lon }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 👥 Synchroniser les techniciens
+
+
+
+
+
+
+ Staff legacy actifs : {{ techSync.report.counts.staff_active }} · Fiches roster : {{ techSync.report.counts.fiches }} · Groupe Authentik tech : {{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}
+
+ Aucun — le roster est à jour ✅
+
+
+
+ {{ s.name }} #{{ s.staff_id }} {{ s.email || '— sans courriel —' }}
+ {{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}
+
+
+
+
+ Pas dans le groupe Authentik tech → à ajouter manuellement sur auth.targo.ca (lecture seule ici).
+ {{ t.name }} {{ t.email }}
+
+
+ Ont l'accès Ops mais aucune fiche (ex-employé ? courriel ≠ legacy ?).
+ {{ techSync.report.access_no_fiche.join(' · ') }}
+
+ Authentik non joignable — vérif d'accès indisponible, seul l'ajout au roster fonctionne.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⏱ Durées par caractéristique — additif, hors transport
+
+
+
+
+ Durée d'un job = 1 base + caractéristiques cochées (×qté), × vitesse du tech. Minutes éditables. Les valeurs apprises (geofence) viendront calibrer ces seeds par régression.
+
+ Catégorie Libellé Min ×Qté Mots-clés (auto-détection)
+
+
+
+
+ appris {{ it.learned_min }}m·{{ it.samples }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -736,7 +860,8 @@
-
🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)
+
{{ satView ? 'Vue carte' : 'Vue satellite' }}
+
🗺 Itinéraire routier · zoom molette · clic droit = Street View · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)
Aucun job ce jour.
@@ -758,10 +883,14 @@
{{ j.detail }}
{{ j.subject }}
{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }} · 🔒 RDV fixe · {{ j.customer }}
-
{{ j.address }} · ⚠ sans coords (hors carte)
+
{{ j.address }} · ⚠ sans coords — 📍 situer
min
+ Démarrer (chrono réel → apprentissage)
+ Terminer (chrono réel)
+ ⏱{{ j.actual_min != null ? j.actual_min : '?' }}m
+ {{ hasLL(j) ? 'Repositionner sur la carte' : 'Situer sur la carte (sans coords)' }}
{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}
Retirer du tech (retour au pool)
@@ -812,6 +941,8 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
+import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
+import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import TechSelect from 'src/components/shared/TechSelect.vue'
@@ -826,6 +957,31 @@ const techs = ref([])
const templates = ref([])
const assignments = ref([])
const coverageData = ref([])
+// Dispo restante par jour × segment (AM/PM/Soir), calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs)
+// → suit le filtre par compétence : cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts.
+// Soir (17-21h) RETIRÉ des blocs offerts : c'est une capacité de secours/urgence/garde uniquement, jamais proposée au client.
+const DAY_SEGS = [{ key: 'am', label: 'AM', from: 8, to: 12 }, { key: 'pm', label: 'PM', from: 12, to: 17 }]
+const _segOv = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
+const _r1 = (x) => Math.round(x * 10) / 10
+const _toH = (t) => { if (t == null) return 0; if (typeof t === 'number') return t; const p = String(t).split(':'); return (Number(p[0]) || 0) + (Number(p[1]) || 0) / 60 }
+const capByDay = computed(() => {
+ const out = {}; const tids = visibleTechs.value.map(t => t.id); const cbtd = cellsByTechDay.value; const tpl = tplByName.value
+ for (const d of dayList.value) {
+ const segs = DAY_SEGS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 }))
+ for (const tid of tids) {
+ const asgs = (cbtd[tid] && cbtd[tid][d.iso]) || [] // quarts publiés/proposés du tech ce jour
+ for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (!(e > s)) continue; for (let i = 0; i < DAY_SEGS.length; i++) segs[i].cap += _segOv(s, e, DAY_SEGS[i].from, DAY_SEGS[i].to) }
+ const occ = occByTechDay.value[tid + '|' + d.iso] // jobs placés (blocs horaires)
+ if (occ && occ.blocks) for (const b of occ.blocks) for (let i = 0; i < DAY_SEGS.length; i++) segs[i].used += _segOv(b.s, b.e, DAY_SEGS[i].from, DAY_SEGS[i].to)
+ }
+ let due_h = 0, jobs_due = 0 // charge DUE = pool non assigné ce jour (filtré par compétence si actif)
+ for (const j of (assignPanel.jobs || [])) { if (j.scheduled_date !== d.iso) continue; if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue; due_h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0)); jobs_due++ }
+ segs.forEach(s => { s.cap = _r1(s.cap); s.used = _r1(s.used); s.free = _r1(s.cap - s.used) })
+ const cap_h = _r1(segs.reduce((x, s) => x + s.cap, 0)); const used_h = _r1(segs.reduce((x, s) => x + s.used, 0))
+ out[d.iso] = { segments: segs, cap_h, used_h, free_h: _r1(cap_h - used_h), due_h: _r1(due_h), jobs_due, overbooked: cap_h > 0 && due_h > cap_h }
+ }
+ return out
+})
const dailyStats = ref([])
const solverStats = ref(null)
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
@@ -1013,9 +1169,9 @@ const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
-const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null })
+const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
async function openLegacyPush () { legacyPush.open = true; legacyPush.loading = true; legacyPush.preview = null; try { legacyPush.preview = await roster.pushLegacyPreview() } catch (e) { err(e) } finally { legacyPush.loading = false } }
-async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
+async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(legacyPush.notify); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (legacyPush.notify && r.notified != null ? ` · ${r.notified} avisé(s) par courriel (boîte du tech)` : '') + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); if (legacyPush.notify && r.notify_errors && r.notify_errors.length) { $q.notify({ type: 'warning', icon: 'mark_email_unread', message: `⚠ ${r.notify_errors.length} avis NON envoyé(s) — ${(r.notify_errors[0] && r.notify_errors[0].error) || ''}`, timeout: 6000 }) } legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
// X sur une ligne de l'aperçu → désassigne le job dans Ops (retour au pool) → l'exclut du push, puis rafraîchit l'aperçu + la grille.
async function removeFromPush (s) {
if (!s || !s.job) { $q.notify({ type: 'warning', message: 'Job introuvable' }); return }
@@ -1027,7 +1183,7 @@ async function removeFromPush (s) {
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
} catch (e) { err(e) } finally { legacyPush.applying = false }
}
-const assignSort = ref('group') // group (parent-enfant) | skill | date | city | priority
+const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : aujourd'hui/en retard d'abord). Autres : group | skill | city | priority
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
function jobCity (j) {
@@ -1044,7 +1200,11 @@ const assignJobsFiltered = computed(() => { const f = assignTypeFilter.value; if
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future).
function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() }
-function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d.slice(5) + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d.slice(5) }
+function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
+// Couleur d'un segment de capacité (heures-tech libres restantes) : rouge=plein, orange=presque plein, vert=ok, gris=aucun quart.
+function capSegClass (s) { if (!s || s.cap <= 0) return 'cap-none'; if (s.free <= 0) return 'cap-full'; if (s.free < 1.5) return 'cap-low'; return 'cap-ok' }
+const capFmt = (h) => (h === 0 ? '0' : (Number.isInteger(h) ? String(h) : h.toFixed(1)))
+const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
const assignGroups = computed(() => {
const jobs = assignJobsFiltered.value
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
@@ -1140,6 +1300,127 @@ function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } //
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
+
+// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
+// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
+// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
+const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
+function addSatLayer (map) {
+ try {
+ if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
+ if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
+ } catch (e) { /* style pas prêt — ignoré */ }
+}
+function applySat (map) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', satView.value ? 'visible' : 'none') } catch (e) {} }
+function toggleSat () { satView.value = !satView.value; try { localStorage.setItem('plan_sat', satView.value ? '1' : '0') } catch (e) {} applySat(_assignMap); applySat(_dayMap); applySat(_locMap) }
+
+// Clic DROIT sur une carte → Google Street View à la position cliquée (nouvel onglet). Branché sur les 3 cartes.
+function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`, '_blank', 'noopener') }
+
+// ── SÉLECTEUR D'EMPLACEMENT (jobs « hors carte ») : carte Mapbox bornée au sud de Montréal (Valleyfield ↔ Lacolle ↔
+// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
+const locMapEl = ref(null); let _locMap = null, _locMarker = null
+const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
+const locPicker = reactive({ open: false, job: null, subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false })
+function openLocPicker (j) {
+ locPicker.job = j.name; locPicker.subject = j.subject || j.name
+ locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
+ locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []
+ locPicker.open = true
+}
+async function initLocMap () {
+ const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
+ if (_locMap) { _locMap.resize(); return }
+ mapboxgl.accessToken = MAPBOX_TOKEN
+ _locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
+ _locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
+ _locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
+ _locMap.on('load', () => {
+ _locMap.resize(); addSatLayer(_locMap)
+ if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
+ else placeLocMarker(locPicker.lon, locPicker.lat, false)
+ })
+ _locMap.on('click', (e) => placeLocMarker(e.lngLat.lng, e.lngLat.lat, true))
+}
+function placeLocMarker (lon, lat, doReverse) {
+ locPicker.lon = +(+lon).toFixed(6); locPicker.lat = +(+lat).toFixed(6)
+ if (!_locMarker) {
+ _locMarker = new window.mapboxgl.Marker({ draggable: true, color: '#e8590c' }).setLngLat([lon, lat]).addTo(_locMap)
+ _locMarker.on('dragend', () => { const ll = _locMarker.getLngLat(); locPicker.lon = +ll.lng.toFixed(6); locPicker.lat = +ll.lat.toFixed(6); reverseGeocode(ll.lng, ll.lat) })
+ } else _locMarker.setLngLat([lon, lat])
+ if (doReverse) reverseGeocode(lon, lat)
+}
+async function reverseGeocode (lon, lat) {
+ if (!MAPBOX_TOKEN) return
+ try {
+ const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
+ const j = await r.json(); const f = (j.features || [])[0]
+ if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
+ } catch (e) { /* non bloquant */ }
+}
+async function locSearch () {
+ const q = (locPicker.search || '').trim(); if (q.length < 3) return
+ locPicker.searching = true
+ try { const r = await addressApi.conformityCandidates(q); locPicker.results = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8) } catch (e) { err(e) } finally { locPicker.searching = false }
+}
+function pickLocResult (rr) {
+ const lon = +rr.longitude, lat = +rr.latitude; if (!isFinite(lon) || !isFinite(lat)) return
+ locPicker.address = rr.address_full || [rr.numero, rr.rue, rr.ville, rr.code_postal].filter(Boolean).join(' ')
+ locPicker.results = []
+ if (_locMap) { _locMap.easeTo({ center: [lon, lat], zoom: 16, duration: 500 }); placeLocMarker(lon, lat, false) } else { locPicker.lon = lon; locPicker.lat = lat }
+}
+async function saveLocPicker () {
+ if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
+ locPicker.saving = true
+ try {
+ await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
+ const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address }
+ locPicker.open = false
+ await reloadOccupancy(); if (_dayMap) refreshDayMap()
+ $q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
+ } catch (e) { err(e) } finally { locPicker.saving = false }
+}
+function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
+// init/destroy via @show/@hide du q-dialog (le conteneur a sa taille finale à @show → carte fiable)
+
+// ── Tableur « durées par caractéristique » (additif, hors transport) — éditable inline, seed + apprentissage ──
+const jobChar = reactive({ open: false, loading: false, saving: false, items: [] })
+async function openJobChar () { jobChar.open = true; jobChar.loading = true; try { const r = await roster.getJobChars(); jobChar.items = r.items || [] } catch (e) { err(e) } finally { jobChar.loading = false } }
+function addJobCharRow () { jobChar.items.push({ id: 'c' + Date.now().toString(36), cat: 'addon', label: '', minutes: 30, per_qty: false, keywords: '', learned_min: null, samples: 0 }) }
+async function saveJobChar () { jobChar.saving = true; try { const r = await roster.saveJobChars(jobChar.items); $q.notify({ type: r.ok ? 'positive' : 'negative', message: r.ok ? `Table enregistrée (${r.count} lignes)` : 'Échec d\'enregistrement' }); if (r.ok) jobChar.open = false } catch (e) { err(e) } finally { jobChar.saving = false } }
+
+// Lien app terrain d'un tech : copie le lien (PWA /field?t=) + propose l'envoi SMS au tech (sans build natif requis).
+async function techAppLinkMenu (t) {
+ try {
+ const r = await roster.techAppLink(t.id); if (!r || !r.ok) { err(new Error('lien app indisponible')); return }
+ try { await navigator.clipboard.writeText(r.url) } catch (e) {}
+ const actions = [{ label: 'OK', color: 'white' }]
+ if (r.phone) actions.unshift({ label: '📲 SMS au tech', color: 'yellow', handler: async () => { try { await sendSmsViaHub(r.phone, 'Ton app terrain Targo (interventions du jour) : ' + r.url, ''); $q.notify({ type: 'positive', message: 'Lien envoyé par SMS à ' + r.name }) } catch (e) { err(e) } } })
+ $q.notify({ message: '📱 Lien app copié' + (r.phone ? '' : ' (pas de téléphone au dossier)') + ' — ' + r.url, timeout: 9000, multiLine: true, actions })
+ } catch (e) { err(e) }
+}
+
+// ── Synchroniser les techniciens : rapport de réconciliation (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) ──
+const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} })
+const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
+async function openTechSync () {
+ techSync.open = true; techSync.loading = true; techSync.report = null; techSync.sel = {}
+ try {
+ const r = await roster.techSyncReport(); techSync.report = r
+ for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
+ } catch (e) { err(e) } finally { techSync.loading = false }
+}
+async function applyTechSync () {
+ const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
+ if (!ids.length) return
+ techSync.applying = true
+ try {
+ const r = await roster.techSyncApply(ids)
+ $q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
+ techSync.open = false
+ await loadBase() // recharge la liste des techniciens dans la grille
+ } catch (e) { err(e) } finally { techSync.applying = false }
+}
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
const assignNoCoord = computed(() => assignPanel.jobs.filter(j => !_hasJobLL(j)).length)
function assignStops () { return assignLocations.value.stops }
@@ -1160,9 +1441,11 @@ async function initAssignMap () {
mapboxgl.accessToken = MAPBOX_TOKEN
_assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
_assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
+ _assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
_assignMap.on('load', () => {
_assignMap.resize()
+ addSatLayer(_assignMap) // couche satellite (sous les pins), masquée par défaut
_assignMap.addSource('aj', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
_assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
@@ -1345,9 +1628,11 @@ async function initDayMap () {
mapboxgl.accessToken = MAPBOX_TOKEN
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
+ _dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
_dayMap.on('load', () => {
_dayMap.resize()
+ addSatLayer(_dayMap) // couche satellite (sous l'itinéraire et les pins), masquée par défaut
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
@@ -1400,8 +1685,16 @@ function destroyDayMap () {
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
+// Chrono (boucle de capture) : début/fin réels → durée mesurée (alimente l'apprentissage des durées par type×tech)
+async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
+async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
async function removeFromDay (j) {
- try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
+ // Action EXPLICITE → renvoie aussi le ticket au pool « Tech Targo » (3301) DANS Legacy (write-back symétrique).
+ try {
+ const r = await roster.returnJobToPool(j.name)
+ dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
+ $q.notify({ type: 'info', message: (r && r.returned_to_pool) ? 'Retiré du tech — ticket renvoyé au pool « Tech Targo » dans Legacy' : 'Retiré du tech (retour au pool « à assigner »)', timeout: 2600 })
+ } catch (e) { err(e) }
}
async function saveDayOrder () {
dayEditor.saving = true
@@ -2068,7 +2361,31 @@ function onKey (e) {
}
}
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
-onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel() /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ })
+// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
+let _legacyRefreshT = null
+function scheduleLegacyRefresh () {
+ clearTimeout(_legacyRefreshT)
+ _legacyRefreshT = setTimeout(async () => {
+ try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } } catch (e) { /* non bloquant */ }
+ }, 700)
+}
+function onLegacyUpdate (data) {
+ const ch = (data && data.changes) || []
+ if (!ch.length) return
+ const closed = ch.filter(c => c.kind === 'closed').length
+ const taken = ch.filter(c => c.kind === 'taken')
+ const moved = ch.filter(c => c.kind === 'reassigned' && !c.pool)
+ const stat = ch.filter(c => c.kind === 'status').length
+ if (closed) $q.notify({ type: 'info', icon: 'task_alt', message: `Legacy : ${closed} ticket(s) fermé(s) — retiré(s) du planning`, timeout: 3500 })
+ if (taken.length) $q.notify({ type: 'info', icon: 'person_remove', message: `Legacy : ${taken.length} ticket(s) pris par un tech dans Legacy — retiré(s) du pool`, timeout: 4000 })
+ for (const m of moved) $q.notify({ type: 'warning', icon: 'swap_horiz', message: `Ticket #${m.ticket} réassigné HORS-Ops (staff ${m.to_staff}) — à vérifier`, timeout: 6000 })
+ if (stat) $q.notify({ type: 'info', icon: 'sync', message: `Legacy : ${stat} changement(s) de statut`, timeout: 3000 })
+ for (const c of ch) { if (c.job) flashJob.value = c.job }
+ scheduleLegacyRefresh()
+}
+const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
+
+onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel(); /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
@@ -2083,7 +2400,31 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
-.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; }
+.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; position: relative; }
+.map-sat-btn { position: absolute; top: 7px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
+.loc-map-wrap { position: relative; }
+.loc-map { height: 360px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
+.loc-map-hint { position: absolute; bottom: 8px; left: 8px; background: rgba(255,255,255,.88); font-size: 11px; padding: 2px 7px; border-radius: 4px; color: #555; pointer-events: none; }
+.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
+.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
+/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
+.cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
+.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
+.cap-ok { background: #c8e6c9; color: #1b5e20; }
+.cap-low { background: #ffe0b2; color: #e65100; }
+.cap-full { background: #ffcdd2; color: #b71c1c; }
+.cap-none { background: #eceff1; color: #b0bec5; }
+.cap-warn { margin-left: 1px; }
+.de-actual { font-size: 11px; font-weight: 700; color: #00695c; background: #e0f2f1; border-radius: 4px; padding: 2px 5px; white-space: nowrap; }
+.chrono-run { animation: chronopulse 1.4s ease-in-out infinite; }
+@keyframes chronopulse { 0%, 100% { opacity: 1 } 50% { opacity: .4 } }
+.jc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
+.jc-table th { text-align: left; color: #607d8b; font-weight: 600; padding: 3px 4px; border-bottom: 1px solid #e0e0e0; }
+.jc-table td { padding: 2px 4px; vertical-align: middle; }
+.jc-table tr.jc-base td:first-child { border-left: 3px solid #1565c0; }
+.jc-table tr.jc-addon td:first-child { border-left: 3px solid #00897b; }
+.jc-table tr.jc-modifier td:first-child { border-left: 3px solid #8e24aa; }
+.jc-learn { font-size: 10px; color: #2e7d32; white-space: nowrap; }
.assign-map { height: 230px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
.assign-map-cap { font-size: 10px; color: #607d8b; padding: 2px 2px 0; line-height: 1.3; }
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
@@ -2174,7 +2515,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
/* minimap du jour (territoire des arrêts) */
-.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
+.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
.de-map-gl { width: 100%; height: 240px; }
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
diff --git a/docs/field-tech-app.md b/docs/field-tech-app.md
new file mode 100644
index 0000000..5f1e52e
--- /dev/null
+++ b/docs/field-tech-app.md
@@ -0,0 +1,52 @@
+# App technicien & capture passive du temps (durées apprises)
+
+> But : mesurer **sans effort tech** le temps réel passé par job → apprendre les durées par **type × technicien** → alimenter le recommandeur « prochaine dispo » et le solveur. Principe directeur : **aucun tap dédié** — la durée se *déduit* d'événements que le tech produit déjà + géorepérage GPS automatique.
+
+## 1. Modèle « fil de checkpoints »
+Chaque job accumule des **checkpoints** horodatés (et géolocalisés). La durée réelle = **1er checkpoint sur place → dernier** (ou entrée→sortie de géorepérage).
+
+| Type de checkpoint | Source | Effort tech |
+|---|---|---|
+| `geo_enter` / `geo_exit` | Géorepérage GPS (zone de l'adresse) | **nul** (auto) |
+| `scan` (série / MAC / code-barre) | Caméra app (modem, ONU, mesh, STB) | déjà fait (activation) |
+| `photo` / `signature` | App | déjà fait |
+| `reply` | Réponse au ticket | déjà fait |
+| `manual_start` / `manual_finish` | Override répartiteur (Ops) ou tech | filet de sécurité |
+
+**Dérivation** : `actual_start` = 1er checkpoint « sur place » (geo_enter, ou 1er scan/photo/reply à < R m de l'adresse) ; `actual_end` = dernier ; `actual_minutes = end − start`. Les checkpoints hors-zone (> R m) sont ignorés pour la durée mais conservés pour l'audit.
+
+## 2. Backend (cette itération — indépendant de l'app)
+- **Endpoint public token-gated** `POST /field/checkpoint` `{ token, type, ts, lat, lon, acc, ref }` → vérifie le token (HMAC du job), enregistre le checkpoint, recalcule `actual_start/end` du Dispatch Job, ajoute une ligne d'audit dans `completion_notes`. Réponse : `{ ok, on_site, distance_m, minutes }`.
+- **Lecture** `GET /field/job?t=
` → infos job (sujet, client, adresse, coords, état) pour l'app.
+- **Token** = `base64url(jobName).hmac12` signé (`crypto`, secret stable du hub) → stateless, valable pour tout job, aucun champ DB ni login.
+- `/field` + `/field/*` câblés **publics** dans `server.js` (calqués sur `/book`, hors forwardAuth Authentik).
+
+## 3. App Capacitor (projet natif dédié — `apps/field-tech/`)
+Véhicule unique pour les techs (remplace le mobile legacy) :
+- **Géorepérage arrière-plan économe** : `@transistorsoft/capacitor-background-geolocation` (référence batterie + geofencing natif iOS/Android) — ou `@capacitor-community/background-geolocation` pour démarrer. On enregistre un geofence par job du jour ; `enter`/`exit` → POST `/field/checkpoint`.
+- **Scan caméra** : `@capacitor-mlkit/barcode-scanning` → série/MAC → checkpoint `scan` **et** push vers GenieACS (par MAC) — réunit l'item roadmap « scan série/MAC des devices ».
+- **Offline-first** : file d'attente locale (Preferences/SQLite) → rejoue les checkpoints à la reconnexion.
+- **Auth** : login Authentik (groupe `tech`) → l'app reçoit ses jobs du jour + un token par job ; ou device-token. (À finaliser.)
+- **Push** : web-push/FCM (déjà côté legacy) pour les nouvelles assignations.
+- Builds : iOS (TestFlight) + Android (APK interne) ; permissions localisation « toujours » + caméra.
+
+## 4. Amorçage SANS app (mineur legacy — cette itération)
+Estimer une 1re durée par ticket depuis les événements legacy **déjà horodatés** : `ticket_msg.date_orig` du staff (réponses), création `device` (scan série), activation Ministra. Fenêtre sur-site ≈ (dernière activité − première) le même jour, filtrée < 8 h (exclut le multi-jour). **Bruité à l'unité, mais l'agrégat (médiane par type) donne une baseline d'apprentissage dès aujourd'hui.** Rapport `GET /dispatch/legacy-sync/mine-durations` (lecture seule).
+
+## 5. Boucle d'apprentissage (consomme les durées)
+1. Capture (checkpoints app + override Ops + baseline minée) → `actual_minutes` par job.
+2. Agrégation : **médiane / p75 par sous-type × tech** (sous-types fins par mots-clés du sujet : « modem » vs « fil/fibre/drop » vs « ONT » vs « install »).
+3. Remplace la table statique `DUR` (util/legacy-parse) + affine `skill_eff` (facteur vitesse par tech×compétence).
+4. Le recommandeur « prochaine dispo » (`bookingSlots/fitBooking`) utilise la durée apprise ajustée au tech.
+
+## 6. Phasage
+- **P1 (maintenant)** : backend checkpoints (§2) + mineur legacy (§4).
+- **P2** : recommandeur « prochaine dispo » (UI Ops) + sous-types fins + durées seed.
+- **P3** : app Capacitor (§3) — scaffold → geofence → scan → offline → stores.
+- **P4** : agrégation apprise (§5) qui remplace les seeds, une fois les données accumulées.
+
+## 7. Sécurité / contraintes
+- Endpoint checkpoint **public mais token-gaté** (HMAC par job) ; pas de PII dans le token.
+- N'écrit QUE sur le Dispatch Job (ERPNext) ; ne touche pas le legacy en écriture.
+- GPS = **preuve + contrôle de distance**, pas du tracking continu serveur (le geofencing reste sur l'appareil).
+- Le tap répartiteur ▶/⏹ (Ops) reste comme override/fallback.
diff --git a/services/legacy-bridge/ops_reassign.php b/services/legacy-bridge/ops_reassign.php
index 8ea0861..643fe23 100644
--- a/services/legacy-bridge/ops_reassign.php
+++ b/services/legacy-bridge/ops_reassign.php
@@ -5,7 +5,8 @@
* de l'app (aucun GRANT à modifier ; le user du hub @10.100.5.61 reste SELECT-only).
*
* Actions (POST, x-www-form-urlencoded ou JSON), header `X-Ops-Token: ` OBLIGATOIRE :
- * action=reassign ticket_id, staff_id, [assist_ids=CSV] → assign_to + participant(assistants) + followed_by + ticket_msg
+ * action=reassign ticket_id, staff_id, [assist_ids=CSV] [notify=1] [address] → assign_to + participant(assistants) + followed_by + ticket_msg
+ * notify=1 → courriel d'assignation au tech (résumé + lien Répondre + Adresse), via PHPMailer6 OAuth2 (ops_secret.php)
* action=close ticket_id → status=closed + date_closed + closed_by + réouverture enfants + ticket_msg
*
* FIDÈLE à ticket_view.php : format participant `-id-;-id-`, followers JSON, log CHANGE LOG, respect du lock_name,
@@ -15,11 +16,11 @@
*/
header('Content-Type: application/json; charset=utf-8');
-// ─── à remplir sur le serveur (hors repo) ───
-$OPS_TOKEN = '__OPS_TOKEN__';
+// ─── secrets : fichier serveur HORS REPO `ops_secret.php` (à côté de ce fichier) qui définit :
+// $OPS_TOKEN, $DB_USER, $DB_PASS + creds OAuth Gmail $oauthEmail,$clientId,$clientSecret,$refreshToken
+// (réutilise les mêmes creds que ticket_view.php). JAMAIS committé.
+require __DIR__ . '/ops_secret.php';
$DB_HOST = '10.100.80.100';
-$DB_USER = '__DB_USER__';
-$DB_PASS = '__DB_PASS__';
$DB_NAME = 'gestionclient';
$OPS_STAFF = 3301; // auteur des entrées ticket_msg (Tech Targo)
@@ -83,7 +84,7 @@ if ($action === 'close') {
// ─── reassign (+ assistants) ───
$staff = (int)($_POST['staff_id'] ?? 0);
if ($staff <= 0) out(['ok' => false, 'error' => 'staff_id requis'], 400);
-$chk = $db->query("SELECT id, first_name, last_name FROM staff WHERE id=$staff AND status=1");
+$chk = $db->query("SELECT id, first_name, last_name, email FROM staff WHERE id=$staff AND status=1");
$s = $chk ? $chk->fetch_assoc() : null;
if (!$s) out(['ok' => false, 'error' => 'staff inactif ou introuvable', 'staff_id' => $staff]);
@@ -103,4 +104,77 @@ $aff = $db->affected_rows;
$who = trim(($s['first_name'] ?? '') . ' ' . ($s['last_name'] ?? ''));
$log = "Réassigné à #$staff ($who) via Ops." . ($assistIds ? ' Assistants : ' . implode(', ', $assistIds) . '.' : '');
ops_log($db, $ticket, $logStaff, $log);
-out(['ok' => true, 'action' => 'reassign', 'affected' => $aff, 'staff' => $staff, 'assistants' => $assistIds]);
+
+// ─── avis courriel au nouveau tech (notify=1) — MÊME contenu que ticket_view.php (résumé + lien Répondre),
+// + ligne « Adresse » fournie par Ops. Envoi via PHPMailer6 OAuth2 (creds dans ops_oauth_boot.php, hors repo). ───
+$notified = false; $notifyErr = null; $notifyTo = '';
+if (!empty($_POST['notify'])) {
+ $staffEmail = trim((string)($s['email'] ?? ''));
+ $notifyTo = $staffEmail;
+ if ($staffEmail === '') { $notifyErr = 'staff sans courriel'; }
+ else {
+ $opsAddress = trim((string)($_POST['address'] ?? '')); // adresse de service propre (Ops)
+ $tq = $db->query("SELECT subject, dept_id, account_id, status, due_date, due_time, bon_id, wizard_fibre FROM ticket WHERE id=$ticket LIMIT 1");
+ $tr = $tq ? $tq->fetch_assoc() : [];
+ $subject = (string)($tr['subject'] ?? '');
+ $deptId = (int)($tr['dept_id'] ?? 0);
+ $accId = (int)($tr['account_id'] ?? 0);
+ $tstatus = (string)($tr['status'] ?? '');
+ $dueEpoch = (int)($tr['due_date'] ?? 0);
+ $dueStr = $dueEpoch > 0 ? date('d-m-Y', $dueEpoch) : 'sans date';
+ $dueTime = trim((string)($tr['due_time'] ?? ''));
+ $dueTimeStr = ($dueTime !== '' && $dueEpoch > 0) ? " - $dueTime" : '';
+ $bonId = trim((string)($tr['bon_id'] ?? ''));
+ $wizFibre = trim((string)($tr['wizard_fibre'] ?? ''));
+ $dn = $db->query("SELECT name FROM ticket_dept WHERE id=$deptId LIMIT 1"); $dnr = $dn ? $dn->fetch_assoc() : null;
+ $deptName = (string)($dnr['name'] ?? '');
+ $cn = $db->query("SELECT first_name, last_name, company, customer_id FROM account WHERE id=$accId LIMIT 1"); $cnr = $cn ? $cn->fetch_assoc() : null;
+ $clientName = $cnr ? trim(($cnr['first_name'] ?? '') . ' ' . ($cnr['last_name'] ?? '') . ' ' . ($cnr['company'] ?? '') . ' - ' . ($cnr['customer_id'] ?? '')) : '';
+ // liens ONU (fibre) / bon de travail — fidèle à ticket_view.php
+ $onu = '';
+ if ($wizFibre !== '') { $wi = explode('|', $wizFibre); $onu = "Connecter ONU "; }
+ if ($deptId === 26) $onu = "Remplacer ONU ";
+ $bonLink = $bonId !== '' ? "Ouvrir Bon de Travail : https://facturation.targo.ca/facturation/accueil.php?menu=bon_travail&bon_id=$bonId " : '';
+ $addrLine = $opsAddress !== '' ? "Adresse : " . htmlspecialchars($opsAddress, ENT_QUOTES) . " " : '';
+ $replyLink = "https://store.targo.ca/targo/reply_ticket.php?ticket=$ticket&staff=$staff";
+ $subjectLine = html_entity_decode("[Ticket #$ticket] $subject [$deptName]", ENT_QUOTES);
+ $body = "Ce ticket vous a été assigné. "
+ . "Répondre: $replyLink "
+ . $onu . $bonLink
+ . "Ticket ID : $ticket "
+ . "Client : $clientName "
+ . "Sujet : $subject "
+ . "Status : $tstatus "
+ . "Due date : $dueStr $dueTimeStr "
+ . $addrLine
+ . "Département : $deptName ";
+ try {
+ // L'app legacy (vendor + PHPMailer6) est dans ./facturation quand ce script est au docroot /var/www ; sinon ici.
+ $APP_DIR = is_dir(__DIR__ . '/facturation/PHPMailer6') ? __DIR__ . '/facturation' : __DIR__;
+ require_once $APP_DIR . '/vendor/autoload.php'; // League\OAuth2 (Google provider) — déjà dans l'app
+ require_once $APP_DIR . '/PHPMailer6/Exception.php';
+ require_once $APP_DIR . '/PHPMailer6/PHPMailer.php';
+ require_once $APP_DIR . '/PHPMailer6/SMTP.php';
+ require_once $APP_DIR . '/PHPMailer6/OAuth.php';
+ $provider = new \League\OAuth2\Client\Provider\Google(['clientId' => $clientId, 'clientSecret' => $clientSecret]); // creds depuis ops_secret.php
+ $mail = new \PHPMailer\PHPMailer\PHPMailer(true);
+ $mail->isSMTP();
+ $mail->Host = gethostbyname('smtp.gmail.com');
+ $mail->SMTPOptions = ['ssl' => ['verify_peer_name' => false]];
+ $mail->Port = 587;
+ $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
+ $mail->SMTPAuth = true;
+ $mail->AuthType = 'XOAUTH2';
+ $mail->setOAuth(new \PHPMailer\PHPMailer\OAuth(['provider' => $provider, 'clientId' => $clientId, 'clientSecret' => $clientSecret, 'refreshToken' => $refreshToken, 'userName' => $oauthEmail]));
+ $mail->setFrom('ticket@targointernet.com', 'Ticket');
+ $mail->addAddress($staffEmail);
+ $mail->Subject = $subjectLine;
+ $mail->CharSet = \PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
+ $mail->isHTML(true);
+ $mail->Body = $body;
+ $mail->send();
+ $notified = true;
+ } catch (\Throwable $e) { $notifyErr = $e->getMessage(); }
+ }
+}
+out(['ok' => true, 'action' => 'reassign', 'affected' => $aff, 'staff' => $staff, 'assistants' => $assistIds, 'notified' => $notified, 'notify_to' => $notifyTo, 'notify_error' => $notifyErr]);
diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js
index c953919..666ddd1 100644
--- a/services/targo-hub/lib/legacy-dispatch-sync.js
+++ b/services/targo-hub/lib/legacy-dispatch-sync.js
@@ -26,6 +26,7 @@ const cfg = require('./config')
const { log, json, httpRequest } = require('./helpers')
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
const addrdb = require('./address-db') // pool PG local (camping_registry)
+const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
const fs = require('fs')
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
@@ -49,6 +50,29 @@ async function batchCloseLegacy (ticketIds, actorEmail) {
const w = await legacyWrite({ action: 'batch_close', ticket_ids: ticketIds.join(','), actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
return (w && w.data) || { ok: false, error: 'no response' }
}
+// Résout le staff legacy de l'acteur Ops depuis son email Authentik (0 si introuvable).
+async function resolveActorStaff (actorEmail) {
+ try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) return ar[0].id } } catch (e) {}
+ return 0
+}
+// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
+// réécrit le ticket legacy à assign_to=3301 (Tech Targo, l'inbox de dispatch). Action EXPLICITE seulement
+// (les redistributions auto vers un autre tech poussent le nouveau tech au prochain Publier, pas 3301).
+async function returnToPool (job, actorEmail) {
+ if (!job) return { ok: false, error: 'job requis' }
+ const djs = await erp.list('Dispatch Job', { filters: [['name', '=', job]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status'], limit: 1 })
+ const j = djs && djs[0]
+ if (!j) return { ok: false, error: 'job introuvable' }
+ const hadTech = !!j.assigned_tech
+ const r = await erp.update('Dispatch Job', job, { assigned_tech: null, status: 'open', start_time: null }).catch(e => ({ ok: false, error: e.message }))
+ let legacy = null
+ if (hadTech && j.legacy_ticket_id) { // n'écrit au legacy que si le ticket avait réellement été attribué
+ const actorStaff = await resolveActorStaff(actorEmail)
+ const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: TARGO_TECH_STAFF_ID, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
+ legacy = (w && w.data) || { ok: false, error: 'no response' }
+ }
+ return { ok: !(r && r.ok === false), job, erp: r, legacy, returned_to_pool: !!(legacy && legacy.ok) }
+}
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
@@ -597,12 +621,12 @@ function pushAssignments (opts = {}) {
_syncLock = run.then(() => {}, () => {})
return run
}
-async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
+async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true } = {}) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy
if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} }
- const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['assigned_tech', '!=', '']], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'subject', 'status'], limit: 2000 })
- if (!jobs.length) return { ok: true, dryRun, candidates: 0, pushed: 0, skipped: 0, mismatch: 0, errors: 0, samples: [] }
+ const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['assigned_tech', '!=', '']], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'subject', 'status', 'address'], limit: 2000 })
+ if (!jobs.length) return { ok: true, dryRun, candidates: 0, pushed: 0, skipped: 0, mismatch: 0, errors: 0, notified: 0, samples: [] }
const techIds = [...new Set(jobs.map(j => j.assigned_tech))]
const techs = await erp.list('Dispatch Technician', { filters: [['name', 'in', techIds]], fields: ['name', 'full_name', 'email'], limit: techIds.length + 5 })
const techByName = {}; for (const t of (techs || [])) techByName[t.name] = t
@@ -629,7 +653,7 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
}
return { staff: null, via: null }
}
- let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0; const errSamples = []
+ let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0; const errSamples = []; const notifyErrors = []
const samples = []
for (const j of jobs) {
const techRow = techByName[j.assigned_tech]
@@ -641,14 +665,14 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) })
if (!dryRun) {
- const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
+ const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
const d = (w && w.data) || {}
- if (d.ok) pushed++
+ if (d.ok) { pushed++; if (d.notified) notified++; else if (notify && d.notify_error && notifyErrors.length < 6) notifyErrors.push({ ticket: j.legacy_ticket_id, to: d.notify_to || '', error: d.notify_error }) }
else if (d.error === 'verrouillé') { locked++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, locked_by: d.locked_by }) }
else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) }
} else pushed++
}
- return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, error_samples: errSamples, samples }
+ return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples }
}
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
@@ -686,6 +710,136 @@ async function ticketThread (legacyId) {
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
}
+// ── VEILLE Legacy → Ops (poll léger → SSE) ──
+// Lecture seule (sauf auto-close immédiat, idempotent) : compare l'état legacy (status/assign_to/last_update) de TOUS
+// les tickets suivis à un snapshot mémoire, et DIFFUSE les deltas sur le topic SSE 'dispatch' (l'Ops rafraîchit en direct).
+// Détecte : fermeture (→ marque le Dispatch Job Completed tout de suite), réassignation hors-Ops, changement de statut,
+// nouvelle activité (last_update). Cadence courte (≈3 min) ≠ la sync d'import (15 min). Pas de verrou (read-only legacy+ERP).
+const _watchSnap = new Map() // ticket_id → { status, assign_to, last_update }
+let _watchTimer = null
+let _lastWatch = null
+async function watchLegacy () {
+ const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
+ const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'subject'], limit: 5000 })
+ const jobByTk = new Map(); const ids = []
+ for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) }
+ if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } }
+ const [rows] = await p.query('SELECT id, status, assign_to, last_update FROM ticket WHERE id IN (?)', [ids])
+ const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
+ const changes = []; let closedNow = 0; let pulled = 0
+ for (const r of (rows || [])) {
+ const id = String(r.id)
+ const prev = _watchSnap.get(id)
+ const cur = { status: r.status, assign_to: String(r.assign_to), last_update: Number(r.last_update) || 0 }
+ _watchSnap.set(id, cur)
+ const j = jobByTk.get(id); if (!j) continue
+ // (1) RÉCONCILIATION D'ÉTAT (à CHAQUE passage, même amorçage) : un job ENCORE dans le pool (open/On Hold, non
+ // assigné côté Ops) dont le ticket legacy est PRIS par un VRAI employé (≠3301, ≠0) et pas fermé → on le RETIRE
+ // du pool (status Cancelled). Couvre les tickets assignés dans Legacy AVANT le démarrage de la veille (ex. 250095).
+ const at = parseInt(cur.assign_to, 10) || 0
+ if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) {
+ try { await erp.update('Dispatch Job', j.name, { status: 'Cancelled' }); pulled++; changes.push({ ticket: id, job: j.name, kind: 'taken', to_staff: cur.assign_to, subject: j.subject || '' }) } catch (e) {}
+ continue // sorti du pool → pas d'autre delta à émettre
+ }
+ // (2) DELTAS diffusés — seulement une fois le snapshot amorcé
+ if (!seeded || !prev) continue
+ if (prev.status !== cur.status) {
+ if (cur.status === 'closed') {
+ try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closedNow++ } catch (e) {} // reflète tout de suite (idempotent)
+ changes.push({ ticket: id, job: j.name, kind: 'closed', subject: j.subject || '' })
+ } else changes.push({ ticket: id, job: j.name, kind: 'status', from: prev.status, to: cur.status, subject: j.subject || '' })
+ } else if (prev.assign_to !== cur.assign_to) {
+ changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: j.assigned_tech || '', subject: j.subject || '' })
+ } else if (prev.last_update !== cur.last_update) {
+ changes.push({ ticket: id, job: j.name, kind: 'activity', subject: j.subject || '' })
+ }
+ }
+ if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes })
+ _lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, changes: changes.length }
+ return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, changes }
+}
+
+// ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ──────────────────────────
+// 3 sources : staff legacy actif ↔ Dispatch Technician (ERPNext, TECH-) ↔ groupe Authentik 'tech' (accès Ops).
+// Le rapport liste les ÉCARTS ; aucune écriture Authentik. L'apply CRÉE des fiches Dispatch Technician (ERPNext) pour
+// les staff choisis (le répartiteur coche). Matching par EMAIL pour l'accès, par id (TECH-) pour la fiche.
+async function akGet (path) {
+ if (!cfg.AUTHENTIK_TOKEN) return null
+ const r = await httpRequest(cfg.AUTHENTIK_URL || 'https://auth.targo.ca', '/api/v3' + path, { method: 'GET', headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 12000 }).catch(() => null)
+ return r && r.data
+}
+async function techGroupEmailSet () {
+ const g = await akGet('/core/groups/?search=tech')
+ const set = new Set()
+ if (g && Array.isArray(g.results)) {
+ const grp = g.results.find(x => x.name === 'tech') || g.results[0]
+ for (const u of (grp && grp.users_obj) || []) { const e = (u.email || '').toLowerCase().trim(); if (e) set.add(e) }
+ return { ok: true, emails: set }
+ }
+ return { ok: false, emails: set }
+}
+async function techSyncReport () {
+ const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
+ const [staff] = await p.query("SELECT id, first_name, last_name, lower(trim(email)) email FROM staff WHERE status=1")
+ const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'status', 'email'], limit: 500 })
+ const ficheStaffIds = new Set(); const ficheEmails = new Set()
+ for (const t of techs) { const sid = (String(t.technician_id || t.name).match(/(\d+)$/) || [])[1]; if (sid) ficheStaffIds.add(sid); if (t.email) ficheEmails.add(String(t.email).toLowerCase().trim()) }
+ const ak = await techGroupEmailSet()
+ const staff_missing_fiche = []
+ for (const s of (staff || [])) {
+ if (ficheStaffIds.has(String(s.id))) continue
+ if (s.email && ficheEmails.has(s.email)) continue // déjà une fiche (matchée par courriel)
+ staff_missing_fiche.push({ staff_id: s.id, name: [s.first_name, s.last_name].filter(Boolean).join(' ') || ('staff ' + s.id), email: s.email || '', in_tech_group: !!(s.email && ak.emails.has(s.email)) })
+ }
+ const fiche_no_access = ak.ok ? techs.filter(t => { const e = (t.email || '').toLowerCase().trim(); return e && !ak.emails.has(e) }).map(t => ({ technician_id: t.technician_id, name: t.full_name, email: t.email })) : []
+ const fiche_no_email = techs.filter(t => !t.email).map(t => ({ technician_id: t.technician_id, name: t.full_name }))
+ const access_no_fiche = ak.ok ? [...ak.emails].filter(e => !ficheEmails.has(e)).sort() : []
+ return {
+ ok: true, authentik: ak.ok,
+ counts: { staff_active: (staff || []).length, fiches: techs.length, tech_group: ak.emails.size, missing_fiche: staff_missing_fiche.length, no_access: fiche_no_access.length, access_no_fiche: access_no_fiche.length },
+ staff_missing_fiche: staff_missing_fiche.sort((a, b) => b.staff_id - a.staff_id),
+ fiche_no_access, fiche_no_email, access_no_fiche,
+ }
+}
+async function techSyncApply (staffIds) {
+ const ids = [...new Set((staffIds || []).map(x => parseInt(x, 10)).filter(Boolean))]
+ if (!ids.length) return { ok: true, created: 0, results: [] }
+ const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
+ const [rows] = await p.query('SELECT id, first_name, last_name, email FROM staff WHERE id IN (?) AND status=1', [ids])
+ const byId = new Map((rows || []).map(r => [String(r.id), r]))
+ let created = 0; const results = []
+ for (const id of ids) { // SÉQUENTIEL (frappe_pg)
+ const s = byId.get(String(id)); if (!s) { results.push({ staff_id: id, ok: false, error: 'staff inactif/introuvable' }); continue }
+ const tid = 'TECH-' + id
+ const ex = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tid]], fields: ['name'], limit: 1 })
+ if (ex && ex.length) { results.push({ staff_id: id, ok: true, skipped: 'existe déjà', technician_id: tid }); continue }
+ const body = { technician_id: tid, full_name: [s.first_name, s.last_name].filter(Boolean).join(' ') || tid, resource_type: 'human', status: 'Disponible', efficiency: 1 }
+ if (s.email) body.email = s.email
+ try { await erp.create('Dispatch Technician', body); created++; results.push({ staff_id: id, ok: true, technician_id: tid }) } catch (e) { results.push({ staff_id: id, ok: false, error: String(e.message || e) }) }
+ }
+ return { ok: true, created, results }
+}
+
+// MINEUR de durées (baseline d'apprentissage SANS app) : approxime le temps sur-site par la fenêtre des réponses STAFF
+// d'un même ticket le même jour (< 8 h). Bruité à l'unité mais l'agrégat (médiane par dept/type) donne un point de départ.
+async function mineDurations () {
+ const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
+ const [rows] = await p.query(
+ `SELECT t.dept_id, (MAX(m.date_orig) - MIN(m.date_orig)) AS span
+ FROM ticket t JOIN ticket_msg m ON m.ticket_id = t.id
+ WHERE t.status='closed' AND t.date_closed > UNIX_TIMESTAMP() - 180*86400 AND m.staff_id > 0
+ GROUP BY t.id, t.dept_id HAVING span > 120 AND span < 8*3600`)
+ const byDept = {}
+ for (const r of (rows || [])) { (byDept[r.dept_id] || (byDept[r.dept_id] = [])).push(Number(r.span) / 60) }
+ const out = []
+ for (const dept in byDept) {
+ const arr = byDept[dept].sort((a, b) => a - b); if (arr.length < 3) continue
+ out.push({ dept_id: Number(dept), job_type: jobType(Number(dept)), n: arr.length, median_min: Math.round(arr[Math.floor(arr.length / 2)]), p75_min: Math.round(arr[Math.floor(arr.length * 0.75)]) })
+ }
+ out.sort((a, b) => b.n - a.n)
+ return { ok: true, note: 'Proxy bruité (fenêtre réponses staff même jour <8h, 180j). Baseline en attendant la capture GPS/app.', by_dept: out.slice(0, 25) }
+}
+
// ── Récurrence (setInterval) ──
let _timer = null
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
@@ -700,8 +854,14 @@ function startSync () {
setTimeout(tick, 90 * 1000)
_timer = setInterval(tick, minutes * 60 * 1000)
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
+ // VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import.
+ const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
+ const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
+ setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
+ _watchTimer = setInterval(wtick, watchMin * 60 * 1000)
+ log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)
}
-function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } }
+function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
async function handle (req, res, method, path) {
try {
@@ -714,7 +874,10 @@ async function handle (req, res, method, path) {
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') return json(res, 200, await pushAssignments({ dryRun: true })) // APERÇU write-back tech → legacy (0 écriture)
- if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '' })) // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik
+ if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel
+ const notify = new URL(req.url, 'http://localhost').searchParams.get('notify') !== '0'
+ return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify }))
+ }
if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed
const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' })
const r = await closeTicketLegacy(id, req.headers['x-authentik-email'] || '')
@@ -728,8 +891,19 @@ async function handle (req, res, method, path) {
if (r && r.ok && Array.isArray(r.results)) { for (const it of r.results) { if (it.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(it.t)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } } }
return json(res, 200, r)
}
+ if (path === '/dispatch/legacy-sync/return-to-pool' && method === 'POST') { // désassigne (ERPNext) + retour pool 3301 (legacy) — action EXPLICITE
+ const job = new URL(req.url, 'http://localhost').searchParams.get('job'); if (!job) return json(res, 400, { ok: false, error: 'job requis' })
+ return json(res, 200, await returnToPool(job, req.headers['x-authentik-email'] || ''))
+ }
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'GET') return json(res, 200, await purgeStaleOrphans({ dryRun: true })) // aperçu purge orphelins TT- périmés
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'POST') return json(res, 200, await purgeStaleOrphans({ dryRun: false })) // supprime
+ if (path === '/dispatch/legacy-sync/watch' && method === 'GET') return json(res, 200, await watchLegacy()) // poll manuel + deltas (diffuse aussi sur SSE 'dispatch')
+ if (path === '/dispatch/legacy-sync/tech-sync' && method === 'GET') return json(res, 200, await techSyncReport()) // rapport réconciliation techs (3 systèmes)
+ if (path === '/dispatch/legacy-sync/mine-durations' && method === 'GET') return json(res, 200, await mineDurations()) // baseline durées apprises (proxy réponses staff)
+ if (path === '/dispatch/legacy-sync/tech-sync/apply' && method === 'POST') { // crée les fiches Dispatch Technician choisies (ERPNext only)
+ const ids = (new URL(req.url, 'http://localhost').searchParams.get('ids') || '').split(',').map(s => s.trim()).filter(Boolean)
+ return json(res, 200, await techSyncApply(ids))
+ }
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
@@ -744,4 +918,4 @@ async function handle (req, res, method, path) {
}
}
-module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
+module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 08c5fe0..db47b5d 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -258,8 +258,66 @@ async function coverage (start, days) {
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
+function nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS"
+function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null }
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
+// ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ───────────────────────────────
+// Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON.
+// `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par
+// régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique.
+const JOBCHAR_PATH = '/app/data/job-characteristics.json'
+const JOBCHAR_SEED = { items: [
+ { id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' },
+ { id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' },
+ { id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' },
+ { id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' },
+ { id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' },
+ { id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' },
+ { id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' },
+ { id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' },
+ { id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' },
+ { id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' },
+ { id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu d’aérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' },
+ { id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' },
+ { id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' },
+ { id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' },
+ { id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' },
+ { id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' },
+ { id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' },
+ { id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' },
+ { id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' },
+ { id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' },
+ { id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' },
+ { id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' },
+ { id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' },
+] }
+function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } }
+function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } }
+// Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}].
+function estimateMinutes (picks, items) {
+ const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i
+ let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) }
+ return Math.round(m)
+}
+// Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte.
+function detectCharacteristics (job, items) {
+ const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
+ const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' '))
+ const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après »
+ const bases = items.filter(i => i.cat === 'base')
+ let base = bases.find(b => hit(b.keywords))
+ if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) }
+ const picks = base ? [{ id: base.id, qty: 1 }] : []
+ for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 })
+ return picks
+}
+function estimateForJob (job, items) {
+ items = items || readJobChar().items
+ const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i
+ return { minutes: estimateMinutes(picks, items), picks, labels: picks.map(p => (by[p.id] || {}).label).filter(Boolean) }
+}
+
// ── #56 Politique de créneaux offerts + holds temporaires ────────────────────
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
@@ -498,6 +556,109 @@ async function handlePublicBooking (req, res, method, path, url) {
return json(res, 404, { error: 'not found' })
}
+// ── APP TECHNICIEN : capture passive du temps via « checkpoints » (GPS geofence / scan / etc.) ──────────────
+// Token signé par job (HMAC, stateless, sans login ni champ DB). Voir docs/field-tech-app.md.
+const FIELD_SECRET = cfg.INTERNAL_TOKEN || cfg.ERP_SERVICE_TOKEN || 'targo-field-v1'
+const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « sur place » autour de l'adresse
+function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
+function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
+function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
+// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
+const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
+function techFieldSign (name) { return fieldSign('T:' + name) }
+function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
+function serveFieldApp (res) {
+ try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
+}
+function savePhoto (jobName, dataUrl) {
+ const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
+ const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 6 * 1024 * 1024) return null
+ const fs = require('fs'); const dir = '/app/uploads/field'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
+ const fn = 'f_' + String(jobName).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '.' + (m[1] === 'png' ? 'png' : 'jpg')
+ try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
+}
+async function handleFieldTech (req, res, method, path, url) {
+ const token = url.searchParams.get('t') || url.searchParams.get('token') || ''
+ if (path === '/field/job' && method === 'GET') {
+ const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
+ const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end', 'status'], limit: 1 })
+ const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
+ return json(res, 200, { ok: true, job: { ...j, actual_minutes: minutesBetween(j.actual_start, j.actual_end) } })
+ }
+ if (path === '/field/checkpoint' && method === 'POST') {
+ const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
+ const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'latitude', 'longitude', 'actual_start', 'actual_end', 'completion_notes', 'status'], limit: 1 })
+ const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
+ const ts = nowFrappe()
+ const lat = Number(b.lat), lon = Number(b.lon); const hasGps = isFinite(lat) && isFinite(lon)
+ let dist = null
+ if (hasGps && j.latitude != null && j.longitude != null && (Math.abs(+j.latitude) > 0.01)) dist = haversineM(+j.latitude, +j.longitude, lat, lon)
+ const onSite = (b.type === 'geo_exit') || dist == null || dist <= ON_SITE_M // hors-zone ignoré pour la durée, gardé en note
+ const patch = {}
+ if (onSite) { if (!j.actual_start) patch.actual_start = ts; patch.actual_end = ts; if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress' }
+ const note = `• ${ts} ${b.type || 'checkpoint'}${b.ref ? ' ' + String(b.ref).slice(0, 40) : ''}${hasGps ? ` · GPS ${lat.toFixed(5)},${lon.toFixed(5)}${b.acc ? ' ±' + Math.round(b.acc) + 'm' : ''}${dist != null ? ` · à ${dist}m` : ''}` : ''}${onSite ? '' : ' · HORS ZONE (ignoré durée)'}`
+ patch.completion_notes = ((j.completion_notes || '') + '\n' + note).slice(-4000)
+ const up = await retryWrite(() => erp.update('Dispatch Job', name, patch))
+ const startTs = j.actual_start || (onSite ? ts : null)
+ return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
+ }
+ if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
+ if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
+ const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
+ const dates = rangeDates(todayET(), 2) // aujourd'hui + demain
+ const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'start_time', 'status', 'actual_start', 'actual_end', 'legacy_ticket_id', 'legacy_activation_url'], orderBy: 'scheduled_date asc, start_time asc', limit: 200 })
+ const staffId = (String(tech).match(/(\d+)$/) || [])[1] || ''
+ const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '' }))
+ return json(res, 200, { ok: true, tech, jobs })
+ }
+ if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job
+ const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
+ const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })
+ const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); const j = r && r[0]
+ const note = `\n• ${nowFrappe()} 📷 photo ${fn}${b.note ? ' — ' + String(b.note).slice(0, 60) : ''}`
+ await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
+ return json(res, 200, { ok: true, file: fn })
+ }
+ if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
+ const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
+ const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30)
+ if (!serial && !mac) return json(res, 400, { ok: false, error: 'série ou MAC requis' })
+ const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes', 'actual_start', 'status'], limit: 1 }); const j = r && r[0]
+ const ts = nowFrappe(); const patch = { completion_notes: (((j && j.completion_notes) || '') + `\n• ${ts} 📦 ${kind}${serial ? ' SN ' + serial : ''}${mac ? ' MAC ' + mac : ''}`).slice(-4000), actual_end: ts }
+ if (j && !j.actual_start) patch.actual_start = ts // un scan sur place = preuve de présence (démarre le chrono)
+ if (j && (j.status === 'open' || j.status === 'assigned')) patch.status = 'In Progress'
+ await retryWrite(() => erp.update('Dispatch Job', name, patch))
+ return json(res, 200, { ok: true, serial, mac, kind, genieacs: 'à brancher par MAC' })
+ }
+ if (path === '/field/ts' && method === 'POST') { // webhook geofence natif Transistorsoft (autoSync) → checkpoints. Auto-authentifié : identifier = token signé du job.
+ const b = await parseBody(req)
+ const locs = Array.isArray(b.location) ? b.location : (b.location ? [b.location] : (Array.isArray(b) ? b : []))
+ let applied = 0
+ for (const L of locs) {
+ const g = L && L.geofence; if (!g || !g.identifier) continue
+ const name = fieldVerify(g.identifier); if (!name) continue
+ const type = String(g.action).toUpperCase() === 'EXIT' ? 'geo_exit' : 'geo_enter'
+ const c = (L.coords) || {}; const ts = nowFrappe()
+ const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'actual_start', 'completion_notes', 'status'], limit: 1 }); const j = r && r[0]; if (!j) continue
+ const patch = { actual_end: ts, completion_notes: (((j.completion_notes) || '') + `\n• ${ts} ${type} (geofence natif)${c.latitude ? ` · GPS ${(+c.latitude).toFixed(5)},${(+c.longitude).toFixed(5)}` : ''}`).slice(-4000) }
+ if (type === 'geo_enter' && !j.actual_start) patch.actual_start = ts
+ if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress'
+ await retryWrite(() => erp.update('Dispatch Job', name, patch)).catch(() => {}); applied++
+ }
+ return json(res, 200, { ok: true, applied })
+ }
+ if (path === '/field/vision' && method === 'POST') { // photo d'étiquette → proxy IA Gemini → {brand,model,serial_number,mac_address}
+ const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
+ if (!b.image) return json(res, 400, { ok: false, error: 'image requise' })
+ try { const eq = await require('./vision').extractEquipment(String(b.image).replace(/^data:image\/[^;]+;base64,/, '')); return json(res, 200, { ok: true, ...eq }) }
+ catch (e) { return json(res, 500, { ok: false, error: String(e.message || e) }) }
+ }
+ return json(res, 404, { ok: false, error: 'not found' })
+}
+// Génère le lien terrain d'un job (pour SMS/app/QR) — exposé via une route interne.
+function fieldLink (name) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + fieldSign(name) }
+function techFieldLink (techName) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + techFieldSign(techName) }
+
// Stats par jour : effectif (techs distincts), heures TRAVAILLÉES, tickets dispatch.
// La garde (on_call) = mise en disponibilité → exclue des heures travaillées.
async function statsByDay (start, days) {
@@ -525,20 +686,23 @@ async function occupancyByTechDay (start, days) {
const dates = rangeDates(start, days)
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
- fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location'], limit: 5000,
+ fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end'], limit: 5000,
})
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
+ const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job
const m = {}
for (const j of jobs) {
if (!j.assigned_tech || !j.scheduled_date) continue
const k = j.assigned_tech + '|' + j.scheduled_date
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
- const dur = Number(j.duration_h) || 0
+ const estMin = estimateForJob(j, occChars).minutes
+ const dur = estMin ? estMin / 60 : (Number(j.duration_h) || 0) // estimation additive (sinon duration_h)
o.h += dur
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
- o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400) })
+ const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
+ o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
}
// ordre = route_order manuel s'il existe, sinon priorité puis heure
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
@@ -567,6 +731,56 @@ async function resolveTechName (techId) {
return f.length ? f[0].name : null
}
+// ── CAPACITÉ par jour, découpée en segments AM / PM / Soir ──────────────────
+// Capacité d'un segment = Σ (recouvrement du quart de chaque tech assigné ce jour-là avec la plage du segment).
+// Utilisé = Σ (recouvrement des jobs PLACÉS (start_time) ce jour-là avec le segment).
+// Libre = capacité − utilisé (heures-tech restantes). due_h = Σ durée estimée des jobs DUS ce jour (placés ou non) ;
+// unplaced_h = part des dus non encore placés. overbooked = due_h > capacité totale du jour.
+const DAY_SEGMENTS = [
+ { key: 'am', label: 'AM', from: 8, to: 12 },
+ { key: 'pm', label: 'PM', from: 12, to: 17 },
+ { key: 'soir', label: 'Soir', from: 17, to: 21 },
+]
+const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
+const r1 = (x) => Math.round(x * 10) / 10
+async function capacityByDay (start, days) {
+ const dates = rangeDates(start, days)
+ const templates = await fetchTemplates()
+ const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
+ const asgs = await fetchAssignments(start, days) // Proposé + Publié = présence planifiée du tech
+ const jobs = await erp.list('Dispatch Job', {
+ filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]],
+ fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'assigned_tech'], limit: 5000,
+ })
+ const out = {}
+ for (const d of dates) out[d] = { date: d, segments: DAY_SEGMENTS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 })), cap_h: 0, used_h: 0, free_h: 0, due_h: 0, jobs_due: 0, unplaced_h: 0, techs: 0 }
+ const techSeen = {}
+ for (const a of asgs) {
+ const o = out[a.date]; if (!o) continue
+ const w = tpl[a.shift]; if (!w || !(w.e > w.s)) continue
+ DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].cap += segOverlap(w.s, w.e, seg.from, seg.to) })
+ const tk = a.date + '|' + a.tech; if (!techSeen[tk]) { techSeen[tk] = 1; o.techs++ }
+ }
+ for (const j of jobs) {
+ const o = out[j.scheduled_date]; if (!o) continue
+ const dur = Number(j.duration_h) || 0
+ o.due_h += dur; o.jobs_due++
+ const sh = j.start_time ? timeToH(j.start_time) : null
+ if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) })
+ else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire
+ }
+ for (const d of dates) {
+ const o = out[d]
+ o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) })
+ o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0))
+ o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0))
+ o.free_h = r1(o.cap_h - o.used_h)
+ o.due_h = r1(o.due_h); o.unplaced_h = r1(o.unplaced_h)
+ o.overbooked = o.cap_h > 0 && o.due_h > o.cap_h
+ }
+ return out
+}
+
async function handle (req, res, method, path, url) {
const qs = url.searchParams
const start = qs.get('start')
@@ -609,6 +823,22 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { coverage: await coverage(start, days) })
}
+ if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir)
+ if (!start) return json(res, 400, { error: 'start requis' })
+ return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
+ }
+ if (path === '/roster/job-characteristics' && method === 'GET') return json(res, 200, { ok: true, ...readJobChar() }) // table additive de durées (éditable)
+ if (path === '/roster/job-characteristics' && method === 'POST') { // sauvegarde du tableur inline
+ const b = await parseBody(req); if (!Array.isArray(b.items)) return json(res, 400, { ok: false, error: 'items requis' })
+ const clean = b.items.filter(i => i && i.id && i.label).map(i => ({ id: String(i.id), cat: ['base', 'addon', 'modifier'].includes(i.cat) ? i.cat : 'addon', label: String(i.label).slice(0, 80), minutes: Math.max(0, Math.round(Number(i.minutes) || 0)), per_qty: !!i.per_qty, keywords: String(i.keywords || '').slice(0, 160), learned_min: i.learned_min != null ? Math.round(Number(i.learned_min)) : null, samples: Number(i.samples) || 0 }))
+ const ok = writeJobChar(clean); return json(res, ok ? 200 : 500, { ok, count: clean.length })
+ }
+ if (path === '/roster/tech-link' && method === 'GET') { // lien app PWA par tech (à copier / SMS) + son téléphone
+ const tech = qs.get('tech'); if (!tech) return json(res, 400, { ok: false, error: 'tech requis' })
+ const rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['name', 'full_name', 'phone'], limit: 1 })
+ const t = rows && rows[0]
+ return json(res, 200, { ok: true, tech, name: (t && t.full_name) || tech, phone: (t && t.phone) || '', url: techFieldLink(tech) })
+ }
if (path === '/roster/stats' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { stats: await statsByDay(start, days) })
@@ -752,7 +982,12 @@ async function handle (req, res, method, path, url) {
if (path === '/roster/unassigned-jobs' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
- for (const j of jobs) j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
+ const chars = readJobChar().items // table additive (1 lecture)
+ for (const j of jobs) {
+ j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
+ const est = estimateForJob(j, chars) // auto-détection DÉTERMINISTE (mots-clés) → durée additive estimée
+ j.est_min = est.minutes; j.est_labels = est.labels
+ }
await attachLocations(jobs)
return json(res, 200, { jobs })
}
@@ -797,6 +1032,32 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
}
+ // Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
+ if (path === '/roster/job/set-location' && method === 'POST') {
+ const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
+ const lat = Number(b.lat), lon = Number(b.lon)
+ if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return json(res, 400, { error: 'lat/lon invalides' })
+ const patch = { latitude: lat, longitude: lon }
+ if (b.address != null && String(b.address).trim() !== '') patch.address = String(b.address).slice(0, 140)
+ const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
+ return json(res, r.ok ? 200 : 500, { ...r, job: b.job, latitude: lat, longitude: lon })
+ }
+ // CHRONO (boucle de capture) : début/fin réels → durée réelle (carburant de l'apprentissage des durées par type×tech).
+ if (path === '/roster/job/start' && method === 'POST') {
+ const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
+ const now = nowFrappe()
+ const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { actual_start: now, actual_end: '', status: 'In Progress' }))
+ return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_start: now })
+ }
+ if (path === '/roster/job/finish' && method === 'POST') {
+ const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
+ const cur = await erp.list('Dispatch Job', { filters: [['name', '=', b.job]], fields: ['actual_start'], limit: 1 })
+ const startTs = (cur && cur[0] && cur[0].actual_start) || null
+ const now = nowFrappe()
+ const patch = { actual_end: now, status: 'Completed' }; if (!startTs) patch.actual_start = now // borne si jamais démarré
+ const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
+ return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_minutes: minutesBetween(startTs || now, now) })
+ }
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
if (path === '/roster/backfill-start-times' && method === 'POST') {
@@ -1037,4 +1298,4 @@ async function handle (req, res, method, path, url) {
return json(res, 404, { error: 'roster: route inconnue ' + path })
}
-module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
+module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
diff --git a/services/targo-hub/lib/vision.js b/services/targo-hub/lib/vision.js
index d08d635..d236872 100644
--- a/services/targo-hub/lib/vision.js
+++ b/services/targo-hub/lib/vision.js
@@ -79,21 +79,21 @@ const EQUIP_SCHEMA = {
required: ['serial_number'],
}
+// Extraction équipement réutilisable (étiquette modem/ONU → marque/modèle/série/MAC). base64 SANS préfixe data:.
+async function extractEquipment (base64) {
+ const parsed = await geminiVision(base64, EQUIP_PROMPT, EQUIP_SCHEMA)
+ if (!parsed) return { serial_number: null, barcodes: [] }
+ if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
+ if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
+ log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
+ return parsed
+}
async function handleEquipment (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'equipment')
if (check.error) return json(res, check.status, { error: check.error })
- try {
- const parsed = await geminiVision(check.base64, EQUIP_PROMPT, EQUIP_SCHEMA)
- if (!parsed) return json(res, 200, { serial_number: null, barcodes: [] })
- if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
- if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
- log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
- return json(res, 200, parsed)
- } catch (e) {
- log('Vision equipment error:', e.message)
- return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
- }
+ try { return json(res, 200, await extractEquipment(check.base64)) }
+ catch (e) { log('Vision equipment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
}
// ─── Invoice / bill OCR ────────────────────────────────────────────────
@@ -249,4 +249,4 @@ async function handleFieldScan (req, res) {
}
}
-module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice, extractField, handleFieldScan }
+module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractField, handleFieldScan }
diff --git a/services/targo-hub/public/field-app.html b/services/targo-hub/public/field-app.html
new file mode 100644
index 0000000..2ccb1bd
--- /dev/null
+++ b/services/targo-hub/public/field-app.html
@@ -0,0 +1,148 @@
+
+
+Targo Tech
+
+
+
+
+
+
+
+
Mes interventions
+
Chargement…
+
+
+
+
‹ Retour
+
+
+
+
+
+
+
📍 Je suis arrivé
+
+
+
+
+
+
+
+
✕ Fermer Scanner code-barre / saisir
+
+
📸 Lire l'étiquette (IA)
+
+
+
+
+
Enregistrer l'appareil
+
+
+
diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js
index cbd78d2..23fc3c9 100644
--- a/services/targo-hub/server.js
+++ b/services/targo-hub/server.js
@@ -134,6 +134,8 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
+ // App technicien — capture passive du temps (checkpoints GPS/scan), token signé par job, PUBLIC (pas de SSO).
+ if (path === '/field' || path.startsWith('/field/')) return require('./lib/roster').handleFieldTech(req, res, method, path, url)
// Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
// Boutique matériel (page modèle, staging) — page + (à venir) catalogue ERPNext, PUBLIC.