Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy. - Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée client-side sur les techs visibles -> suit le filtre de compétence. - Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool. - Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/ device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini). - Chrono job (start/finish), repositionnement carte (set-location), vue satellite, Street View clic-droit, année devant les dates dues groupées. - Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches). - vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres). - Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour au pool ; notification courriel à l'assignation. Déployé sur le hub ; ce commit aligne le repo sur l'état en production. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
922 lines
71 KiB
JavaScript
922 lines
71 KiB
JavaScript
'use strict'
|
||
/**
|
||
* legacy-dispatch-sync.js — PONT legacy (osTicket/MariaDB) → Dispatch Job (ERPNext).
|
||
*
|
||
* Tire RÉGULIÈREMENT les tickets ouverts assignés au compte « Tech Targo »
|
||
* (staff id 3301 dans la DB legacy `gestionclient`) et crée/maj un Dispatch Job
|
||
* dans ERPNext pour les répartir sur la grille Planification / le tableau Dispatch.
|
||
*
|
||
* Pourquoi 3301 : dans le legacy, le travail terrain à dispatcher est assigné au
|
||
* compte générique « Tech Targo » (default_staff des dépts Installation/Réparation/
|
||
* Fibre). C'est exactement « les tickets assignés à tech targo ».
|
||
*
|
||
* IDEMPOTENT : chaque ticket legacy porte un `legacy_ticket_id` sur le Dispatch Job.
|
||
* On cherche avant de créer → jamais de doublon. On NE clobbe PAS le travail du
|
||
* répartiteur : un job déjà assigné/déplacé n'est plus touché (maj de date seulement
|
||
* tant qu'il est encore `open` + non assigné).
|
||
*
|
||
* Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run
|
||
* Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off.
|
||
*
|
||
* Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
|
||
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
|
||
*/
|
||
const erp = require('./erp')
|
||
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.
|
||
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
|
||
function opsLegacyToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
|
||
async function legacyWrite (fields) { // POST form-urlencoded + X-Ops-Token → ops_reassign.php
|
||
const body = Object.entries(fields).map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v == null ? '' : v)).join('&')
|
||
return httpRequest(OPS_LEGACY_URL, '', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': opsLegacyToken() }, timeout: 15000 })
|
||
}
|
||
// Ferme un ticket dans le legacy (via PHP : status=closed + date_closed + closed_by=acteur + réouverture enfants + log).
|
||
async function closeTicketLegacy (ticketId, actorEmail) {
|
||
let actorStaff = 0
|
||
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]) actorStaff = ar[0].id } } catch (e) {}
|
||
const w = await legacyWrite({ action: 'close', ticket_id: ticketId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||
return (w && w.data) || { ok: false, error: 'no response' }
|
||
}
|
||
// Fermeture EN LOT : 1 seul appel PHP (1 connexion) pour N tickets → bien plus rapide que N appels.
|
||
async function batchCloseLegacy (ticketIds, actorEmail) {
|
||
let actorStaff = 0
|
||
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]) actorStaff = ar[0].id } } catch (e) {}
|
||
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
|
||
// un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »).
|
||
const CAMP_PLACE_KW = ['lac des pins', 'lac de pins', 'sandysun', 'sandy sun', 'frontiere', 'ensoleill']
|
||
let _campings = null; let _campingsAt = 0
|
||
async function getCampings () {
|
||
if (_campings && (Date.now() - _campingsAt) < 600000) return _campings // cache 10 min
|
||
try { const r = await addrdb.pool().query('SELECT keyword, name, latitude, longitude FROM camping_registry WHERE active'); _campings = r.rows; _campingsAt = Date.now() } catch (e) { _campings = _campings || [] }
|
||
return _campings
|
||
}
|
||
// fields en ORDRE DE PRIORITÉ (sujet d'abord = label de service explicite, puis ville/adresse de delivery).
|
||
// Le 1er champ qui contient un signal camping décide → évite qu'une ville de delivery (résidence) écrase le sujet.
|
||
function campingFor (campings, fields) {
|
||
for (const f of (Array.isArray(fields) ? fields : [fields])) {
|
||
const t = norm(f || '')
|
||
if (!(t.includes('camping') || CAMP_PLACE_KW.some(k => t.includes(k)))) continue
|
||
for (const c of campings) if (c.keyword && t.includes(c.keyword)) return c
|
||
}
|
||
return null
|
||
}
|
||
let mysql
|
||
try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ }
|
||
|
||
const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch)
|
||
|
||
// Parseurs/mapping PURS extraits dans util/legacy-parse (testables en isolation) :
|
||
// DEPT_JOBTYPE/DUR + jobType/prio/tzDate/startTime/coord. + norm (util/text). (Phase 1 : logique pure séparée des I/O.)
|
||
const { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord } = require('./util/legacy-parse')
|
||
const { norm } = require('./util/text')
|
||
|
||
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
|
||
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
|
||
// → chaque adresse n'est géocodée qu'une fois par cycle de vie du hub ; les échecs sont mémorisés
|
||
// (valeur null) pour ne PAS marteler RQA à chaque cycle. N'accepte qu'une correspondance fiable (≥0.7).
|
||
// Géocodage RQA via la RECHERCHE TRIGRAM (RPC `search_addresses`, pg_trgm) — celle de l'autocomplete de
|
||
// dispo. Trouve les rues que l'ilike manquait (générique géré par la colonne `long` + trigram phase 2).
|
||
// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette
|
||
// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet
|
||
// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés).
|
||
// TERRITOIRE de service TARGO (Montérégie sud-ouest : Haut-St-Laurent, Beauharnois-Salaberry, Jardins-de-Napierville,
|
||
// Roussillon). Rejette les géocodes hors-zone — Mapbox/trigram tombent sinon sur des HOMONYMES (Gaspésie, Outaouais,
|
||
// Estrie : « Saint-Louis », « Franklin », « Lac des Pins »…). bbox volontairement large mais bien en deçà du reste du QC.
|
||
const TERR = { latMin: 44.85, latMax: 45.6, lonMin: -74.85, lonMax: -73.2 }
|
||
function inTerritory (lat, lon) { const a = parseFloat(lat), o = parseFloat(lon); return isFinite(a) && isFinite(o) && a >= TERR.latMin && a <= TERR.latMax && o >= TERR.lonMin && o <= TERR.lonMax }
|
||
|
||
// Repli CENTROÏDE (code postal complet, sinon ville) depuis rqa_addresses LOCAL — « au pire, le centre du CP/ville ».
|
||
// Toujours EN territoire (filtré par bbox). Cache module.
|
||
const _centroidCache = new Map()
|
||
async function geocodeCentroid (postalCode, city) {
|
||
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase()
|
||
const key = 'ctr|' + fsa + '|' + norm(city)
|
||
if (_centroidCache.has(key)) return _centroidCache.get(key)
|
||
let res = null
|
||
try {
|
||
const pg = addrdb.pool()
|
||
if (fsa.length >= 6) { // centroïde du code postal complet (zone fine, ex. J0S1T0)
|
||
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE replace(upper(code_postal), ' ', '') = $1", [fsa])
|
||
const row = r.rows[0]
|
||
if (row && Number(row.n) > 0 && inTerritory(row.lat, row.lon)) res = { lat: +row.lat, lon: +row.lon }
|
||
}
|
||
if (!res && city) { // sinon centroïde de la VILLE, contraint au territoire
|
||
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE lower(unaccent(ville)) = lower(unaccent($1)) AND latitude BETWEEN $2 AND $3 AND longitude BETWEEN $4 AND $5", [city, TERR.latMin, TERR.latMax, TERR.lonMin, TERR.lonMax])
|
||
const row = r.rows[0]
|
||
if (row && Number(row.n) > 0) res = { lat: +row.lat, lon: +row.lon }
|
||
}
|
||
} catch (e) { log('geocodeCentroid error:', e.message) }
|
||
_centroidCache.set(key, res)
|
||
return res
|
||
}
|
||
|
||
const _geoCache = new Map()
|
||
async function geocodeRQA (addressLine, postalCode, city) {
|
||
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
|
||
if (!key || !addressLine) return null
|
||
if (_geoCache.has(key)) return _geoCache.get(key)
|
||
let res = null
|
||
try {
|
||
const rows = await searchAddressesRpc(addressLine, 8)
|
||
if (rows && rows.length) {
|
||
const civic = (String(addressLine).match(/^\s*(\d+)/) || [])[1] || null
|
||
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
|
||
const cityN = norm(city)
|
||
const GEN = ['rue', 'rang', 'chemin', 'ch', 'route', 'rte', 'avenue', 'av', 'ave', 'boul', 'boulevard', 'bd', 'montee', 'cote', 'place', 'pl', 'allee', 'terrasse', 'croissant', 'des', 'de', 'du', 'la', 'le', 'aux']
|
||
const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue
|
||
const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) }
|
||
const pick = rows.find(r => {
|
||
if (!coord(r.latitude, r.longitude) || !inTerritory(r.latitude, r.longitude)) return false // hors territoire = homonyme → rejet
|
||
if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet
|
||
if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet
|
||
const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
|
||
// En TERRITOIRE Targo (J0L/J0S, déjà priorisé par la RPC + filtrage mots-de-rue en phase 1) → on fait
|
||
// confiance au classement RPC (= l'autocomplete client). Civique + rue concordent déjà.
|
||
if (rFsa === 'J0L' || rFsa === 'J0S') return true
|
||
// Hors territoire → exiger une concordance EXPLICITE avec l'enregistrement legacy (CP OU ville),
|
||
// sinon rejet (ex. faux positif trigram « Rue Grenet, Montréal H4L »).
|
||
const rCity = norm(r.nom_municipalite)
|
||
const postalOk = !!(fsa && rFsa && rFsa === fsa)
|
||
const cityOk = !!(cityN && rCity && (rCity.includes(cityN) || cityN.includes(rCity) || rCity.split('-')[0] === cityN.split('-')[0]))
|
||
return postalOk || cityOk
|
||
})
|
||
if (pick) res = coord(pick.latitude, pick.longitude)
|
||
}
|
||
} catch (e) { log('geocodeRQA error:', e.message) } // RQA indispo → pas de coords (échec mémorisé)
|
||
_geoCache.set(key, res)
|
||
return res
|
||
}
|
||
|
||
// Repli Mapbox (token public déjà utilisé par le Dispatch) pour les rues TROP RÉCENTES pour le RQA
|
||
// (nouveaux développements absents du répertoire). Moins précis en rural que le RQA mais « une coord
|
||
// vaut mieux que zéro » pour le routage. Contraint au Québec (country=ca + proximity Montérégie +
|
||
// bornes coord()). Cache module. Désactivé si MAPBOX_TOKEN absent de l'env.
|
||
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || ''
|
||
const _mbCache = new Map()
|
||
async function geocodeMapbox (addressLine, city, postalCode) {
|
||
if (!MAPBOX_TOKEN || !addressLine) return null
|
||
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
|
||
if (_mbCache.has(key)) return _mbCache.get(key)
|
||
let res = null
|
||
try {
|
||
const q = [addressLine, city, 'Québec'].filter(Boolean).join(', ')
|
||
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +
|
||
`?country=ca&proximity=-73.5,45.2&limit=1&types=address&language=fr&access_token=${MAPBOX_TOKEN}`
|
||
const r = await httpRequest(url, '', { timeout: 12000 })
|
||
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
|
||
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
|
||
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
|
||
if (c && inTerritory(c.lat, c.lon)) res = c // hors territoire (proximité insuffisante → homonyme lointain) → rejet
|
||
}
|
||
} catch (e) { log('geocodeMapbox error:', e.message) }
|
||
_mbCache.set(key, res)
|
||
return res
|
||
}
|
||
|
||
let _pool
|
||
function pool () {
|
||
if (!mysql) return null
|
||
if (!_pool) {
|
||
_pool = mysql.createPool({
|
||
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
|
||
connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
|
||
})
|
||
}
|
||
return _pool
|
||
}
|
||
|
||
// Lien d'activation STB/Ministra : DÉJÀ posté dans le fil du ticket par le wizard legacy à la vente.
|
||
// On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient.
|
||
const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i
|
||
function extractActivationUrl (msg) { if (!msg) return ''; const m = String(msg).match(ACTIVATION_RE); return m ? m[0] : '' }
|
||
// Détail du ticket = 1er message du fil legacy (HTML osTicket) → texte lisible, tronqué, pour l'afficher dans Ops.
|
||
function stripHtml (html, max = 1500) {
|
||
if (!html) return ''
|
||
let s = String(html)
|
||
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||
.replace(/�*39;|'|'/gi, "'").replace(/"/gi, '"')
|
||
.replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)) } catch (e) { return '' } })
|
||
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(parseInt(n, 10)) } catch (e) { return '' } })
|
||
.replace(/&(eacute|egrave|ecirc|euml|agrave|acirc|aacute|ccedil|ocirc|ouml|ugrave|ucirc|icirc|iuml|ntilde|deg|laquo|raquo|hellip|rsquo|lsquo|ldquo|rdquo|ndash|mdash|nbsp|euro);/gi, (m, e) => ({ eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë', agrave: 'à', acirc: 'â', aacute: 'á', ccedil: 'ç', ocirc: 'ô', ouml: 'ö', ugrave: 'ù', ucirc: 'û', icirc: 'î', iuml: 'ï', ntilde: 'ñ', deg: '°', laquo: '«', raquo: '»', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', ndash: '–', mdash: '—', nbsp: ' ', euro: '€' }[e.toLowerCase()] || m))
|
||
.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
||
if (s.length > max) s = s.slice(0, max) + '…'
|
||
return s
|
||
}
|
||
|
||
async function fetchTargoTickets () {
|
||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||
const [rows] = await p.query(
|
||
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
|
||
t.date_create, t.last_update,
|
||
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
|
||
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
|
||
(SELECT mm.msg FROM ticket_msg mm
|
||
WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%'
|
||
ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
|
||
(SELECT mm3.msg FROM ticket_msg mm3
|
||
WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
|
||
FROM ticket t
|
||
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||
LEFT JOIN account a ON a.id = t.account_id
|
||
LEFT JOIN delivery dv ON dv.id = COALESCE(
|
||
NULLIF(t.delivery_id, 0),
|
||
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
|
||
)
|
||
WHERE t.status = 'open' AND t.assign_to = ?
|
||
ORDER BY t.due_date DESC`,
|
||
[TARGO_TECH_STAFF_ID],
|
||
)
|
||
return rows || []
|
||
}
|
||
|
||
// caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
|
||
let _custCache = new Map()
|
||
let _slCache = new Map()
|
||
function resetCaches () { _custCache = new Map(); _slCache = new Map() }
|
||
|
||
async function resolveCustomer (accountId) {
|
||
if (!accountId) return null
|
||
const k = String(accountId)
|
||
if (_custCache.has(k)) return _custCache.get(k)
|
||
const r = await erp.list('Customer', { filters: [['legacy_account_id', '=', k]], fields: ['name', 'customer_name'], limit: 1 })
|
||
const c = (r && r[0]) || null
|
||
_custCache.set(k, c)
|
||
return c
|
||
}
|
||
async function resolveServiceLocation (custName, city) {
|
||
if (!custName) return null
|
||
let list = _slCache.get(custName)
|
||
if (!list) {
|
||
list = (await erp.list('Service Location', { filters: [['customer', '=', custName]], fields: ['name', 'address_line', 'city', 'latitude', 'longitude'], limit: 10 })) || []
|
||
_slCache.set(custName, list)
|
||
}
|
||
if (!list.length) return null
|
||
if (city) { const hit = list.find(l => norm(l.city) === norm(city)); if (hit) return hit } // préfère la ville qui matche
|
||
return list[0]
|
||
}
|
||
|
||
// Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching).
|
||
async function buildJob (t) {
|
||
const cust = await resolveCustomer(t.account_id)
|
||
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
|
||
const jt = jobType(t.dept_id)
|
||
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '')
|
||
// Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la
|
||
// source la plus fiable (lat/long par adresse). On préfère donc l'adresse de service à l'adresse
|
||
// de facturation du compte, et les coords delivery aux coords Service Location ERPNext (placeholders).
|
||
const dc = coord(t.dv_lat, t.dv_lon)
|
||
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
|
||
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
|
||
const addr = svcAddr || billAddr
|
||
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
|
||
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
|
||
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
|
||
|
||
const payload = {
|
||
ticket_id: 'LEG-' + t.id,
|
||
subject,
|
||
job_type: jt,
|
||
duration_h: DUR[jt] || 1,
|
||
priority: prio(t.priority),
|
||
status: 'open',
|
||
order_source: 'Manual',
|
||
legacy_ticket_id: String(t.id),
|
||
legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…)
|
||
}
|
||
if (addr) payload.address = addr.slice(0, 140) // adresse de SERVICE (delivery, sinon facturation) EN CLAIR — toujours importée, indépendamment du lien Service Location / des coords (champ Frappe Data = 140 car.)
|
||
const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil)
|
||
// En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description.
|
||
const dateHdr = '🎫 Ticket #' + t.id + ' · 🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
|
||
const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
|
||
if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch)
|
||
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
|
||
const st = startTime(t.due_time); if (st) payload.start_time = st
|
||
if (cust) payload.customer = cust.name
|
||
let coordSrc = null
|
||
// CAMPING (priorité max) : l'adresse de service est un terrain de camping → géoloc FIXE du camping,
|
||
// pas la résidence du client. Signal = sujet/ville/adresse de service du ticket.
|
||
const camp = campingFor(await getCampings(), [t.subject, t.dv_city, t.dv_addr])
|
||
if (camp) { payload.latitude = camp.latitude; payload.longitude = camp.longitude; coordSrc = 'camping' }
|
||
if (!coordSrc && dc) { payload.latitude = dc.lat; payload.longitude = dc.lon; coordSrc = 'delivery' } // point de service legacy
|
||
if (sl) {
|
||
payload.service_location = sl.name
|
||
if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si pas de delivery
|
||
}
|
||
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA (autoritaire) puis Mapbox (couverture)
|
||
const useSvc = !!svcAddr
|
||
const line = useSvc ? t.dv_addr : t.address1
|
||
const zip = useSvc ? t.dv_zip : t.zip
|
||
const ci = useSvc ? t.dv_city : t.city
|
||
const g = await geocodeRQA(line, zip, ci)
|
||
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
|
||
else { const mb = await geocodeMapbox(line, ci, zip); if (mb) { payload.latitude = mb.lat; payload.longitude = mb.lon; coordSrc = 'mapbox_geocode' } }
|
||
}
|
||
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null }, dept: t.dept, addr }
|
||
}
|
||
|
||
async function findExisting (legacyId) {
|
||
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address'], limit: 1 })
|
||
return (r && r[0]) || null
|
||
}
|
||
|
||
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
|
||
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
|
||
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
|
||
let _syncLock = Promise.resolve()
|
||
function sync (opts = {}) {
|
||
const run = _syncLock.then(() => syncImpl(opts), () => syncImpl(opts))
|
||
_syncLock = run.then(() => {}, () => {}) // le suivant attend, quel que soit le résultat
|
||
return run
|
||
}
|
||
|
||
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
|
||
async function syncImpl ({ dryRun = false } = {}) {
|
||
resetCaches()
|
||
const tickets = await fetchTargoTickets()
|
||
let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0, coordsFilled = 0, noCoords = 0
|
||
const coordTally = {} // observabilité : répartition des sources de coords (delivery/service_location/rqa_geocode/none)
|
||
const errSamples = [] // observabilité : échantillon des erreurs create/update (« ne rien échapper »)
|
||
const details = []
|
||
for (const t of tickets) {
|
||
try {
|
||
const b = await buildJob(t)
|
||
if (!b.matched.customer) unmatched++
|
||
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
|
||
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
|
||
const ex = await findExisting(b.legacy_id)
|
||
if (ex) {
|
||
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
|
||
// s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur.
|
||
const patch = {}
|
||
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
|
||
if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque)
|
||
if (b.payload.legacy_detail && ex.legacy_detail !== b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // (re)backfill description + dates (idempotent : ne réécrit que si différent)
|
||
// Coords (localisation, sans risque pour l'ordonnancement) : on remplit si absentes/0 côté ERPNext,
|
||
// ET on UPGRADE vers les coords `delivery` (point de service exact) si elles diffèrent des coords
|
||
// existantes (souvent issues du Service Location, moins précises). delivery écrase ; SL/RQA non.
|
||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||
const exHas = hasCoord(ex.latitude) && hasCoord(ex.longitude)
|
||
// delivery (point exact) ET camping (géoloc fixe du camping vs résidence) ÉCRASENT des coords existantes différentes ; SL/RQA/Mapbox non.
|
||
const isUpgrade = (b.matched.coord_src === 'delivery' || b.matched.coord_src === 'camping') && exHas &&
|
||
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
|
||
if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
|
||
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
|
||
// Adresse de SERVICE : backfill si jamais écrite (cas des 175 jobs historiques) ; et rafraîchit pour les jobs encore au pool (open + non assigné).
|
||
if (b.payload.address && (!ex.address || (ex.status === 'open' && !ex.assigned_tech && ex.address !== b.payload.address))) patch.address = b.payload.address
|
||
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
|
||
// Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné),
|
||
// sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation.
|
||
if (ex.status === 'open' && !ex.assigned_tech && b.payload.subject && ex.subject !== b.payload.subject) patch.subject = b.payload.subject
|
||
if (!dryRun && Object.keys(patch).length) {
|
||
const r = await erp.update('Dispatch Job', ex.name, patch)
|
||
if (r && r.ok) { updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) }
|
||
else { errors++; const msg = (r && r.error) || 'update failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'update', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'update-failed', job: ex.name, error: msg }) }
|
||
} else skipped++
|
||
} else if (dryRun) {
|
||
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, addr: b.addr })
|
||
} else {
|
||
const r = await erp.create('Dispatch Job', b.payload)
|
||
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
|
||
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
|
||
}
|
||
} catch (e) {
|
||
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
|
||
}
|
||
}
|
||
let closedResolved = 0
|
||
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
|
||
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
|
||
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
|
||
return { ...summary, details }
|
||
}
|
||
|
||
// Réconciliation : prouve qu'AUCUN ticket n'est échappé. Compare legacy(assign_to=3301, open) ↔ Dispatch Jobs (legacy_ticket_id).
|
||
async function reconcile () {
|
||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||
const [rows] = await p.query('SELECT id FROM ticket WHERE status = ? AND assign_to = ?', ['open', TARGO_TECH_STAFF_ID])
|
||
const legacyIds = new Set((rows || []).map(r => String(r.id)))
|
||
// Dispatch Jobs issus du pont (legacy_ticket_id renseigné)
|
||
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech'], limit: 5000 })
|
||
const erpIds = new Set((djs || []).map(j => String(j.legacy_ticket_id)))
|
||
const missing = [...legacyIds].filter(id => !erpIds.has(id)) // legacy ouvert mais PAS dans ERPNext = échappé → à corriger
|
||
// orphelins = DJ encore "open"/non assigné dont le ticket legacy n'est plus ouvert(3301) (fermé/réassigné côté legacy)
|
||
const stillOpen = (djs || []).filter(j => j.status === 'open' && !j.assigned_tech)
|
||
const orphan = stillOpen.filter(j => !legacyIds.has(String(j.legacy_ticket_id))).map(j => ({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }))
|
||
return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun }
|
||
}
|
||
|
||
// RÉIMPORT autoritaire des adresses de SERVICE depuis la table legacy `delivery` (point de service réel),
|
||
// pour TOUS les Dispatch Jobs issus du pont — y compris assignés/fermés que la sync (open + 3301) ne retouche pas.
|
||
// Écrit `address` (svcAddr delivery > billAddr) et remplit les coords MANQUANTES (0,0/NULL) depuis delivery
|
||
// (ne clobbe JAMAIS un job déjà géolocalisé → sécurité dispatch). Les jobs de CAMPING sont exclus : la `delivery`
|
||
// legacy y pointe la RÉSIDENCE du client (ce qu'on corrige justement par la géoloc fixe du camping).
|
||
// Passe par le verrou de sync (frappe_pg = zéro concurrence). dryRun => 0 écriture + échantillon des changements.
|
||
function reimportAddresses (opts = {}) {
|
||
const run = _syncLock.then(() => reimportAddressesImpl(opts), () => reimportAddressesImpl(opts))
|
||
_syncLock = run.then(() => {}, () => {})
|
||
return run
|
||
}
|
||
async function reimportAddressesImpl ({ dryRun = false, overwrite = true } = {}) {
|
||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'subject', 'address', 'latitude', 'longitude', 'status', 'assigned_tech'], limit: 5000 })
|
||
const ids = [...new Set((jobs || []).map(j => parseInt(j.legacy_ticket_id, 10)).filter(Number.isFinite))]
|
||
if (!ids.length) return { ok: true, dryRun, jobs: 0, legacy_found: 0, address_updated: 0, coords_filled: 0, camping_skipped: 0, skipped: 0, errors: 0, samples: [] }
|
||
// Même jointure `delivery` que la sync (delivery_id du ticket → repli delivery avec coords → n'importe quel delivery du compte).
|
||
const [rows] = await p.query(
|
||
`SELECT t.id,
|
||
dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip, dv.latitude AS dv_lat, dv.longitude AS dv_lon,
|
||
a.address1, a.address2, a.city AS a_city, a.state, a.zip AS a_zip
|
||
FROM ticket t
|
||
LEFT JOIN account a ON a.id = t.account_id
|
||
LEFT JOIN delivery dv ON dv.id = COALESCE(
|
||
NULLIF(t.delivery_id, 0),
|
||
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
|
||
)
|
||
WHERE t.id IN (?)`,
|
||
[ids],
|
||
)
|
||
const byId = new Map((rows || []).map(r => [String(r.id), r]))
|
||
const campings = await getCampings()
|
||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||
let addrUpd = 0, coordFill = 0, skipped = 0, campSkip = 0, errors = 0
|
||
const samples = []
|
||
for (const j of jobs) {
|
||
const r = byId.get(String(j.legacy_ticket_id)); if (!r) { skipped++; continue }
|
||
if (campingFor(campings, [j.subject, r.dv_city, r.dv_addr])) { campSkip++; continue } // camping → résidence non pertinente, on garde la géoloc camping
|
||
const svcAddr = [r.dv_addr, r.dv_city, r.dv_zip].filter(Boolean).join(', ')
|
||
const billAddr = [r.address1, r.address2, r.a_city, r.state, r.a_zip].filter(Boolean).join(', ')
|
||
const addr = (svcAddr || billAddr).slice(0, 140)
|
||
const patch = {}
|
||
if (addr && (overwrite ? norm(addr) !== norm(j.address || '') : !j.address)) patch.address = addr
|
||
const dc = coord(r.dv_lat, r.dv_lon) // coords delivery valides (bornes QC) ?
|
||
if (dc && !(hasCoord(j.latitude) && hasCoord(j.longitude))) { patch.latitude = dc.lat; patch.longitude = dc.lon } // remplit SEULEMENT si manquantes
|
||
if (!Object.keys(patch).length) { skipped++; continue }
|
||
if (patch.address) addrUpd++
|
||
if (patch.latitude != null) coordFill++
|
||
if (samples.length < 15) samples.push({ job: j.name, ticket: j.legacy_ticket_id, status: j.status, src: svcAddr ? 'delivery' : 'billing', from: (j.address || '').slice(0, 45), to: patch.address || '(adresse inchangée)', coords_filled: patch.latitude != null })
|
||
if (!dryRun) { const res = await erp.update('Dispatch Job', j.name, patch); if (!(res && res.ok)) errors++ }
|
||
}
|
||
return { ok: true, dryRun, jobs: jobs.length, legacy_found: rows.length, address_updated: addrUpd, coords_filled: coordFill, camping_skipped: campSkip, skipped, errors, samples }
|
||
}
|
||
|
||
// REMPLIT les coords MANQUANTES (0,0/NULL) des Dispatch Jobs qui ont une adresse mais pas de GPS → « hors carte ».
|
||
// Priorité : coords de la Service Location liée (si valides) → géocodage RQA (trigram) → Mapbox. Ne clobbe JAMAIS
|
||
// un job déjà géolocalisé. Couvre TOUTE source (legacy ET jobs de test/seed). Verrou de sync. dryRun => 0 écriture.
|
||
function fillMissingCoords (opts = {}) {
|
||
const run = _syncLock.then(() => fillMissingCoordsImpl(opts), () => fillMissingCoordsImpl(opts))
|
||
_syncLock = run.then(() => {}, () => {})
|
||
return run
|
||
}
|
||
const PC_RE = /[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d/ // code postal canadien
|
||
function splitAddr (address) { // "ligne, ville, code postal" → { line, city, postal } (tolérant)
|
||
const parts = String(address || '').split(',').map(s => s.trim()).filter(Boolean)
|
||
const line = parts[0] || ''
|
||
const last = parts[parts.length - 1] || ''
|
||
const postal = PC_RE.test(last) ? last : ''
|
||
const city = postal ? (parts[parts.length - 2] || '') : (parts.length > 1 ? parts[parts.length - 1] : '')
|
||
return { line, city, postal }
|
||
}
|
||
async function fillMissingCoordsImpl ({ dryRun = false } = {}) {
|
||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
|
||
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude)))
|
||
if (!targets.length) return { ok: true, dryRun, candidates: 0, filled: 0, via_service_location: 0, via_rqa: 0, via_mapbox: 0, failed: 0, samples: [] }
|
||
// Précharge les coords des Service Locations liées (1 requête).
|
||
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
|
||
const slCoord = new Map()
|
||
if (slCodes.length) {
|
||
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
|
||
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
|
||
}
|
||
let filled = 0, viaSL = 0, viaRQA = 0, viaMB = 0, failed = 0
|
||
const samples = []
|
||
for (const j of targets) {
|
||
let c = null, src = null
|
||
if (j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
|
||
if (!c) { const { line, city, postal } = splitAddr(j.address); const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } }
|
||
if (!c) { const { line, city, postal } = splitAddr(j.address); const mb = await geocodeMapbox(line, city, postal); if (mb) { c = mb; src = 'mapbox' } }
|
||
if (!c) { failed++; if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: 'ÉCHEC géocodage' }); continue }
|
||
if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: `${c.lat.toFixed(5)},${c.lon.toFixed(5)} via ${src}` })
|
||
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
|
||
filled++; if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else viaMB++
|
||
}
|
||
return { ok: true, dryRun, candidates: targets.length, filled, via_service_location: viaSL, via_rqa: viaRQA, via_mapbox: viaMB, failed, samples }
|
||
}
|
||
|
||
// CORRIGE les coords MANQUANTES *ou HORS-TERRITOIRE* (homonymes mal géocodés : Gaspésie/Outaouais/Estrie).
|
||
// Chaîne TOUT-EN-TERRITOIRE : camping → coords Service Location → RQA rue (gardé territoire) → centroïde code
|
||
// postal → centroïde ville. ÉCRASE une coord hors-zone (c'est le bug) ; remplit une coord manquante. Verrou de sync.
|
||
function fixGeocoding (opts = {}) {
|
||
const run = _syncLock.then(() => fixGeocodingImpl(opts), () => fixGeocodingImpl(opts))
|
||
_syncLock = run.then(() => {}, () => {})
|
||
return run
|
||
}
|
||
async function fixGeocodingImpl ({ dryRun = false } = {}) {
|
||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
|
||
// cibles = pas de coords valides EN territoire (manquantes OU hors-zone)
|
||
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude) && inTerritory(j.latitude, j.longitude)))
|
||
if (!targets.length) return { ok: true, dryRun, candidates: 0, fixed: 0, via_camping: 0, via_service_location: 0, via_rqa: 0, via_postal: 0, via_city: 0, failed: 0, samples: [] }
|
||
const campings = await getCampings()
|
||
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
|
||
const slCoord = new Map()
|
||
if (slCodes.length) {
|
||
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
|
||
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude) && inTerritory(s.latitude, s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
|
||
}
|
||
let fixed = 0, viaCamp = 0, viaSL = 0, viaRQA = 0, viaPostal = 0, viaCity = 0, failed = 0
|
||
const samples = []
|
||
for (const j of targets) {
|
||
const { line, city, postal } = splitAddr(j.address)
|
||
let c = null, src = null
|
||
const camp = campingFor(campings, [j.subject, j.address])
|
||
if (camp && inTerritory(camp.latitude, camp.longitude)) { c = { lat: Number(camp.latitude), lon: Number(camp.longitude) }; src = 'camping' }
|
||
if (!c && j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
|
||
if (!c) { const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } } // déjà gardé territoire
|
||
if (!c) { const ce = await geocodeCentroid(postal, city); if (ce) { c = ce; src = (String(postal || '').replace(/\s+/g, '').length >= 6) ? 'postal' : 'city' } }
|
||
if (!c) { failed++; if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), result: 'ÉCHEC (absent RQA)' }); continue }
|
||
if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), from: `${(+j.latitude || 0).toFixed(3)},${(+j.longitude || 0).toFixed(3)}`, to: `${c.lat.toFixed(4)},${c.lon.toFixed(4)} via ${src}` })
|
||
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
|
||
fixed++; if (src === 'camping') viaCamp++; else if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else if (src === 'postal') viaPostal++; else viaCity++
|
||
}
|
||
return { ok: true, dryRun, candidates: targets.length, fixed, via_camping: viaCamp, via_service_location: viaSL, via_rqa: viaRQA, via_postal: viaPostal, via_city: viaCity, failed, samples }
|
||
}
|
||
|
||
// PURGE des orphelins d'un ANCIEN import : jobs nommés `TT-<id>` SANS legacy_ticket_id dont le ticket legacy est
|
||
// `closed` (doublons/périmés — adresse réduite à la ville ; closeResolved ne peut PAS les voir faute de
|
||
// legacy_ticket_id). Supprime via l'API Frappe (nettoie les liens). NE touche JAMAIS un ticket encore `open`.
|
||
// Le record canonique reste côté legacy + les jobs `LEG-`. Verrou de sync. dryRun => 0 écriture.
|
||
function purgeStaleOrphans (opts = {}) {
|
||
const run = _syncLock.then(() => purgeStaleOrphansImpl(opts), () => purgeStaleOrphansImpl(opts))
|
||
_syncLock = run.then(() => {}, () => {})
|
||
return run
|
||
}
|
||
async function purgeStaleOrphansImpl ({ dryRun = false } = {}) {
|
||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||
const jobs = await erp.list('Dispatch Job', { filters: [['name', 'like', 'TT-%']], fields: ['name', 'status', 'subject', 'legacy_ticket_id'], limit: 500 })
|
||
const withId = (jobs || [])
|
||
.filter(j => !j.legacy_ticket_id) // seulement les orphelins (les jobs du pont actuel ont un legacy_ticket_id → intouchables)
|
||
.map(j => ({ ...j, tid: (String(j.name).match(/^TT-(\d+)$/) || [])[1] }))
|
||
.filter(j => j.tid)
|
||
if (!withId.length) return { ok: true, dryRun, candidates: 0, closed: 0, deleted: 0, kept_open: 0, kept_open_detail: [], errors: 0, samples: [] }
|
||
const ids = [...new Set(withId.map(j => parseInt(j.tid, 10)))]
|
||
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
|
||
const st = {}; for (const r of rows) st[String(r.id)] = r.status
|
||
const stale = withId.filter(j => st[j.tid] === 'closed') // périmés = ticket legacy fermé
|
||
const keptOpen = withId.filter(j => st[j.tid] !== 'closed').map(j => ({ name: j.name, legacy_status: st[j.tid] || 'introuvable' }))
|
||
let deleted = 0, errors = 0
|
||
const samples = []
|
||
for (const j of stale) {
|
||
if (samples.length < 25) samples.push({ name: j.name, subject: (j.subject || '').slice(0, 40) })
|
||
if (!dryRun) { const r = await erp.remove('Dispatch Job', j.name); if (r && r.ok) deleted++; else errors++ }
|
||
}
|
||
return { ok: true, dryRun, candidates: withId.length, closed: stale.length, deleted, kept_open: keptOpen.length, kept_open_detail: keptOpen, errors, samples }
|
||
}
|
||
|
||
// WRITE-BACK vers le legacy osTicket : pousse l'assignation tech décidée dans Ops → `ticket.assign_to`.
|
||
// Mapping TECH-<id> → staff legacy <id>, VALIDÉ par concordance de NOM (le staff legacy doit porter le même nom
|
||
// que le tech Ops → évite de pousser un tech de test/système mal mappé). Ne touche QUE les tickets encore `open`.
|
||
// Idempotent (saute si assign_to est déjà le bon). C'est un WRITE EXPLICITE vers le legacy — jamais automatique
|
||
// (le scheduler ne l'appelle pas) ; uniquement via la route POST. Verrou de sync. dryRun => 0 écriture + aperçu.
|
||
function pushAssignments (opts = {}) {
|
||
const run = _syncLock.then(() => pushAssignmentsImpl(opts), () => pushAssignmentsImpl(opts))
|
||
_syncLock = run.then(() => {}, () => {})
|
||
return run
|
||
}
|
||
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', '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
|
||
// Mapping PRINCIPAL par EMAIL : staff.email (legacy) = Dispatch Technician.email (= identité Authentik via ldap_id). Fiable, sans parsing.
|
||
const emails = [...new Set((techs || []).map(t => String(t.email || '').toLowerCase()).filter(Boolean))]
|
||
const [emRows] = emails.length ? await p.query('SELECT id, first_name, last_name, status, lower(email) AS email FROM staff WHERE status = 1 AND lower(email) IN (?)', [emails]) : [[]]
|
||
const staffByEmail = new Map((emRows || []).map(s => [s.email, s]))
|
||
// Repli : staff déduit de TECH-<id>, VALIDÉ par concordance de nom (pour les techs sans email).
|
||
const sufIds = [...new Set(jobs.map(j => (String(j.assigned_tech).match(/(\d{2,})$/) || [])[1]).filter(Boolean))]
|
||
const [sufRows] = sufIds.length ? await p.query('SELECT id, first_name, last_name, status FROM staff WHERE id IN (?)', [sufIds]) : [[]]
|
||
const staffById = new Map((sufRows || []).map(s => [String(s.id), s]))
|
||
const ids = [...new Set(jobs.map(j => parseInt(j.legacy_ticket_id, 10)).filter(Boolean))]
|
||
const [tk] = ids.length ? await p.query('SELECT id, status, assign_to FROM ticket WHERE id IN (?)', [ids]) : [[]]
|
||
const tkById = new Map((tk || []).map(r => [String(r.id), r]))
|
||
// Résout le staff legacy : email d'abord (autoritaire), sinon TECH-<id> validé par nom.
|
||
const resolveStaff = (techRow, assignedTech) => {
|
||
const email = String((techRow && techRow.email) || '').toLowerCase()
|
||
if (email && staffByEmail.has(email)) return { staff: staffByEmail.get(email), via: 'email' }
|
||
const sid = (String(assignedTech).match(/(\d{2,})$/) || [])[1]
|
||
const cand = sid ? staffById.get(sid) : null
|
||
if (cand) {
|
||
const a = norm((techRow && techRow.full_name) || ''); const b = norm((cand.first_name || '') + ' ' + (cand.last_name || ''))
|
||
if (a && b && (a === b || a.includes(b) || b.includes(a) || (a.split(' ')[0] === b.split(' ')[0] && a.split(' ').slice(-1)[0] === b.split(' ').slice(-1)[0]))) return { staff: cand, via: 'nom' }
|
||
}
|
||
return { staff: null, via: null }
|
||
}
|
||
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]
|
||
const { staff: st, via } = resolveStaff(techRow, j.assigned_tech)
|
||
const tkrow = tkById.get(String(j.legacy_ticket_id))
|
||
if (!st || Number(st.status) !== 1) { mismatch++; if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, tech: j.assigned_tech, ops_nom: (techRow && techRow.full_name) || '(?)', action: '⚠ staff legacy introuvable (ni email ni nom) — ignoré' }); continue }
|
||
const staffId = String(st.id)
|
||
if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas
|
||
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 || '', 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.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, 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 »
|
||
// (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL.
|
||
async function closeResolved () {
|
||
const p = pool(); if (!p) return { checked: 0, closed: 0 }
|
||
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold']]], fields: ['name', 'legacy_ticket_id', 'status'], limit: 5000 })
|
||
if (!djs.length) return { checked: 0, closed: 0 }
|
||
const ids = [...new Set(djs.map(j => parseInt(j.legacy_ticket_id)).filter(Boolean))]
|
||
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
|
||
const st = {}; for (const r of rows) st[String(r.id)] = r.status
|
||
let closed = 0; const details = []
|
||
for (const j of djs) {
|
||
if (st[j.legacy_ticket_id] === 'closed') { // fermé côté legacy → on retire d'ERPNext
|
||
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closed++; details.push({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }) } catch (e) {}
|
||
}
|
||
}
|
||
return { checked: djs.length, closed, details }
|
||
}
|
||
|
||
// Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only.
|
||
async function ticketThread (legacyId) {
|
||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||
const id = String(legacyId || '').replace(/[^0-9]/g, ''); if (!id) return { ok: false, error: 'id invalide' }
|
||
const [trows] = await p.query('SELECT subject, status FROM ticket WHERE id = ? LIMIT 1', [id])
|
||
const [rows] = await p.query(
|
||
`SELECT mm.id, mm.date_orig, mm.staff_id, s.first_name, s.last_name, s.username, mm.msg
|
||
FROM ticket_msg mm LEFT JOIN staff s ON s.id = mm.staff_id
|
||
WHERE mm.ticket_id = ? ORDER BY mm.id ASC LIMIT 200`, [id])
|
||
const messages = (rows || []).map(r => ({
|
||
at: r.date_orig ? new Date(Number(r.date_orig) * 1000).toISOString() : null,
|
||
author: [r.first_name, r.last_name].filter(Boolean).join(' ') || r.username || (r.staff_id ? ('Staff ' + r.staff_id) : 'Système / client'),
|
||
text: stripHtml(r.msg, 6000),
|
||
})).filter(m => m.text)
|
||
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-<id>) ↔ 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-<id>) 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)
|
||
function startSync () {
|
||
// OPT-IN : la récurrence ne démarre QUE si LEGACY_DISPATCH_SYNC ∈ {on,1,true}.
|
||
// (Évite toute écriture automatique surprise au boot ; preview/run manuels restent dispo via les routes.)
|
||
if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return }
|
||
if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
|
||
const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15
|
||
const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
|
||
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
|
||
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 } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
|
||
|
||
async function handle (req, res, method, path) {
|
||
try {
|
||
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
|
||
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
|
||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
|
||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
|
||
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'GET') return json(res, 200, await fillMissingCoords({ dryRun: true })) // aperçu géocodage des « hors carte »
|
||
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
|
||
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') { // É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'] || '')
|
||
if (r && r.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(id)]], 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/batch-close' && method === 'POST') { // ferme N tickets legacy en 1 appel + marque les Dispatch Jobs Completed
|
||
const ids = (new URL(req.url, 'http://localhost').searchParams.get('tickets') || '').split(',').map(s => s.trim()).filter(Boolean)
|
||
if (!ids.length) return json(res, 400, { ok: false, error: 'tickets requis' })
|
||
const r = await batchCloseLegacy(ids, req.headers['x-authentik-email'] || '')
|
||
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)) }
|
||
if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
|
||
const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null
|
||
const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks
|
||
return json(res, 200, { ok: true, enabled: /^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || '')), last_sync: _lastRun, age_min: ageMin, stale: ageMin == null || ageMin > max })
|
||
}
|
||
return json(res, 404, { error: 'route inconnue' })
|
||
} catch (e) {
|
||
return json(res, 500, { error: String((e && e.message) || e) })
|
||
}
|
||
}
|
||
|
||
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
|