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 @@
Ressource{{ hiddenCount }}{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}
{{ d.dow }}
{{ d.dnum }}
+
+ {{ capFmt(s.free) }} + {{ s.label }} — {{ capFmt(s.free) }} h libre / {{ capFmt(s.cap) }} h · occupé {{ capFmt(s.used) }} h
{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'toutes ressources affichées' }} ({{ visibleTechs.length }} tech)
+
+ + Surcharge : {{ capFmt(capByDay[d.iso].due_h) }} h dues > {{ capFmt(capByDay[d.iso].cap_h) }} h de capacité ({{ capByDay[d.iso].jobs_due }} job(s)) + +
Marquer fériéF
@@ -172,6 +182,7 @@ {{ t.group }} {{ hoursOf(t.id) }}h Timeline dispatch de {{ t.name }} — jobs de la semaine & priorités + Lien app terrain de {{ t.name }} (copier / envoyer par SMS)
{{ 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
+ + +
+ + +
+
+ + + + + +
⏱ 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égorieLibellé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)
+
· ⚠ 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…
+
+ + +
+ + +
+ 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.